Files
astro/moon/svg/occultation_limb_profile.go
T
b612 044f2f4381 feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
2026-10-15 23:54:15 +08:00

630 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/timenote"
"b612.me/astro/moon"
)
const (
limbProfileSVGDefaultWidth = 920
limbProfileSVGDefaultHeight = 620
limbProfileSVGMinimumWidth = 640
limbProfileSVGMinimumHeight = 480
limbProfileSVGMaxRows = 12
// limbProfilePanelGap 栏间距须容下上栏的时刻、位置角两行刻度与下栏上沿的两行接触编号。
limbProfilePanelGap = 56.0
// limbProfileInsetMinHeight 缩略图(含标题)的最小高度,接触表按它限行。
limbProfileInsetMinHeight = 120.0
)
// ErrInvalidStarOccultationLimbProfileSVGOptions 表示精密月掩剖面图选项无效 / invalid precise occultation profile chart options.
var ErrInvalidStarOccultationLimbProfileSVGOptions = errors.New("invalid stellar occultation limb profile SVG options")
// ErrEmptyStarOccultationLimbProfile 表示剖面没有采样,恒星全程在月缘起伏带外 / the profile has no samples; the star stays outside the relief band.
var ErrEmptyStarOccultationLimbProfile = errors.New("stellar occultation limb profile has no samples")
// ErrInvalidStarOccultationLimbProfile 表示剖面数据不是 StarOccultationLimbProfile 能给出的形态 / malformed limb profile data.
var ErrInvalidStarOccultationLimbProfile = errors.New("invalid stellar occultation limb profile")
// StarOccultationLimbProfileSVGOptions 控制精密恒星月掩的月缘剖面图 / options for the precise stellar occultation profile chart.
type StarOccultationLimbProfileSVGOptions struct {
// Width、Height 为画布尺寸,非正值用默认 / canvas size; non-positive uses defaults.
Width int
Height int
// Title 为空时自动生成 / empty uses an automatic title.
Title string
// Language 为 "en" 时英文,否则中文 / "en" for English, otherwise Chinese.
Language string
// Location 控制显示时刻,nil 为 UTC+8 / display zone; nil uses UTC+8.
Location *time.Location
// TimeScale 为 TimeScaleUT1 时改用 UT1 时刻,此时 Location 须为 nil 或 UTC / label time scale.
TimeScale astro.TimeScale
}
// StarOccultationLimbProfileSVG 渲染精密恒星月掩剖面:横轴时刻、纵轴相对参考圆月面高度(km),给出遮挡区间与全部掩入复现 / precise stellar occultation limb profile chart.
func StarOccultationLimbProfileSVG(profile moon.StarOccultationLimbProfileResult, options StarOccultationLimbProfileSVGOptions) (string, error) {
if options.Width > 0 && options.Width < limbProfileSVGMinimumWidth {
return "", fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidStarOccultationLimbProfileSVGOptions, limbProfileSVGMinimumWidth)
}
if options.Height > 0 && options.Height < limbProfileSVGMinimumHeight {
return "", fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationLimbProfileSVGOptions, limbProfileSVGMinimumHeight)
}
if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC {
return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location")
}
samples := 0
for _, segment := range profile.Segments {
samples += len(segment)
}
if samples < 2 || profile.Occultation.Greatest.IsZero() {
return "", ErrEmptyStarOccultationLimbProfile
}
if err := validateStarOccultationLimbProfile(profile); err != nil {
return "", err
}
if options.Width <= 0 {
options.Width = limbProfileSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = limbProfileSVGDefaultHeight
}
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
profile = moon.StarOccultationLimbProfileInUT1(profile)
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
}
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
options.Language = starOccultationSVGLanguageEnglish
} else {
options.Language = starOccultationSVGLanguageChinese
}
return renderStarOccultationLimbProfileSVG(profile, options), nil
}
type limbProfilePoint struct {
t time.Time
limbKM, starKM float64
positionAngle float64
// skyX、skyY 为恒星相对月心的天空平面坐标(km,向东、向北为正)。
skyX, skyY float64
}
// limbProfileHeights 把角量换算成月面上相对参考球的高度(km),恒星与月缘用同一比例。
func limbProfileHeights(segment []moon.StarOccultationLimbSample, referenceKM float64) []limbProfilePoint {
points := make([]limbProfilePoint, 0, len(segment))
for _, sample := range segment {
scale := referenceKM / sample.MeanRadiusArcsec
points = append(points, limbProfilePoint{
t: sample.Time,
limbKM: (sample.LimbRadiusArcsec - sample.MeanRadiusArcsec) * scale,
starKM: (sample.SeparationArcsec - sample.MeanRadiusArcsec) * scale,
positionAngle: sample.PositionAngleDeg,
skyX: sample.SeparationArcsec * scale * math.Sin(sample.PositionAngleDeg*math.Pi/180),
skyY: sample.SeparationArcsec * scale * math.Cos(sample.PositionAngleDeg*math.Pi/180),
})
}
return points
}
func limbProfileText(language, zh, en string) string {
if language == starOccultationSVGLanguageEnglish {
return en
}
return zh
}
func renderStarOccultationLimbProfileSVG(profile moon.StarOccultationLimbProfileResult, options StarOccultationLimbProfileSVGOptions) string {
info := profile.Occultation
width, height := float64(options.Width), float64(options.Height)
language := options.Language
segments := make([][]limbProfilePoint, 0, len(profile.Segments))
minKM, maxKM := math.Inf(1), math.Inf(-1)
for _, segment := range profile.Segments {
points := limbProfileHeights(segment, limbProfileReferenceKM(profile))
if len(points) < 2 {
continue
}
for _, point := range points {
minKM = math.Min(minKM, math.Min(point.limbKM, point.starKM))
maxKM = math.Max(maxKM, math.Max(point.limbKM, point.starKM))
}
segments = append(segments, points)
}
if !(maxKM > minKM) {
minKM, maxKM = -1, 1
}
padKM := (maxKM - minKM) * 0.08
minKM, maxKM = minKM-padKM, maxKM+padKM
title := options.Title
if title == "" {
target := info.TargetID
if target == "" {
target = limbProfileText(language, "恒星", "star")
}
date := info.Greatest.In(options.Location).Format("2006-01-02")
title = date + limbProfileText(language, " 精密月掩月缘剖面 · "+target, " Limb Profile · Occultation of "+target)
}
site := starOccultationFormatCoordinates(info.Observer.Longitude, info.Observer.Latitude)
summary := fmt.Sprintf(limbProfileText(language, "观测点 %s | 椭球高 %.0f 米 | 遮挡 %d 段 | 采样 %s", "Site %s | elevation %.0f m | %d hidden interval(s) | step %s"),
site, info.Observer.Height, len(profile.Hidden), profile.Step)
direction := limbProfileDirectionText(segments, profile.Hidden, language)
const top = 136.0
tableWidth := 260.0
left, right := 70.0, width-tableWidth-48
chartTop, chartBottom := top, height-122
panels := len(segments)
if panels < 1 {
panels = 1
}
panelHeight := (chartBottom - chartTop - limbProfilePanelGap*float64(panels-1)) / float64(panels)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`<marker id="limb-profile-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#d0473b"/></marker>`)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`, width-44, height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="46" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
width/2, starOccultationTitleFontSize(title, width), html.EscapeString(svgchart.EllipsizeText(title, width-80, float64(starOccultationTitleFontSize(title, width)))))
fmt.Fprintf(&b, `<text x="%.3f" y="76" fill="#222222" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(svgchart.EllipsizeText(summary, width-80, 13)))
fmt.Fprintf(&b, `<text x="%.3f" y="98" fill="#3b4143" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(svgchart.EllipsizeText(direction, width-80, 12)))
for index, points := range segments {
y0 := chartTop + float64(index)*(panelHeight+limbProfilePanelGap)
writeLimbProfilePanel(&b, points, profile.Hidden, left, right, y0, y0+panelHeight, minKM, maxKM, options)
}
// 接触表与图例让出缩略图的最小高度:图例占 84 px,表头 28 px,每行 19 px。
maxRows := int((chartBottom + 30 - limbProfileInsetMinHeight - 18 - top - 28 - 84) / 19)
if maxRows < 2 {
maxRows = 2
}
if maxRows > limbProfileSVGMaxRows {
maxRows = limbProfileSVGMaxRows
}
legendBottom := writeLimbProfileContacts(&b, profile, right+28, top, width-48, maxRows, options)
writeLimbProfileInset(&b, segments, info, limbProfileReferenceKM(profile), right+28, legendBottom+18, width-48, chartBottom+30, language)
writeLimbProfileFooter(&b, info, limbProfileReferenceKM(profile), left, height-66, width-left-40, options)
b.WriteString(`</svg>`)
return b.String()
}
func writeLimbProfilePanel(b *strings.Builder, points []limbProfilePoint, hidden []moon.OccultationInterval,
left, right, top, bottom, minKM, maxKM float64, options StarOccultationLimbProfileSVGOptions) {
start, end := points[0].t, points[len(points)-1].t
span := end.Sub(start).Seconds()
if span <= 0 {
span = 1
}
x := func(t time.Time) float64 { return left + (right-left)*t.Sub(start).Seconds()/span }
y := func(km float64) float64 { return bottom - (bottom-top)*(km-minKM)/(maxKM-minKM) }
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#fafaf8" stroke="#9a9a96" stroke-width="0.8"/>`, left, top, right-left, bottom-top)
for _, interval := range hidden {
if interval.End.Before(start) || interval.Start.After(end) {
continue
}
x0, x1 := x(laterTime(interval.Start, start)), x(earlierTime(interval.End, end))
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#3b4a5a" fill-opacity="0.16"/>`, x0, top, math.Max(x1-x0, 0.6), bottom-top)
}
if minKM < 0 && maxKM > 0 {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#b8b8b4" stroke-dasharray="4 3" stroke-width="0.8"/>`, left, y(0), right, y(0))
}
var limb, star strings.Builder
for index, point := range points {
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(&limb, "%s%.2f %.2f ", command, x(point.t), y(point.limbKM))
fmt.Fprintf(&star, "%s%.2f %.2f ", command, x(point.t), y(point.starKM))
}
fmt.Fprintf(b, `<path d="%s" fill="none" stroke="#6f5a3a" stroke-width="1.3"/>`, strings.TrimSpace(limb.String()))
fmt.Fprintf(b, `<path d="%s" fill="none" stroke="#9b3f36" stroke-width="1.1" stroke-dasharray="5 2"/>`, strings.TrimSpace(star.String()))
font := `font-family="Georgia, 'Times New Roman', serif"`
// 横轴按整刻度标时刻,下方配该时刻的位置角;刻度线贯穿剖面框作网格。
step := limbProfileTickStep(span, right-left)
majors := limbProfileTicks(start, end, step, options.Location)
for _, tick := range majors {
tx := x(tick)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e2e2de" stroke-width="0.6"/>`, tx, top, tx, bottom)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#9a9a96" stroke-width="0.8"/>`, tx, bottom, tx, bottom+3)
// 标签夹进剖面框,靠近两端时不越出框线,也不压住框外的 PA 轴标题。
label := tick.In(options.Location).Format(limbProfileTickLayout(step))
labelX := limbProfileClampLabel(tx, svgchart.EstimatedTextWidth(label, 10), left, right)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="middle">%s</text>`, labelX, bottom+14, font, label)
angle := limbProfileFormatAngle(limbProfilePositionAngleAt(points, tick))
angleX := limbProfileClampLabel(tx, svgchart.EstimatedTextWidth(angle, 9), left, right)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6f5a3a" %s font-size="9" text-anchor="middle">%s</text>`, angleX, bottom+26, font, angle)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6f5a3a" %s font-size="9" text-anchor="end">PA</text>`, left-4, bottom+26, font)
minor := step / 6
if step%time.Minute != 0 {
minor = step / 5
}
major := make(map[int64]bool, len(majors))
for _, tick := range majors {
major[tick.UnixNano()] = true
}
for _, tick := range limbProfileTicks(start, end, minor, options.Location) {
if major[tick.UnixNano()] {
continue
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#b8b8b4" stroke-width="0.6"/>`, x(tick), bottom, x(tick), bottom+2)
}
for _, label := range limbProfileContactLabels(hidden, start, end, x, left, right) {
labelY := top - 4 - float64(label.row)*11
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#3b4a5a" stroke-width="0.8"/>`, label.x, labelY+2, label.contactX, top+6)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4a5a" %s font-size="10" font-weight="700" text-anchor="middle">%d</text>`, label.x, labelY, font, label.number)
}
if ratio := limbProfileExaggeration(points, right-left, bottom-top, maxKM-minKM); ratio > 1.5 {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="10" text-anchor="end">%s</text>`, right-6, top+14, font,
html.EscapeString(fmt.Sprintf(limbProfileText(options.Language, "纵向放大约 %.0f 倍", "vertical ×%.0f"), ratio)))
} else if ratio > 0 && ratio < 1/1.5 {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="10" text-anchor="end">%s</text>`, right-6, top+14, font,
html.EscapeString(fmt.Sprintf(limbProfileText(options.Language, "横向放大约 %.0f 倍", "horizontal ×%.0f"), 1/ratio)))
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="end">%+.1f</text>`, left-4, top+9, font, maxKM)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="end">%+.1f</text>`, left-4, bottom, font, minKM)
}
const limbProfileContactLabelGap = 14.0
type limbProfileContactLabel struct {
number int
row int
x, contactX float64
}
// limbProfileContactLabels 接触编号两行排位后逐行自右向左回推,使其落在 [left, right] 内且同行间距不小于 14 px。
func limbProfileContactLabels(hidden []moon.OccultationInterval, start, end time.Time, x func(time.Time) float64, left, right float64) []limbProfileContactLabel {
var labels []limbProfileContactLabel
rows := [2][]int{}
rowEnd := [2]float64{math.Inf(-1), math.Inf(-1)}
number := 0
for _, interval := range hidden {
for _, contact := range []time.Time{interval.Start, interval.End} {
number++
if contact.Before(start) || contact.After(end) {
continue
}
cx := x(contact)
row := 0
if rowEnd[1] < rowEnd[0] {
row = 1
}
if cx-rowEnd[0] >= limbProfileContactLabelGap {
row = 0
}
labelX := math.Max(cx, rowEnd[row]+limbProfileContactLabelGap)
rowEnd[row] = labelX
rows[row] = append(rows[row], len(labels))
labels = append(labels, limbProfileContactLabel{number: number, row: row, x: labelX, contactX: cx})
}
}
halfWidth := func(label limbProfileContactLabel) float64 {
return svgchart.EstimatedTextWidth(fmt.Sprint(label.number), 10) / 2
}
for _, indices := range rows {
if len(indices) == 0 {
continue
}
limit := math.Inf(1)
for k := len(indices) - 1; k >= 0; k-- {
label := &labels[indices[k]]
label.x = math.Min(label.x, math.Min(limit, right-halfWidth(*label)))
limit = label.x - limbProfileContactLabelGap
}
// 一行塞不下时均分整行,间距小于 14 px 也不越框。
first, last := labels[indices[0]], labels[indices[len(indices)-1]]
if low, high := left+halfWidth(first), right-halfWidth(last); first.x < low && len(indices) > 1 {
for k, index := range indices {
labels[index].x = low + (high-low)*float64(k)/float64(len(indices)-1)
}
} else if first.x < low {
labels[indices[0]].x = low
}
}
return labels
}
func laterTime(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
func earlierTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
// writeLimbProfileContacts 返回图例底边,供缩略图接在下方。
func writeLimbProfileContacts(b *strings.Builder, profile moon.StarOccultationLimbProfileResult, left, top, right float64, maxRows int, options StarOccultationLimbProfileSVGOptions) float64 {
language := options.Language
font := `font-family="Georgia, 'Times New Roman', serif"`
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111111" %s font-size="13" font-weight="700">%s</text>`, left, top+4, font,
html.EscapeString(limbProfileText(language, "掩入 / 复现", "Disappearances / reappearances")))
rows := make([][2]string, 0, 2*len(profile.Hidden))
for _, interval := range profile.Hidden {
rows = append(rows,
[2]string{fmt.Sprintf("%d %s", len(rows)+1, limbProfileText(language, "掩入", "Disappears")), interval.Start.In(options.Location).Format("15:04:05.00")},
[2]string{fmt.Sprintf("%d %s", len(rows)+2, limbProfileText(language, "复现", "Reappears")), interval.End.In(options.Location).Format("15:04:05.00")})
}
if len(rows) == 0 {
rows = append(rows, [2]string{limbProfileText(language, "未发生遮挡", "Not occulted"), ""})
}
overflow := 0
if len(rows) > maxRows {
overflow = len(rows) - (maxRows - 1)
rows = rows[:maxRows-1]
}
for index, row := range rows {
baseline := top + 28 + float64(index)*19
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="12">%s</text>`, left, baseline, font, html.EscapeString(row[0]))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#222222" %s font-size="12" text-anchor="end">%s</text>`, right, baseline, font, row[1])
}
if overflow > 0 {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="11">%s</text>`, left, top+28+float64(len(rows))*19, font,
html.EscapeString(fmt.Sprintf(limbProfileText(language, "另有 %d 个接触未列出", "%d more contacts not listed"), overflow)))
}
listed := len(rows)
if overflow > 0 {
listed++
}
legendTop := top + 40 + float64(listed)*19
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#6f5a3a" stroke-width="1.3"/>`, left, legendTop, left+22, legendTop)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="11">%s</text>`, left+28, legendTop+4, font,
html.EscapeString(limbProfileText(language, "恒星方向月缘高度", "Limb height toward the star")))
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#9b3f36" stroke-width="1.1" stroke-dasharray="5 2"/>`, left, legendTop+18, left+22, legendTop+18)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="11">%s</text>`, left+28, legendTop+22, font,
html.EscapeString(limbProfileText(language, "恒星距参考圆月面(km)", "Star above the mean limb (km)")))
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="22" height="10" fill="#3b4a5a" fill-opacity="0.16"/>`, left, legendTop+31)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#3b4143" %s font-size="11">%s</text>`, left+28, legendTop+40, font,
html.EscapeString(limbProfileText(language, "恒星被遮挡", "Star hidden")))
return legendTop + 44
}
func writeLimbProfileFooter(b *strings.Builder, info moon.StarOccultationInfo, referenceKM, left, baseline, maxWidth float64, options StarOccultationLimbProfileSVGOptions) {
note := limbProfileText(options.Language,
"纵轴为月面上相对参考球 %.1f km 的高度;月缘按剖面表插值,短于采样步长的遮挡可能漏检。",
"Heights are relative to the %.1f km reference sphere; the limb is interpolated from the profile table and events shorter than the step may be missed.")
note = fmt.Sprintf(note, referenceKM)
lines := svgchart.TruncateTextLines(svgchart.WrapTextBalanced(note, maxWidth, 10), maxWidth, 10, 2)
lines = append(lines, svgchart.EllipsizeText(timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language), maxWidth, 10))
font := `font-family="Georgia, 'Times New Roman', serif"`
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" %s font-size="10">%s</text>`, left, baseline+float64(index)*14, font, html.EscapeString(line))
}
}
// limbProfileTrackKM 返回恒星在天空平面上走过的弦长(km,按月面尺度)。
func limbProfileTrackKM(points []limbProfilePoint) float64 {
first, last := points[0], points[len(points)-1]
return math.Hypot(last.skyX-first.skyX, last.skyY-first.skyY)
}
// limbProfileExaggeration 纵向放大倍数:横向每像素代表的月面距离与纵向之比。
func limbProfileExaggeration(points []limbProfilePoint, widthPX, heightPX, spanKM float64) float64 {
if widthPX <= 0 || heightPX <= 0 || spanKM <= 0 {
return 0
}
return (limbProfileTrackKM(points) / widthPX) / (spanKM / heightPX)
}
// limbProfileCompass 把位置角换成八方位名。
func limbProfileCompass(positionAngle float64, language string) string {
zh := []string{"北", "东北", "东", "东南", "南", "西南", "西", "西北"}
en := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
index := int(math.Floor(math.Mod(positionAngle+22.5+720, 360) / 45))
if language == starOccultationSVGLanguageEnglish {
return en[index]
}
return zh[index]
}
func limbProfileDirectionText(segments [][]limbProfilePoint, hidden []moon.OccultationInterval, language string) string {
if len(segments) == 0 {
return ""
}
first := segments[0][0]
lastSegment := segments[len(segments)-1]
last := lastSegment[len(lastSegment)-1]
if len(segments) == 1 {
return fmt.Sprintf(limbProfileText(language,
"恒星沿月缘自 PA %s(%s)移向 PA %s(%s),横向约 %.0f km;PA 自天北向东量",
"The star runs along the limb from PA %s (%s) to PA %s (%s), about %.0f km; PA measured from north through east"),
limbProfileFormatAngle(first.positionAngle), limbProfileCompass(first.positionAngle, language),
limbProfileFormatAngle(last.positionAngle), limbProfileCompass(last.positionAngle, language), limbProfileTrackKM(segments[0]))
}
// 深掩时取首次掩入与最后复现时刻的位置角,而非两栏端点。
immersion, emersion := first.positionAngle, last.positionAngle
if len(hidden) > 0 {
immersion = limbProfilePositionAngleAt(segments[0], hidden[0].Start)
emersion = limbProfilePositionAngleAt(lastSegment, hidden[len(hidden)-1].End)
}
return fmt.Sprintf(limbProfileText(language,
"恒星于 PA %s(%s)掩入、PA %s(%s)复现;上下两栏分别为掩入侧与复现侧,PA 自天北向东量",
"The star disappears at PA %s (%s) and reappears at PA %s (%s); panels show the two sides; PA measured from north through east"),
limbProfileFormatAngle(immersion), limbProfileCompass(immersion, language),
limbProfileFormatAngle(emersion), limbProfileCompass(emersion, language))
}
// limbProfileFormatAngle 按一位小数格式化位置角,舍入到 360.0° 时归一为 0.0°。
func limbProfileFormatAngle(positionAngle float64) string {
rounded := math.Round(positionAngle*10) / 10
if rounded >= 360 {
rounded -= 360
}
return fmt.Sprintf("%.1f°", rounded)
}
// writeLimbProfileInset 画掩甚时刻的站心月相缩略图(上北左东),加粗放大的月缘段并用箭头标出恒星运动方向。
func writeLimbProfileInset(b *strings.Builder, segments [][]limbProfilePoint, info moon.StarOccultationInfo, referenceKM, left, top, right, bottom float64, language string) {
size := math.Min(right-left, bottom-top-26)
if size < 60 || len(segments) == 0 {
return
}
font := `font-family="Georgia, 'Times New Roman', serif"`
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111111" %s font-size="12" font-weight="700">%s</text>`, left, top+10, font,
html.EscapeString(svgchart.EllipsizeText(limbProfileText(language, "月缘位置(上北左东)", "On the limb (N up, E left)"), right-left, 12)))
radius := size/2 - 16
cx, cy := left+(right-left)/2, top+26+size/2
writeLocalOccultationMoon(b, cx, cy, radius, "limb-profile-moon",
localOccultationMoonAppearanceAt(info.Greatest, info.Observer), "limb-profile-moon-phase")
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="middle">%s</text>`, cx, cy-radius-4, font, limbProfileText(language, "北", "N"))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" %s font-size="10" text-anchor="end">%s</text>`, cx-radius-4, cy+4, font, limbProfileText(language, "东", "E"))
limbPoint := func(positionAngle, scale float64) (float64, float64) {
angle := positionAngle * math.Pi / 180
return cx - scale*math.Sin(angle), cy - scale*math.Cos(angle)
}
for _, points := range segments {
first, last := points[0], points[len(points)-1]
x0, y0 := limbPoint(first.positionAngle, radius)
x1, y1 := limbPoint(last.positionAngle, radius)
sweep := 0
if signedPositionAngleStep(first.positionAngle, last.positionAngle) < 0 {
sweep = 1
}
fmt.Fprintf(b, `<path d="M%.2f %.2f A%.2f %.2f 0 0 %d %.2f %.2f" fill="none" stroke="#e0a32e" stroke-width="3.6"/>`, x0, y0, radius, radius, sweep, x1, y1)
}
// 恒星轨迹按真实比例画在月盘坐标里,深掩时两段之间连成穿过月面的弦。
kmToPX := radius / referenceKM
firstPoint := segments[0][0]
lastSegment := segments[len(segments)-1]
lastPoint := lastSegment[len(lastSegment)-1]
fmt.Fprintf(b, `<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d0473b" stroke-width="1.6" marker-end="url(#limb-profile-arrow)"/>`,
cx-firstPoint.skyX*kmToPX, cy-firstPoint.skyY*kmToPX, cx-lastPoint.skyX*kmToPX, cy-lastPoint.skyY*kmToPX)
}
// signedPositionAngleStep 返回自 from 到 to 的位置角变化(度),取 (−180, 180];负值为顺时针(向西)。
func signedPositionAngleStep(from, to float64) float64 {
delta := math.Mod(to-from+540, 360) - 180
return delta
}
// limbProfileTickStep 选使相邻时刻标签不重叠的最小整刻度步长。
func limbProfileTickStep(spanSeconds, widthPX float64) time.Duration {
for _, step := range []time.Duration{5 * time.Second, 10 * time.Second, 15 * time.Second, 20 * time.Second, 30 * time.Second,
time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute, 30 * time.Minute, time.Hour} {
if widthPX*step.Seconds()/spanSeconds >= 56 {
return step
}
}
return 2 * time.Hour
}
func limbProfileTickLayout(step time.Duration) string {
if step%time.Minute == 0 {
return "15:04"
}
return "15:04:05"
}
// limbProfilePositionAngleAt 在相邻样本间线性插值位置角,跨 0° 时按最短方向。
func limbProfilePositionAngleAt(points []limbProfilePoint, at time.Time) float64 {
for index := 1; index < len(points); index++ {
if points[index].t.Before(at) {
continue
}
previous, next := points[index-1], points[index]
span := next.t.Sub(previous.t).Seconds()
if span <= 0 {
return next.positionAngle
}
fraction := at.Sub(previous.t).Seconds() / span
return math.Mod(previous.positionAngle+fraction*signedPositionAngleStep(previous.positionAngle, next.positionAngle)+360, 360)
}
return points[len(points)-1].positionAngle
}
// limbProfileTicks 返回 [start, end] 内按显示时区本地时钟对齐 step 的全部刻度;每个刻度按它自身所在时刻的时区偏移判断,跨夏令时切换也落在当地整刻度上。
func limbProfileTicks(start, end time.Time, step time.Duration, location *time.Location) []time.Time {
// ceil 返回不早于 at、在当地时钟上是 step 整数倍的第一个时刻;结果若落到另一偏移下,按结果的偏移重算一次。
ceil := func(at time.Time) time.Time {
withOffset := func(at time.Time, offset int) time.Time {
shift := time.Duration(offset) * time.Second
tick := at.Add(shift).Truncate(step).Add(-shift)
if tick.Before(at) {
tick = tick.Add(step)
}
return tick
}
_, offset := at.In(location).Zone()
tick := withOffset(at, offset)
if _, actual := tick.In(location).Zone(); actual != offset {
tick = withOffset(at, actual)
}
return tick
}
var ticks []time.Time
for tick := ceil(start); !tick.After(end) && len(ticks) < 1000; tick = ceil(tick.Add(step)) {
ticks = append(ticks, tick)
}
return ticks
}
// limbProfileClampLabel 返回中锚标签的 x,使宽 width 的标签整体落在 [left, right] 内。
func limbProfileClampLabel(x, width, left, right float64) float64 {
return math.Max(left+width/2, math.Min(right-width/2, x))
}
// limbProfileReferenceKM 剖面结果声明的参考月球半径,旧结果未填时用 1737.4 km。
func limbProfileReferenceKM(profile moon.StarOccultationLimbProfileResult) float64 {
if profile.ReferenceRadiusKM > 0 && !math.IsInf(profile.ReferenceRadiusKM, 0) {
return profile.ReferenceRadiusKM
}
return 1737.4
}
// validateStarOccultationLimbProfile 拒绝 StarOccultationLimbProfile 给不出的数据:至多两栏、每栏至少两个样本且时间递增、角量有限、遮挡区间有序。
func validateStarOccultationLimbProfile(profile moon.StarOccultationLimbProfileResult) error {
if len(profile.Segments) > 2 {
return fmt.Errorf("%w: at most two segments, got %d", ErrInvalidStarOccultationLimbProfile, len(profile.Segments))
}
finiteValue := func(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
if !finiteValue(profile.ReferenceRadiusKM) || profile.ReferenceRadiusKM < 0 {
return fmt.Errorf("%w: reference radius must be finite and non-negative", ErrInvalidStarOccultationLimbProfile)
}
for index, segment := range profile.Segments {
if len(segment) < 2 {
return fmt.Errorf("%w: segment %d has fewer than two samples", ErrInvalidStarOccultationLimbProfile, index)
}
for sampleIndex, sample := range segment {
if !finiteValue(sample.SeparationArcsec) || !finiteValue(sample.LimbRadiusArcsec) ||
!finiteValue(sample.MeanRadiusArcsec) || sample.MeanRadiusArcsec <= 0 || !finiteValue(sample.PositionAngleDeg) {
return fmt.Errorf("%w: segment %d sample %d has non-finite geometry", ErrInvalidStarOccultationLimbProfile, index, sampleIndex)
}
if sampleIndex > 0 && !sample.Time.After(segment[sampleIndex-1].Time) {
return fmt.Errorf("%w: segment %d samples must increase in time", ErrInvalidStarOccultationLimbProfile, index)
}
}
}
for index, interval := range profile.Hidden {
if !interval.End.After(interval.Start) || (index > 0 && interval.Start.Before(profile.Hidden[index-1].End)) {
return fmt.Errorf("%w: hidden intervals must be ordered", ErrInvalidStarOccultationLimbProfile)
}
}
return nil
}