feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+14 -2
View File
@@ -3,6 +3,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -253,7 +254,8 @@ func renderOccultationPathSVG(
}
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
}
writeStarOccultationFooter(&b, layout, options, flags, occultationScaleInstant(path, planetPath))
writeStarOccultationFooter(&b, layout, options, flags, occultationScaleInstant(path, planetPath),
occultationLimbApplied(path, planetPath))
b.WriteString(`</svg>`)
return b.String(), nil
}
@@ -757,16 +759,26 @@ func occultationScaleInstant(path moon.StarOccultationPath, planetPath *moon.Pla
return path.Greatest.Time
}
// occultationLimbApplied 报告图中界线是否按月缘剖面修正。
func occultationLimbApplied(path moon.StarOccultationPath, planetPath *moon.PlanetOccultationPath) bool {
if planetPath != nil {
return planetPath.LunarLimbApplied
}
return path.LunarLimbApplied
}
func writeStarOccultationFooter(
b *strings.Builder,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
instant time.Time,
limbApplied bool,
) {
maxWidth := layout.width - 80
lineHeight := svgchart.FooterLineHeight(11)
scaleNote := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
scaleNote := limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Limits(limbApplied, options.Language), options.Language)
// 页脚默认排在画布底边之内(与既有版面一致),说明行放得下时声明单独占最后一行;
// 自定义说明太长时改用按字号留白的行数上限:截断说明也要保住时标声明和底边留白。
lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, maxWidth, 11)
+8 -4
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -11,6 +12,7 @@ import (
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
@@ -217,7 +219,7 @@ func occultationDetailedMoonRows(at time.Time, language string) []svgchart.Panel
jde := basic.UTC2TT(basic.Date2JD(at.UTC()))
ra, dec := basic.HMoonTrueRaDec(jde)
sd := basic.MoonSemidiameter(jde)
hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*6378.137/1737.4) * 180 / math.Pi * 3600
hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*geodata.EarthWGS84RadiusKM/1737.4) * 180 / math.Pi * 3600
rows := []svgchart.PanelRow{
{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)},
{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)},
@@ -363,7 +365,8 @@ func renderOccultationDetailedSVG(
return "", err
}
writeOccultationDetailedGrid(&b, view, blocks)
writeOccultationDetailedFooter(&b, view, options, occultationScaleInstant(path, planetPath))
writeOccultationDetailedFooter(&b, view, options, occultationScaleInstant(path, planetPath),
occultationLimbApplied(path, planetPath))
b.WriteString(`</svg>`)
return b.String(), nil
}
@@ -433,7 +436,7 @@ func writeOccultationDetailedSummary(
}
}
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions, instant time.Time) {
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions, instant time.Time, limbApplied bool) {
text := options.FooterNote
if text == "" {
if options.Language == starOccultationSVGLanguageEnglish {
@@ -448,7 +451,8 @@ func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailed
if options.FooterNote != "" {
lines = svgchart.WrapText(text, maxWidth, 11)
}
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
lines = svgchart.FooterLinesWithScale(lines, limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Limits(limbApplied, options.Language), options.Language),
maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), view.footerY,
view.height-svgchart.FooterBottomPadding(11)))
+14 -2
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"b612.me/astro/internal/ephemtest"
"b612.me/astro/moon"
)
@@ -155,6 +156,7 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
t.Fatalf("StarOccultationDetailedSVG() error = %v", err)
}
star := occultationSVGDetailedPanelRows(t, starDiagram)
semiValues := ephemtest.NewValues(t, "occultation_detailed_panel")
for _, expected := range []struct {
title, label, value string
}{
@@ -172,7 +174,12 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
{"天平动", "纬天平动 b", "-2.79°"},
{"天平动", "自转轴位置角 c", "-21.92°"},
} {
if got := occultationSVGPanelValue(t, star, expected.title, expected.label); got != expected.value {
got := occultationSVGPanelValue(t, star, expected.title, expected.label)
// 月心地心坐标随模型变化,与行星坐标行一样按模型取期望值。
if expected.title == "月亮(地心坐标)" && strings.HasPrefix(expected.label, "赤") {
expected.value = semiValues.WantString("moon."+expected.label, expected.value, got)
}
if got != expected.value {
t.Fatalf("stellar panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value)
}
}
@@ -195,7 +202,12 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
{"接触时刻", "全掩始", "10:30:00"},
{"接触时刻", "全掩终", "12:30:00"},
} {
if got := occultationSVGPanelValue(t, planet, expected.title, expected.label); got != expected.value {
got := occultationSVGPanelValue(t, planet, expected.title, expected.label)
// 新模型视位置更准,坐标行按模型取期望值。
if strings.HasPrefix(expected.label, "赤") {
expected.value = semiValues.WantString("planet."+expected.label, expected.value, got)
}
if got != expected.value {
t.Fatalf("planetary panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value)
}
}
+76
View File
@@ -0,0 +1,76 @@
package svg
import (
"strings"
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/moon"
)
type constantLimb float64
func (r constantLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return float64(r), true }
type failingLimb struct{}
func (failingLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
func TestLocalOccultationSVGAcceptsCustomLimbAndRejectsFailedLimbFlag(t *testing.T) {
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
find := func(profile basic.LunarLimb) moon.PlanetOccultationInfo {
t.Helper()
events, err := moon.FindPlanetOccultations(start, end, moon.OccultationJupiter,
-42.36358358336173, 21.762064240049245, 0, moon.OccultationSearchOptions{LunarLimb: profile})
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
return events[0]
}
circular := find(nil)
custom := find(constantLimb(1750))
if !custom.LunarLimbApplied {
t.Fatal("constant 1750 km limb: LunarLimbApplied = false, want true")
}
if custom.ExternalImmersion.Equal(circular.ExternalImmersion) {
t.Fatal("constant 1750 km limb did not move the contact")
}
diagram, err := LocalPlanetOccultationSVG(custom, LocalPlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG(custom limb) error = %v", err)
}
if !strings.Contains(diagram, "月缘剖面") {
t.Fatal("custom limb chart does not declare the limb profile")
}
failed := find(failingLimb{})
if failed.LunarLimbApplied {
t.Fatal("failing limb: LunarLimbApplied = true, want false")
}
if !failed.ExternalImmersion.Equal(circular.ExternalImmersion) || !failed.InternalEmersion.Equal(circular.InternalEmersion) {
t.Fatal("failing limb changed the circular contacts")
}
diagram, err = LocalPlanetOccultationSVG(failed, LocalPlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG(failed limb) error = %v", err)
}
if strings.Contains(diagram, "月缘剖面") || !strings.Contains(diagram, "圆月面") {
t.Fatal("failed limb chart must declare the circular limb")
}
}
func TestOccultationPathLimbFlagRequiresCorrectedLimit(t *testing.T) {
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(start, end, moon.OccultationJupiter,
moon.OccultationPathOptions{Step: 10 * time.Minute, TargetSpacingKM: 400, LunarLimb: failingLimb{}})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() = %d paths, %v; want one", len(paths), err)
}
if paths[0].LunarLimbApplied {
t.Fatal("failing limb: path LunarLimbApplied = true, want false")
}
}
+116
View File
@@ -0,0 +1,116 @@
package svg
// 月缘剖面精修过的接触时刻不落在圆月面上,站心图仍须能出图。
import (
"strings"
"testing"
"time"
"b612.me/astro/moon"
"b612.me/astro/moon/limb"
)
func TestLocalPlanetOccultationSVGAcceptsLunarLimbContacts(t *testing.T) {
events, err := moon.FindPlanetOccultations(
time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC),
time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC),
moon.OccultationJupiter, -42.36358358336173, 21.762064240049245, 0,
moon.OccultationSearchOptions{LunarLimb: limb.Default()},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
if !events[0].LunarLimbApplied {
t.Fatal("LunarLimbApplied = false, want true")
}
if _, err := LocalPlanetOccultationSVG(events[0], LocalPlanetOccultationSVGOptions{}); err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
}
func TestLocalStarOccultationSVGAcceptsLunarLimbContacts(t *testing.T) {
star := moon.StarCoordinate{
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
}
date := time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC)
events, err := moon.FindStarOccultations(date, date.Add(24*time.Hour), star,
24.493397847811025, -74.18160307712625, 0,
moon.OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v; want one", len(events), err)
}
if _, err := LocalStarOccultationSVG(events[0], star, LocalStarOccultationSVGOptions{}); err != nil {
t.Fatalf("LocalStarOccultationSVG() error = %v", err)
}
}
func TestOccultationSVGsDeclareLunarLimbConvention(t *testing.T) {
observer := [2]float64{-42.36358358336173, 21.762064240049245}
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
draw := func(applied bool) string {
t.Helper()
options := moon.OccultationSearchOptions{}
if applied {
options.LunarLimb = limb.Default()
}
events, err := moon.FindPlanetOccultations(start, end, moon.OccultationJupiter,
observer[0], observer[1], 0, options)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
if events[0].LunarLimbApplied != applied {
t.Fatalf("LunarLimbApplied = %v, want %v", events[0].LunarLimbApplied, applied)
}
diagram, err := LocalPlanetOccultationSVG(events[0], LocalPlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
return diagram
}
if circular := draw(false); !strings.Contains(circular, "圆月面") {
t.Fatal("circular chart does not declare the circular limb")
}
if corrected := draw(true); !strings.Contains(corrected, "月缘剖面") {
t.Fatal("limb chart does not declare the limb profile")
}
}
func TestOccultationPathSVGsDeclareLunarLimbConvention(t *testing.T) {
start := time.Date(2034, time.November, 21, 0, 0, 0, 0, time.UTC)
end := time.Date(2034, time.November, 23, 0, 0, 0, 0, time.UTC)
for _, applied := range []bool{false, true} {
options := moon.OccultationPathOptions{Step: 10 * time.Minute, TargetSpacingKM: 400}
if applied {
options.LunarLimb = limb.Default()
}
paths, err := moon.FindPlanetOccultationPaths(start, end, moon.OccultationJupiter, options)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() = %d paths, %v; want one", len(paths), err)
}
if paths[0].LunarLimbApplied != applied {
t.Fatalf("LunarLimbApplied = %v, want %v", paths[0].LunarLimbApplied, applied)
}
want := "月面按圆月面计算。"
if applied {
want = "界线按月缘剖面修正"
}
global, err := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
}
if !strings.Contains(global, want) {
t.Fatalf("global path chart (limb=%v) missing %q", applied, want)
}
detailed, err := PlanetOccultationDetailedSVG(paths[0], OccultationDetailedSVGOptions{Location: time.UTC})
if err != nil {
t.Fatalf("PlanetOccultationDetailedSVG() error = %v", err)
}
if !strings.Contains(detailed, want) {
t.Fatalf("detailed chart (limb=%v) missing %q", applied, want)
}
}
}
+629
View File
@@ -0,0 +1,629 @@
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
}
+373
View File
@@ -0,0 +1,373 @@
package svg
// 精密月掩剖面图:每段遮挡都要出现在接触表里,无样本或非法选项时返回错误。
import (
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgchart"
"b612.me/astro/moon"
"b612.me/astro/moon/limb"
)
var limbProfileTestStar = moon.StarCoordinate{
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
}
func limbProfileTestGraze(t *testing.T) moon.StarOccultationLimbProfileResult {
t.Helper()
start := time.Date(2026, time.February, 10, 12, 0, 0, 0, time.UTC)
events, err := moon.FindStarOccultations(start, start.Add(36*time.Hour), limbProfileTestStar,
41.5907, -55.07036, 0, moon.OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v; want one", len(events), err)
}
profile, err := moon.StarOccultationLimbProfile(events[0], limbProfileTestStar, limb.Default(), moon.StarOccultationLimbProfileOptions{})
if err != nil {
t.Fatal(err)
}
if len(profile.Hidden) < 2 {
t.Fatalf("hidden intervals = %d, want a multi-contact graze", len(profile.Hidden))
}
return profile
}
func TestStarOccultationLimbProfileSVGListsEveryContact(t *testing.T) {
profile := limbProfileTestGraze(t)
for _, language := range []string{"", "en"} {
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Language: language, Location: time.UTC})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(diagram, "<svg") || !strings.HasSuffix(diagram, "</svg>") {
t.Fatal("output is not a complete SVG document")
}
for _, interval := range profile.Hidden {
for _, instant := range []time.Time{interval.Start, interval.End} {
if label := instant.UTC().Format("15:04:05.00"); !strings.Contains(diagram, label) {
t.Errorf("language %q: contact %s missing", language, label)
}
}
}
if got := strings.Count(diagram, `fill-opacity="0.16"`); got < len(profile.Hidden) {
t.Errorf("language %q: %d shaded rects, want at least %d", language, got, len(profile.Hidden))
}
}
}
func TestStarOccultationLimbProfileSVGDeclaresUT1(t *testing.T) {
profile := limbProfileTestGraze(t)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diagram, "UT1") {
t.Error("UT1 chart does not declare the time scale")
}
if _, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("x", 3600)}); err == nil {
t.Error("UT1 with a non-UTC location accepted")
}
}
func TestStarOccultationLimbProfileSVGRejectsBadInput(t *testing.T) {
if _, err := StarOccultationLimbProfileSVG(moon.StarOccultationLimbProfileResult{}, StarOccultationLimbProfileSVGOptions{}); err == nil {
t.Error("empty profile accepted")
}
profile := limbProfileTestGraze(t)
if _, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Width: 100}); err == nil {
t.Error("tiny width accepted")
}
}
func TestStarOccultationLimbProfileSVGTextStaysInsideFrame(t *testing.T) {
profile := limbProfileTestGraze(t)
text := regexp.MustCompile(`<text x="([0-9.]+)"[^>]*font-size="([0-9]+)"([^>]*)>([^<]*)</text>`)
for _, language := range []string{"zh", "en"} {
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Language: language, Location: time.UTC})
if err != nil {
t.Fatal(err)
}
for _, match := range text.FindAllStringSubmatch(diagram, -1) {
x, _ := strconv.ParseFloat(match[1], 64)
size, _ := strconv.ParseFloat(match[2], 64)
width := svgchart.EstimatedTextWidth(match[4], size)
low, high := x, x+width
if strings.Contains(match[3], "middle") {
low, high = x-width/2, x+width/2
} else if strings.Contains(match[3], `"end"`) {
low, high = x-width, x
}
if low < 22 || high > limbProfileSVGDefaultWidth-22 {
t.Errorf("language %q: text [%.0f, %.0f] leaves the frame: %s", language, low, high, match[4])
}
}
}
}
func TestStarOccultationLimbProfileSVGDrawsPhasedMoonAndNumberedContacts(t *testing.T) {
profile := limbProfileTestGraze(t)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diagram, `href="#le-moon"`) || !strings.Contains(diagram, `class="limb-profile-moon"`) {
t.Fatal("inset does not use the textured, phased Moon")
}
if strings.Contains(diagram, `data-illumination="1.000000"`) {
t.Error("inset ignores the lunar phase")
}
for number := 1; number <= 2*len(profile.Hidden); number++ {
if !strings.Contains(diagram, `font-weight="700" text-anchor="middle">`+strconv.Itoa(number)+`</text>`) {
t.Errorf("contact %d is not numbered on the profile", number)
}
}
}
func limbProfileTestAt(t *testing.T, latitude float64) moon.StarOccultationLimbProfileResult {
t.Helper()
info := moon.StarOccultationInfo{Greatest: time.Date(2026, time.February, 11, 3, 47, 15, 0, time.UTC),
Observer: moon.Observer{Longitude: 41.5907, Latitude: latitude}}
if latitude < -55.5 {
start := time.Date(2026, time.February, 10, 12, 0, 0, 0, time.UTC)
events, err := moon.FindStarOccultations(start, start.Add(36*time.Hour), limbProfileTestStar, 41.5907, latitude, 0,
moon.OccultationSearchOptions{LunarLimb: limb.Default()})
if err != nil || len(events) != 1 {
t.Fatalf("FindStarOccultations() = %d events, %v", len(events), err)
}
info = events[0]
}
profile, err := moon.StarOccultationLimbProfile(info, limbProfileTestStar, limb.Default(), moon.StarOccultationLimbProfileOptions{})
if err != nil {
t.Fatal(err)
}
return profile
}
type limbProfileTestText struct {
x, y, width float64
text string
}
func limbProfileTestTexts(diagram string) []limbProfileTestText {
pattern := regexp.MustCompile(`<text x="([0-9.]+)" y="([0-9.]+)"[^>]*font-size="([0-9]+)"([^>]*)>([^<]*)</text>`)
var texts []limbProfileTestText
for _, match := range pattern.FindAllStringSubmatch(diagram, -1) {
x, _ := strconv.ParseFloat(match[1], 64)
y, _ := strconv.ParseFloat(match[2], 64)
size, _ := strconv.ParseFloat(match[3], 64)
width := svgchart.EstimatedTextWidth(match[5], size)
if strings.Contains(match[4], "middle") {
x -= width / 2
} else if strings.Contains(match[4], `"end"`) {
x -= width
}
texts = append(texts, limbProfileTestText{x, y, width, match[5]})
}
return texts
}
func TestStarOccultationLimbProfileSVGDeepCase(t *testing.T) {
profile := limbProfileTestAt(t, -57.0)
if len(profile.Segments) != 2 || len(profile.Hidden) != 1 || profile.Occultation.Type != moon.OccultationTotal {
t.Fatalf("deep profile: %d segments, %d intervals, type %v", len(profile.Segments), len(profile.Hidden), profile.Occultation.Type)
}
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
texts := limbProfileTestTexts(diagram)
for i, a := range texts {
for _, b := range texts[i+1:] {
if math.Abs(a.y-b.y) < 9 && a.x < b.x+b.width && b.x < a.x+a.width {
t.Errorf("texts overlap: %q at (%.0f,%.0f) and %q at (%.0f,%.0f)", a.text, a.x, a.y, b.text, b.x, b.y)
}
}
}
immersion := profile.Hidden[0].Start
want := ""
for _, sample := range profile.Segments[0] {
if !sample.Time.Before(immersion) {
want = fmt.Sprintf("PA %.1f°", sample.PositionAngleDeg)
break
}
}
if !strings.Contains(diagram, want) {
t.Errorf("direction text does not use the immersion position angle %s", want)
}
if !strings.Contains(diagram, "横向放大约") {
t.Error("compressed vertical scale is not labelled")
}
}
func TestStarOccultationLimbProfileSVGNotOcculted(t *testing.T) {
profile := limbProfileTestAt(t, -55.07036+0.8/111.2)
if len(profile.Hidden) != 0 || profile.Occultation.Type != moon.OccultationNone {
t.Fatalf("near-miss profile: %d intervals, type %v", len(profile.Hidden), profile.Occultation.Type)
}
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(diagram, "未发生遮挡") {
t.Error("not-occulted chart does not say so")
}
if _, err := StarOccultationLimbProfileSVG(moon.StarOccultationLimbProfileResult{}, StarOccultationLimbProfileSVGOptions{}); !errors.Is(err, ErrEmptyStarOccultationLimbProfile) {
t.Errorf("empty profile error = %v, want ErrEmptyStarOccultationLimbProfile", err)
}
}
func TestLimbProfileHelpers(t *testing.T) {
kathmandu := time.FixedZone("UTC+5:45", 5*3600+45*60)
start := time.Date(2026, 2, 11, 3, 3, 7, 0, time.UTC)
for _, step := range []time.Duration{10 * time.Minute, time.Hour} {
ticks := limbProfileTicks(start, start.Add(3*time.Hour), step, kathmandu)
if len(ticks) == 0 || ticks[0].Before(start) {
t.Fatalf("step %v: ticks %v", step, ticks)
}
for _, tick := range ticks {
local := tick.In(kathmandu)
if local.Sub(time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, kathmandu))%step != 0 {
t.Errorf("step %v: tick %v not aligned in the display zone", step, local)
}
}
}
// 豪勋爵岛夏令时只拨 30 分钟:切换后 20 分钟刻度仍须落在当地整 20 分上。
lordHowe, err := time.LoadLocation("Australia/Lord_Howe")
if err == nil {
switchStart := time.Date(2026, 4, 4, 14, 0, 0, 0, time.UTC)
for _, tick := range limbProfileTicks(switchStart, switchStart.Add(2*time.Hour), 20*time.Minute, lordHowe) {
if local := tick.In(lordHowe); local.Minute()%20 != 0 || local.Second() != 0 {
t.Errorf("Lord Howe tick %v is not on a local 20-minute mark", local)
}
}
}
if got := limbProfileFormatAngle(359.96); got != "0.0°" {
t.Errorf("359.96 formats as %s", got)
}
}
func TestStarOccultationLimbProfileSVGRejectsMalformedProfiles(t *testing.T) {
good := limbProfileTestGraze(t)
clone := func() moon.StarOccultationLimbProfileResult {
profile := good
profile.Segments = make([][]moon.StarOccultationLimbSample, len(good.Segments))
for index, segment := range good.Segments {
profile.Segments[index] = append([]moon.StarOccultationLimbSample(nil), segment...)
}
profile.Hidden = append([]moon.OccultationInterval(nil), good.Hidden...)
return profile
}
cases := map[string]func(*moon.StarOccultationLimbProfileResult){
"NaN position angle": func(p *moon.StarOccultationLimbProfileResult) { p.Segments[0][3].PositionAngleDeg = math.NaN() },
"NaN mean radius": func(p *moon.StarOccultationLimbProfileResult) { p.Segments[0][3].MeanRadiusArcsec = math.NaN() },
"Inf separation": func(p *moon.StarOccultationLimbProfileResult) { p.Segments[0][3].SeparationArcsec = math.Inf(1) },
"five segments": func(p *moon.StarOccultationLimbProfileResult) {
for len(p.Segments) < 5 {
p.Segments = append(p.Segments, p.Segments[0])
}
},
"time going back": func(p *moon.StarOccultationLimbProfileResult) {
p.Segments[0][4].Time = p.Segments[0][2].Time
},
"reversed interval": func(p *moon.StarOccultationLimbProfileResult) {
p.Hidden[0].Start, p.Hidden[0].End = p.Hidden[0].End, p.Hidden[0].Start
},
}
for name, mutate := range cases {
profile := clone()
mutate(&profile)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if !errors.Is(err, ErrInvalidStarOccultationLimbProfile) {
t.Errorf("%s: err = %v, want ErrInvalidStarOccultationLimbProfile", name, err)
}
if diagram != "" {
t.Errorf("%s: produced output", name)
}
}
}
func TestStarOccultationLimbProfileSVGLabelsNeverOverlap(t *testing.T) {
base := limbProfileTestGraze(t)
start := base.Segments[0][0].Time
end := base.Segments[0][len(base.Segments[0])-1].Time
// 8 段各 8 s 的密集遮挡,加上不同起点相位,覆盖编号与首刻度的碰撞。
for phase := 0; phase < 40; phase++ {
profile := base
shift := time.Duration(phase) * 1700 * time.Millisecond
profile.Segments = [][]moon.StarOccultationLimbSample{append([]moon.StarOccultationLimbSample(nil), base.Segments[0]...)}
for index := range profile.Segments[0] {
profile.Segments[0][index].Time = profile.Segments[0][index].Time.Add(shift)
}
profile.Hidden = nil
for k := 0; k < 8; k++ {
at := start.Add(shift).Add(end.Sub(start)/3 + time.Duration(k)*12*time.Second)
profile.Hidden = append(profile.Hidden, moon.OccultationInterval{Start: at, End: at.Add(8 * time.Second)})
}
profile.Occultation.Greatest = profile.Occultation.Greatest.Add(shift)
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
texts := limbProfileTestTexts(diagram)
for i, a := range texts {
for _, b := range texts[i+1:] {
if math.Abs(a.y-b.y) < 8 && a.x < b.x+b.width-0.5 && b.x < a.x+a.width-0.5 {
t.Fatalf("phase %d: %q at (%.1f,%.0f) overlaps %q at (%.1f,%.0f)", phase, a.text, a.x, a.y, b.text, b.x, b.y)
}
}
}
}
}
func TestStarOccultationLimbProfileSVGContactNumbersStayInsideFrame(t *testing.T) {
base := limbProfileTestGraze(t)
start := base.Segments[0][0].Time
end := base.Segments[0][len(base.Segments[0])-1].Time
const frameLeft, frameRight = 70.0, limbProfileSVGDefaultWidth - 260.0 - 48
for _, count := range []int{3, 6, 8, 12} {
for _, gap := range []time.Duration{2 * time.Second, 8 * time.Second} {
profile := base
profile.Hidden = nil
for k := count - 1; k >= 0; k-- {
at := end.Add(-time.Duration(k+1) * 2 * gap)
profile.Hidden = append(profile.Hidden, moon.OccultationInterval{Start: at, End: at.Add(gap)})
}
if profile.Hidden[0].Start.Before(start) {
t.Fatalf("%d intervals do not fit the profile span", count)
}
diagram, err := StarOccultationLimbProfileSVG(profile, StarOccultationLimbProfileSVGOptions{Location: time.UTC})
if err != nil {
t.Fatal(err)
}
var numbers []limbProfileTestText
for _, text := range limbProfileTestTexts(diagram) {
if _, err := strconv.Atoi(text.text); err == nil && text.y > 110 && text.y < 136 {
numbers = append(numbers, text)
}
}
if len(numbers) != 2*count {
t.Fatalf("%d intervals, gap %v: %d contact numbers, want %d", count, gap, len(numbers), 2*count)
}
for i, a := range numbers {
if a.x < frameLeft-0.5 || a.x+a.width > frameRight+0.5 {
t.Errorf("%d intervals, gap %v: number %s spans %.1f–%.1f, frame %.0f–%.0f", count, gap, a.text, a.x, a.x+a.width, frameLeft, frameRight)
}
for _, b := range numbers[i+1:] {
if a.y == b.y && a.x < b.x+b.width-0.5 && b.x < a.x+a.width-0.5 {
t.Errorf("%d intervals, gap %v: numbers %s and %s overlap", count, gap, a.text, b.text)
}
}
}
}
}
}
+18 -3
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -254,7 +255,7 @@ func renderLocalStarOccultationSVG(
writeLocalStarOccultationOverview(&b, diagram, events, layout, options)
writeLocalStarOccultationContacts(&b, events, layout, options)
writeLocalStarOccultationStages(&b, events, layout, options)
writeLocalStarOccultationFooter(&b, layout, options, info.Greatest)
writeLocalStarOccultationFooter(&b, layout, options, info.Greatest, info.LunarLimbApplied)
b.WriteString(`</svg>`)
return b.String()
}
@@ -374,6 +375,8 @@ func writeLocalStarOccultationTrack(
}
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.StarXArcsec), mapY(frame.StarYArcsec))
}
// 白色衬底保证轨迹叠在暗月面与亮月面上都可见。
fmt.Fprintf(b, `<path class="local-star-track-halo" d="%s" fill="none" stroke="#ffffff" stroke-width="3.6" stroke-linecap="round" opacity="0.7"/>`, strings.TrimSpace(path.String()))
fmt.Fprintf(b, `<path class="local-star-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
}
@@ -469,12 +472,22 @@ func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, l
}
}
// 被掩目标轮廓用浅金色,配暗色衬底,暗月面与亮月面上都能分辨。
const localOccultationHiddenOutlineColor = "#f3d48e"
func writeLocalOccultationHiddenOutlineHalo(b *strings.Builder, x, y, radius float64) {
fmt.Fprintf(b, `<circle class="hidden-outline-halo" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#1a1c1e" stroke-width="3" opacity="0.55"/>`, x, y, radius)
}
func writeLocalStarOccultationStar(b *strings.Builder, x, y float64, hidden bool, class, label string) {
fill, stroke, dash := "#fff7d6", "#9b3028", ""
if hidden {
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
fill, stroke, dash = "none", localOccultationHiddenOutlineColor, ` stroke-dasharray="3 2"`
}
fmt.Fprintf(b, `<g class="%s" data-label="%s">`, html.EscapeString(class), html.EscapeString(label))
if hidden {
writeLocalOccultationHiddenOutlineHalo(b, x, y, 4.2)
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="4.2" fill="%s" stroke="%s" stroke-width="1.4"%s/>`, x, y, fill, stroke, dash)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x-6, y, x+6, y, stroke)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x, y-6, x, y+6, stroke)
@@ -558,11 +571,13 @@ func writeLocalStarOccultationFooter(
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
instant time.Time,
limbApplied bool,
) {
directionLines, footerLines := localOccultationFooterLines(
localStarOccultationSVGDirectionText(options),
localStarOccultationSVGFooterNote(options),
timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Contacts(limbApplied, options.Language), options.Language),
layout.footerY, layout.width, layout.height)
for index, line := range directionLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
+14 -8
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"errors"
"fmt"
@@ -223,7 +224,7 @@ func renderLocalPlanetOccultationSVG(
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`<marker id="local-planet-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></marker>`)
b.WriteString(`<marker id="local-planet-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></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"/>`, layout.width-44, layout.height-36)
@@ -319,8 +320,6 @@ func writeLocalPlanetOccultationOverview(
table.Reserve(mapX(event.frame.PlanetXArcsec)-radius, mapY(event.frame.PlanetYArcsec)-radius, 2*radius, 2*radius)
}
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language, table)
writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY)
for _, event := range events {
writeLocalPlanetOccultationDisk(b,
mapX(event.frame.PlanetXArcsec), mapY(event.frame.PlanetYArcsec),
@@ -330,6 +329,8 @@ func writeLocalPlanetOccultationOverview(
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
"local-planet-occultation-moon-overview")
writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY)
overviewLabels := make([]localOccultationOverviewLabel, 0, len(events))
for _, event := range events {
x := mapX(event.frame.PlanetXArcsec)
@@ -378,7 +379,9 @@ func writeLocalPlanetOccultationTrack(
}
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.PlanetXArcsec), mapY(frame.PlanetYArcsec))
}
fmt.Fprintf(b, `<path class="local-planet-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-planet-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
// 白色衬底保证轨迹叠在暗月面与亮月面上都可见。
fmt.Fprintf(b, `<path class="local-planet-track-halo" d="%s" fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" opacity="0.7"/>`, strings.TrimSpace(path.String()))
fmt.Fprintf(b, `<path class="local-planet-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1" stroke-dasharray="4 3" opacity="0.85" marker-end="url(#local-planet-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
}
func writeLocalPlanetOccultationLunarPath(
@@ -447,7 +450,8 @@ func writeLocalPlanetOccultationDisk(
) {
fill, stroke, dash := "#d6ad69", "#704b2d", ""
if hidden {
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
writeLocalOccultationHiddenOutlineHalo(b, x, y, math.Max(radius, 0.15))
fill, stroke, dash = "none", localOccultationHiddenOutlineColor, ` stroke-dasharray="3 2"`
}
fmt.Fprintf(b, `<circle class="%s" data-label="%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" stroke="%s" stroke-width="1.2"%s/>`,
html.EscapeString(class), html.EscapeString(label), x, y, math.Max(radius, 0.15), fill, stroke, dash)
@@ -516,8 +520,9 @@ func writeLocalPlanetOccultationStages(
writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon",
event.appearance, fmt.Sprintf("local-planet-occultation-moon-stage-%d", index))
if event.hidden {
fmt.Fprintf(b, `<circle class="stage-planet-hidden-outline" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#7d3430" stroke-width="1.1" stroke-dasharray="2 2"/>`,
x, y, displayRadius)
writeLocalOccultationHiddenOutlineHalo(b, x, y, displayRadius)
fmt.Fprintf(b, `<circle class="stage-planet-hidden-outline" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="%s" stroke-width="1.4" stroke-dasharray="3 2"/>`,
x, y, displayRadius, localOccultationHiddenOutlineColor)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10.5" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy+radius+18, html.EscapeString(event.name))
@@ -560,7 +565,8 @@ func writeLocalPlanetOccultationFooter(
directionLines, footerLines := localOccultationFooterLines(
localPlanetOccultationSVGDirectionText(options),
localPlanetOccultationSVGFooterNote(info, options),
timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language),
limbnote.Join(timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language),
layout.footerY, layout.width, layout.height)
for index, line := range directionLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
+1 -1
View File
@@ -63,7 +63,7 @@ func TestOccultationSVGTimeScaleUT1(t *testing.T) {
}
}
// assertOccultationUT1Declaration 钉住 UT1 声明是可解析的 <text> 元素并写出 DUT1:曾经的裸文本写入不会渲染。
// assertOccultationUT1Declaration UT1 声明须是可解析的 <text> 元素并写出 DUT1。
func assertOccultationUT1Declaration(t *testing.T, name, diagram string) {
t.Helper()
for _, text := range occultationSVGTexts(t, diagram) {
@@ -0,0 +1,6 @@
{
"moon.赤纬 Dec.": "-00°01'34.4\"",
"moon.赤经 R.A.": "23h37m22.4s",
"planet.赤纬 Dec.": "+03°27'41.3\"",
"planet.赤经 R.A.": "00h58m06.2s"
}