feat: 完善时标与天象几何计算并扩展输出接口

- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
This commit is contained in:
2026-09-23 18:55:12 +08:00
parent 1f31a9b5b5
commit 16c62a97d5
503 changed files with 33290 additions and 9471 deletions
+107
View File
@@ -0,0 +1,107 @@
package svg
import (
"fmt"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,避免末行贴边或被裁掉。
const occultationChartBottomMarginMinimum = 6.0
func occultationBottomMarginViolations(t *testing.T, diagram string, height int) []string {
t.Helper()
violations := []string{}
for _, text := range occultationSVGTexts(t, diagram) {
if text.value == "" {
continue
}
_, below := svgchart.EstimatedTextExtents(text.fontSize)
if margin := float64(height) - (text.y + below); margin < occultationChartBottomMarginMinimum {
violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", text.value, margin, occultationChartBottomMarginMinimum))
}
}
return violations
}
func TestOccultationChartsKeepBottomMargin(t *testing.T) {
long := footerFitLongText("margin ")
starPath := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
cases := []struct {
name string
height int
document string
}{}
add := func(name string, height int, document string, err error) {
if err != nil {
t.Fatalf("%s: %v", name, err)
}
cases = append(cases, struct {
name string
height int
document string
}{name, height, document})
}
for _, size := range [][2]int{{640, 480}, {920, 720}} {
options := footerFitStarTexts(long)
options.Width, options.Height = size[0], size[1]
diagram, err := StarOccultationPathSVG(starPath, options)
add(fmt.Sprintf("恒星全球 %dx%d", size[0], size[1]), size[1], diagram, err)
planetOptions := PlanetOccultationSVGOptions(options)
planetDiagram, err := PlanetOccultationPathSVG(planetPath, planetOptions)
add(fmt.Sprintf("行星全球 %dx%d", size[0], size[1]), size[1], planetDiagram, err)
}
local, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Width: 640, Height: 520, Title: long, DirectionText: long, FooterNote: long,
})
add("站心恒星", 520, local, err)
localPlanet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{
Width: 760, Height: 600, Title: long, DirectionText: long, FooterNote: long,
})
add("站心行星", 600, localPlanet, err)
detailed, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), OccultationDetailedSVGOptions{
Width: 700, Height: 990, Title: long, FooterNote: long,
})
add("恒星详图", 990, detailed, err)
for _, tc := range cases {
if violations := occultationBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 {
t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0])
}
}
}
// 页脚时标声明契约:超长自定义说明可以吃掉说明文字,但不能吃掉时标声明。
func TestOccultationChartsKeepTimeScaleDeclaration(t *testing.T) {
long := footerFitLongText("scale ")
cst := time.FixedZone("CST", 8*3600)
starPath := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
options := footerFitStarTexts(long)
options.Width, options.Height = 920, 720
options.Location = cst
global, err := StarOccultationPathSVG(starPath, options)
if err != nil {
t.Fatalf("恒星全球:%v", err)
}
planetOptions := PlanetOccultationSVGOptions(options)
planet, err := PlanetOccultationPathSVG(planetPath, planetOptions)
if err != nil {
t.Fatalf("行星全球:%v", err)
}
local, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Width: 920, Height: 720, Location: cst, FooterNote: long,
})
if err != nil {
t.Fatalf("站心恒星:%v", err)
}
for name, document := range map[string]string{"恒星全球": global, "行星全球": planet, "站心恒星": local} {
if !strings.Contains(document, "显示时区") && !strings.Contains(document, "图中时刻为 UTC") {
t.Errorf("%s:超长说明把时标声明挤掉了", name)
}
}
}
+39 -5
View File
@@ -3,6 +3,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"errors"
"fmt"
"html"
@@ -10,6 +11,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
@@ -53,6 +55,11 @@ type StarOccultationSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 UTC+8。
// Location controls displayed event times. Nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale.
TimeScale astro.TimeScale
// Projection 选择全球地图投影;零值会为限于单半球的高纬事件自动使用极区地图。
// Projection selects the global map projection. The zero value automatically uses a polar map for a high-latitude event confined to one hemisphere.
Projection MapProjection
@@ -92,12 +99,20 @@ func StarOccultationPathSVG(
path moon.StarOccultationPath,
options StarOccultationSVGOptions,
) (string, error) {
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")
}
if err := validateStarOccultationPath(path); err != nil {
return "", err
}
if err := validateStarOccultationSVGOptions(options); err != nil {
return "", err
}
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
path = moon.StarOccultationPathInUT1(path)
}
options = normalizeStarOccultationSVGOptions(options)
diagram, err := renderStarOccultationPathSVG(path, options)
if err != nil {
@@ -238,7 +253,7 @@ func renderOccultationPathSVG(
}
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
}
writeStarOccultationFooter(&b, layout, options, flags)
writeStarOccultationFooter(&b, layout, options, flags, occultationScaleInstant(path, planetPath))
b.WriteString(`</svg>`)
return b.String(), nil
}
@@ -734,18 +749,37 @@ func writeOccultationEventsPanel(
}
}
// occultationScaleInstant 取时标声明用的掩甚时刻:行星带优先用行星路径。
func occultationScaleInstant(path moon.StarOccultationPath, planetPath *moon.PlanetOccultationPath) time.Time {
if planetPath != nil {
return planetPath.Greatest.Time
}
return path.Greatest.Time
}
func writeStarOccultationFooter(
b *strings.Builder,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
instant time.Time,
) {
lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, layout.width-80, 11)
lines = svgchart.TruncateTextLines(lines, layout.width-80, 11,
svgchart.BaselineLineLimit(11, 15, layout.footerY, layout.height))
maxWidth := layout.width - 80
lineHeight := svgchart.FooterLineHeight(11)
scaleNote := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
// 页脚默认排在画布底边之内(与既有版面一致),说明行放得下时声明单独占最后一行;
// 自定义说明太长时改用按字号留白的行数上限:截断说明也要保住时标声明和底边留白。
lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, maxWidth, 11)
if roomy := svgchart.BaselineLineLimit(11, lineHeight, layout.footerY, layout.height); len(lines) < roomy {
lines = append(lines, scaleNote)
} else {
tight := svgchart.BaselineLineLimit(11, lineHeight, layout.footerY,
layout.height-svgchart.FooterBottomPadding(11))
lines = svgchart.FooterLinesWithScale(lines, scaleNote, maxWidth, 11, tight)
}
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
layout.footerY+float64(index)*15, html.EscapeString(line))
layout.footerY+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}
+52 -16
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"errors"
"fmt"
"html"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
@@ -27,18 +29,30 @@ const (
)
// OccultationDetailedSVGOptions 控制详细版式的月掩星组合图。
// OccultationDetailedSVGOptions configures the detailed-layout occultation chart.
type OccultationDetailedSVGOptions struct {
// Width 与 Height 是画布尺寸;<=0 使用 1000x1414 的整页开本。
// 版式必须容得下地图与数据块,否则返回 ErrInvalidOccultationDetailedSVGOptions。
// Width and Height are the canvas size; <=0 uses the 1000x1414 whole-page format.
// The layout must fit the map and the data block, otherwise the renderer returns ErrInvalidOccultationDetailedSVGOptions.
Width int
Height int
// Language 为 "en" 时使用英文,其他值使用中文。
// Language renders English for "en" and Chinese for any other value.
Language string
// Location 控制显示时刻的时区;nil 使用 UTC+8。
// Location sets the zone of displayed instants; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale.
TimeScale astro.TimeScale
// TimeLabelStep 控制中心线上的时刻标签;零值用 30 分钟,负值禁用。
// TimeLabelStep controls the time labels along the center line; zero uses 30 minutes and a negative value disables them.
TimeLabelStep time.Duration
// Title 与 FooterNote 为空时自动生成。
// Title and FooterNote are generated automatically when left empty.
Title string
FooterNote string
}
@@ -200,7 +214,7 @@ func writeOccultationDetailedGrid(b *strings.Builder, view occultationDetailedVi
// occultationDetailedMoonRows 月亮的地心坐标;S.D./H.P. 由地心月距换算,与日食月食同口径。
func occultationDetailedMoonRows(at time.Time, language string) []svgchart.PanelRow {
jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true)
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
@@ -247,10 +261,18 @@ func StarOccultationDetailedSVG(
star moon.StarCoordinate,
options OccultationDetailedSVGOptions,
) (string, error) {
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")
}
if err := validateStarOccultationPath(path); err != nil {
return "", err
}
return renderOccultationDetailedSVG(path, star, nil, options)
civilGreatest := path.Greatest.Time
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
path = moon.StarOccultationPathInUT1(path)
}
return renderOccultationDetailedSVG(path, star, nil, civilGreatest, options)
}
// PlanetOccultationDetailedSVG 生成详细版式的行星月掩星图。
@@ -259,21 +281,31 @@ func PlanetOccultationDetailedSVG(
path moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) (string, error) {
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")
}
if err := validatePlanetOccultationPath(path); err != nil {
return "", err
}
civilGreatest := path.Greatest.Time
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
path = moon.PlanetOccultationPathInUT1(path)
}
// 中文标题与目标块要写"木星"这类中文行星名,不能直接用 TargetID 里的英文。
shape := planetOccultationStarShape(path, path.TargetID)
if options.Language == starOccultationSVGLanguageChinese {
shape.TargetID = planetOccultationChineseName(path.Planet)
}
return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, options)
return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, civilGreatest, options)
}
func renderOccultationDetailedSVG(
path moon.StarOccultationPath,
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
civilGreatest time.Time,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validateOccultationDetailedSVGOptions(options); err != nil {
@@ -326,12 +358,12 @@ func renderOccultationDetailedSVG(
return "", fmt.Errorf("planetary occultation detailed band geometry: %w", err)
}
}
blocks := occultationDetailedBlocks(path, star, planetPath, options)
blocks := occultationDetailedBlocks(path, star, planetPath, civilGreatest, options)
if err := validateOccultationDetailedBlocks(view, blocks); err != nil {
return "", err
}
writeOccultationDetailedGrid(&b, view, blocks)
writeOccultationDetailedFooter(&b, view, options)
writeOccultationDetailedFooter(&b, view, options, occultationScaleInstant(path, planetPath))
b.WriteString(`</svg>`)
return b.String(), nil
}
@@ -401,7 +433,7 @@ func writeOccultationDetailedSummary(
}
}
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions) {
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions, instant time.Time) {
text := options.FooterNote
if text == "" {
if options.Language == starOccultationSVGLanguageEnglish {
@@ -411,15 +443,18 @@ func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailed
}
}
maxWidth := view.width - 2*view.margin
// 默认说明本来就是单行,只有调用方文本才折行截断。
// 默认说明本来就是单行,只有调用方文本才折行截断;时标声明单独占一行。
lines := []string{text}
if options.FooterNote != "" {
lines = svgchart.TruncateTextLines(svgchart.WrapText(text, maxWidth, 11), maxWidth, 11,
svgchart.BaselineLineLimit(11, 15, view.footerY, view.height))
lines = svgchart.WrapText(text, maxWidth, 11)
}
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), view.footerY,
view.height-svgchart.FooterBottomPadding(11)))
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
view.margin, view.footerY+float64(index)*15, html.EscapeString(line))
view.margin, view.footerY+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}
@@ -431,7 +466,7 @@ func occultationDetailedTargetRows(
language string,
) []svgchart.PanelRow {
english := language == starOccultationSVGLanguageEnglish
jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true)
jde := basic.UTC2TT(basic.Date2JD(at.UTC()))
rows := []svgchart.PanelRow{{Label: "目标", Value: star.ID}}
if english {
rows[0].Label = "Target"
@@ -511,6 +546,7 @@ func occultationDetailedBlocks(
path moon.StarOccultationPath,
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
civilGreatest time.Time,
options OccultationDetailedSVGOptions,
) []svgchart.PanelBlock {
english := options.Language == starOccultationSVGLanguageEnglish
@@ -550,15 +586,15 @@ func occultationDetailedBlocks(
}
ephemeris := []svgchart.PanelRow{
{Label: name("投影", "Projection"), Value: name("正射球面", "Orthographic")},
{Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true), true))},
{Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)))},
{Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.UTC2TT(basic.Date2JD(civilGreatest.UTC())), true))},
{Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.UTC2TT(basic.Date2JD(civilGreatest.UTC()))))},
{Label: name("部分掩带宽", "Partial-band width"), Value: fmt.Sprintf("%.1f km", partialWidth)},
}
if planetPath != nil && planetPath.HasTotalBand {
ephemeris = append(ephemeris, svgchart.PanelRow{Label: name("全掩带宽", "Total-band width"),
Value: fmt.Sprintf("%.1f km", planetPath.GreatestTotalWidthKM)})
}
jde := basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)
jde := basic.UTC2TT(basic.Date2JD(civilGreatest.UTC()))
physical := basic.MoonPhysical(jde)
libration := []svgchart.PanelRow{
{Label: name("经天平动 l", "Libration l"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLongitude)},
@@ -580,8 +616,8 @@ func occultationDetailedBlocks(
contacts = append(contacts, svgchart.PanelRow{Label: name("外掩终", "Partial ends"), Value: format(path.End.Time)})
return []svgchart.PanelBlock{
{Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(path.Greatest.Time, options.Language)},
{Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, path.Greatest.Time, options.Language)},
{Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(civilGreatest, options.Language)},
{Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, civilGreatest, options.Language)},
{Title: name("掩带路径点", "Band path points"), Rows: pathRows},
{Title: name("接触时刻", "Contacts"), Rows: contacts},
{Title: name("历表与常数", "Ephemeris and constants"), Rows: ephemeris},
+1 -1
View File
@@ -159,7 +159,7 @@ func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
title, label, value string
}{
{"月亮(地心坐标)", "赤经 R.A.", "23h37m22.4s"},
{"月亮(地心坐标)", "赤纬 Dec.", "-00°01'34.0\""},
{"月亮(地心坐标)", "赤纬 Dec.", "-00°01'34.1\""},
{"月亮(地心坐标)", "视半径 S.D.", "00°15'25.5\""},
{"月亮(地心坐标)", "地平视差 H.P.", "00°56'37.7\""},
{"目标天体", "目标", "HR 4799"},
+59 -37
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"errors"
"fmt"
"html"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/moon"
@@ -55,6 +57,11 @@ type LocalStarOccultationSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 UTC+8。
// Location controls displayed event times. Nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale.
TimeScale astro.TimeScale
}
type localStarOccultationSVGLayout struct {
@@ -115,12 +122,19 @@ func LocalStarOccultationSVG(
star moon.StarCoordinate,
options LocalStarOccultationSVGOptions,
) (string, error) {
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")
}
if err := validateLocalStarOccultationInfo(info, star); err != nil {
return "", err
}
if err := validateLocalStarOccultationSVGOptions(options); err != nil {
return "", err
}
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
}
options = normalizeLocalStarOccultationSVGOptions(options)
diagram := moon.StarOccultationDiagram(info, star, moon.StarOccultationDiagramOptions{
StepDays: options.Step.Hours() / 24,
@@ -128,6 +142,9 @@ func LocalStarOccultationSVG(
if len(diagram.Frames) == 0 {
return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalStarOccultationInfo)
}
if options.TimeScale == astro.TimeScaleUT1 {
info = moon.StarOccultationInfoInUT1(info)
}
return renderLocalStarOccultationSVG(info, diagram, options), nil
}
@@ -237,7 +254,7 @@ func renderLocalStarOccultationSVG(
writeLocalStarOccultationOverview(&b, diagram, events, layout, options)
writeLocalStarOccultationContacts(&b, events, layout, options)
writeLocalStarOccultationStages(&b, events, layout, options)
writeLocalStarOccultationFooter(&b, layout, options)
writeLocalStarOccultationFooter(&b, layout, options, info.Greatest)
b.WriteString(`</svg>`)
return b.String()
}
@@ -250,7 +267,9 @@ func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, he
panelWidth := math.Max(188, math.Min(236, width*0.25))
overviewLeft := margin
overviewRight := width - margin - panelWidth - gap
footerSpace := 64.0
// 页脚末行按 9px 字号留 12 px 底边留白,预留高度与页脚起始位置随之上移,
// 否则英文默认说明会被截到只剩一行、时标声明整行消失。
footerSpace := 74.0
stageHeight := math.Max(132, math.Min(170, height*0.23))
stageBottom := height - footerSpace
stageTop := stageBottom - stageHeight
@@ -268,7 +287,7 @@ func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, he
panelWidth: panelWidth,
stageTop: stageTop,
stageBottom: stageBottom,
footerY: height - 43,
footerY: height - 49,
headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight),
}
}
@@ -297,18 +316,28 @@ func writeLocalStarOccultationOverview(
mapY := func(value float64) float64 { return cy - value*scale }
moonRadius := localStarOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
table := &svgchart.LabelTable{}
table.ReserveText(layout.overviewLeft, layout.overviewTop-10, 14, overviewTitle, "start")
table.Reserve(layout.panelX-12, layout.overviewTop-16, layout.width-(layout.panelX-12), layout.height-layout.overviewTop)
table.Reserve(cx-moonRadius, cy-moonRadius, 2*moonRadius, 2*moonRadius)
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language, table)
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
"local-star-occultation-moon-overview")
writeLocalStarOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalStarOccultationTrack(b, diagram.Frames, mapX, mapY)
overviewLabels := make([]localOccultationOverviewLabel, 0, len(events))
for _, event := range events {
x := mapX(event.frame.StarXArcsec)
y := mapY(event.frame.StarYArcsec)
writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "overview-star", event.label)
writeLocalStarOccultationOverviewLabel(b, event, x, y, cx, cy, options.Language)
overviewLabels = append(overviewLabels, localOccultationOverviewLabel{
text: localStarOccultationSVGEventName(event.label, options.Language),
x: x,
y: y,
})
}
writeLocalOccultationOverviewLabels(b, overviewLabels, table, cx, cy, moonRadius, "#792d28")
}
func localStarOccultationSVGExtent(frames []moon.StarOccultationDiagramFrame) float64 {
@@ -416,7 +445,7 @@ func localStarOccultationSVGLunarPathLabel(language string) string {
return "白道"
}
func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string) {
func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string, table *svgchart.LabelTable) {
north, east, west, south := "北", "东", "西", "南"
if language == starOccultationSVGLanguageEnglish {
north, east, west, south = "N", "E", "W", "S"
@@ -432,6 +461,9 @@ func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, l
{cx, cy + radius + 24, "middle", south},
}
for _, label := range labels {
if table != nil {
table.ReserveText(label.x, label.y, 12, label.text, label.anchor)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700" text-anchor="%s">%s</text>`,
label.x, label.y, label.anchor, html.EscapeString(label.text))
}
@@ -448,24 +480,6 @@ func writeLocalStarOccultationStar(b *strings.Builder, x, y float64, hidden bool
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x, y-6, x, y+6, stroke)
b.WriteString(`</g>`)
}
func writeLocalStarOccultationOverviewLabel(
b *strings.Builder,
event localStarOccultationEventFrame,
x, y, cx, cy float64,
language string,
) {
dx, dy, anchor := 8.0, -9.0, "start"
if x > cx {
dx, anchor = -8, "end"
}
if y < cy-20 {
dy = 15
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#792d28" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(localStarOccultationSVGEventName(event.label, language)))
}
func writeLocalStarOccultationContacts(
b *strings.Builder,
events []localStarOccultationEventFrame,
@@ -543,10 +557,12 @@ func writeLocalStarOccultationFooter(
b *strings.Builder,
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
instant time.Time,
) {
directionLines, footerLines := localOccultationFooterLines(
localStarOccultationSVGDirectionText(options), options.DirectionText != "",
localStarOccultationSVGFooterNote(options), options.FooterNote != "",
localStarOccultationSVGDirectionText(options),
localStarOccultationSVGFooterNote(options),
timenote.Scale(options.TimeScale, instant, options.Location, 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>`,
@@ -613,23 +629,29 @@ func localStarOccultationSVGHeaderLines(info moon.StarOccultationInfo, options L
return lines
}
// localOccultationFooterLines 排布本地图页脚:补充说明贴画布底边,方向说明占用上方余高,两块都按行数上限截断。
// localOccultationFooterLines 排布本地图页脚:时标声明并进补充说明后一起均衡折行,方向说明占用上方余高,两块都按行数上限截断。
func localOccultationFooterLines(
directionText string, directionCustom bool,
noteText string, noteCustom bool,
directionText, noteText, scaleNote string,
footerY, width, height float64,
) ([]string, []string) {
maxWidth := width - 80
direction := occultationWrappedTextLines(directionText, directionCustom, maxWidth, 10)
note := occultationWrappedTextLines(noteText, noteCustom, maxWidth, 9)
direction := svgchart.WrapTextBalanced(directionText, maxWidth, 10)
// 默认把时标声明并进说明一起均衡折行;说明太长折不下时改成"说明截断 + 声明独占最后一行"。
mergedLines := svgchart.WrapTextBalanced(strings.TrimSpace(noteText+" "+scaleNote), maxWidth, 9)
notesOnly := svgchart.WrapTextBalanced(strings.TrimSpace(noteText), maxWidth, 9)
noteAbove, noteBelow := svgchart.EstimatedTextExtents(9)
noteHeight := noteAbove + float64(len(note)-1)*12 + noteBelow
noteHeight := noteAbove + float64(len(mergedLines)-1)*12 + noteBelow
direction = svgchart.TruncateTextLines(direction, maxWidth, 10,
svgchart.BaselineLineLimit(10, 14, footerY-8, height-2-noteHeight))
svgchart.BaselineLineLimit(10, 14, footerY-8, height-svgchart.FooterBottomPadding(9)-noteHeight))
noteFirst := footerY + float64(len(direction))*14 - 6
note = svgchart.TruncateTextLines(note, maxWidth, 9,
svgchart.BaselineLineLimit(9, 12, noteFirst, height-2))
return direction, note
maxNoteLines := svgchart.BaselineLineLimit(9, 12, noteFirst, height-svgchart.FooterBottomPadding(9))
noteLines := mergedLines
if len(mergedLines) > maxNoteLines {
noteLines = svgchart.FooterLinesWithScale(notesOnly, scaleNote, maxWidth, 9, maxNoteLines)
} else {
noteLines = svgchart.TruncateTextLines(mergedLines, maxWidth, 9, maxNoteLines)
}
return direction, noteLines
}
// localOccultationHeaderLineLimit 由版式的页脚预留与阶段区高度推出页眉行数上限,总览区被压到 120 px 以下时圆盘与方向标记会跑出画布。
@@ -676,7 +698,7 @@ func localStarOccultationSVGSummaryText(info moon.StarOccultationInfo, options L
return fmt.Sprintf("Site %s | elevation %.0f m | %s | duration %s", coordinates, info.Observer.Height,
localStarOccultationSVGTypeName(info.Type, options.Language), duration)
}
return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | 掩星历时 %s", coordinates, info.Observer.Height,
return fmt.Sprintf("观测点 %s | 椭球高 %.0f 米 | %s | 掩星历时 %s", coordinates, info.Observer.Height,
localStarOccultationSVGTypeName(info.Type, options.Language), duration)
}
@@ -0,0 +1,176 @@
package svg
import (
"encoding/xml"
"io"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 本地月掩页脚契约:方向说明与补充说明(含时标声明)都排在可用宽度内,同一块各行宽度接近,
// 不留孤立短尾行,且显示时区的偏移只写一次。
const (
localOccultationFooterDirectionFill = "#4f595b"
localOccultationFooterNoteFill = "#747c7d"
)
type localOccultationFooterText struct {
fill string
fontSize float64
value string
}
func localOccultationFooterTexts(t *testing.T, diagram string) []localOccultationFooterText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
texts := []localOccultationFooterText{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("decode SVG: %v", err)
}
element, ok := token.(xml.StartElement)
if !ok || element.Name.Local != "text" {
continue
}
text := localOccultationFooterText{}
for _, attribute := range element.Attr {
switch attribute.Name.Local {
case "fill":
text.fill = attribute.Value
case "font-size":
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
}
}
if text.fill != localOccultationFooterDirectionFill && text.fill != localOccultationFooterNoteFill {
continue
}
var content string
if err := decoder.DecodeElement(&content, &element); err != nil {
t.Fatalf("decode text element: %v", err)
}
text.value = content
texts = append(texts, text)
}
return texts
}
func localOccultationFooterWidths(lines []localOccultationFooterText) ([]string, []float64) {
values := make([]string, 0, len(lines))
widths := make([]float64, 0, len(lines))
for _, line := range lines {
values = append(values, line.value)
widths = append(widths, svgchart.EstimatedTextWidth(line.value, line.fontSize))
}
return values, widths
}
func localOccultationFooterSpread(lines []localOccultationFooterText) float64 {
_, widths := localOccultationFooterWidths(lines)
minimum, maximum := widths[0], widths[0]
for _, width := range widths {
minimum = math.Min(minimum, width)
maximum = math.Max(maximum, width)
}
return maximum / minimum
}
func localOccultationFooterGroup(lines []localOccultationFooterText, fill string) []localOccultationFooterText {
group := []localOccultationFooterText{}
for _, line := range lines {
if line.fill == fill {
group = append(group, line)
}
}
return group
}
// 说明块各行的最大最小宽度比不得超过 15%,整块最短行不得短于最长行的 60%(旧实现里时标声明独占的短尾行只有 25%~47%)。
func TestLocalOccultationFooterLinesAreEven(t *testing.T) {
location := time.FixedZone("UTC+08:00", 8*3600)
const width, height = 920, 720
for _, language := range []string{"zh", "en"} {
star, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Language: language, Location: location, Width: width, Height: height,
})
if err != nil {
t.Fatalf("star %s: %v", language, err)
}
planet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{
Language: language, Location: location, Width: width, Height: height,
})
if err != nil {
t.Fatalf("planet %s: %v", language, err)
}
for name, diagram := range map[string]string{"star": star, "planet": planet} {
lines := localOccultationFooterTexts(t, diagram)
if len(lines) < 2 {
t.Fatalf("%s %s: footer lines = %#v, want the direction and note blocks", name, language, lines)
}
values, widths := localOccultationFooterWidths(lines)
for index, line := range lines {
if limit := float64(width) - 80; widths[index] > limit {
t.Fatalf("%s %s: footer line %q width %.1f exceeds %.1f", name, language, line.value, widths[index], limit)
}
if strings.Contains(line.value, "…") {
t.Fatalf("%s %s: default footer text is truncated: %q", name, language, line.value)
}
}
note := localOccultationFooterGroup(lines, localOccultationFooterNoteFill)
if len(note) == 0 {
t.Fatalf("%s %s: missing the footer note block", name, language)
}
// 说明块与方向说明行字号不同(fs9 / fs10),故按块判 1.15;整块只兜"孤尾短行"(1.60)。
if spread := localOccultationFooterSpread(note); spread > 1.15 {
t.Fatalf("%s %s: note block spread = %.3f, want <= 1.15 (%q)", name, language, spread, values)
}
if spread := localOccultationFooterSpread(lines); spread > 1.60 {
t.Fatalf("%s %s: footer spread = %.3f, want no orphan short line (%q)", name, language, spread, values)
}
joined := strings.Join(values, " ")
if zone := "显示时区"; language == "en" {
if !strings.Contains(joined, "shown in") {
t.Fatalf("%s %s: footer note lost the time zone declaration: %q", name, language, joined)
}
} else if !strings.Contains(joined, zone) {
t.Fatalf("%s %s: footer note lost the time zone declaration: %q", name, language, joined)
}
}
}
}
// 偏移型时区名(UTC+08:00)不得让时标声明写成“显示时区 UTC+08:00,UTC+08:00”。
func TestLocalOccultationFooterNamesTheZoneOnce(t *testing.T) {
location := time.FixedZone("UTC+08:00", 8*3600)
for _, language := range []string{"zh", "en"} {
star, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Language: language, Location: location,
})
if err != nil {
t.Fatalf("star %s: %v", language, err)
}
planet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{
Language: language, Location: location,
})
if err != nil {
t.Fatalf("planet %s: %v", language, err)
}
for name, diagram := range map[string]string{"star": star, "planet": planet} {
note := localOccultationFooterGroup(localOccultationFooterTexts(t, diagram), localOccultationFooterNoteFill)
values, _ := localOccultationFooterWidths(note)
joined := strings.Join(values, " ")
if got := strings.Count(joined, "UTC+08:00"); got != 1 {
t.Fatalf("%s %s: footer note names UTC+08:00 %d times, want once: %q", name, language, got, joined)
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"b612.me/astro/internal/svgchart"
)
// localOccultationOverviewLabel 是总览区一个接触标签:文本与它在图上的接触点位置。
type localOccultationOverviewLabel struct {
text string
x, y float64
}
// writeLocalOccultationOverviewLabels 排布总览区的接触标签:先按原偏移试位,压叠时沿接触点径向
// 外推、再上下错开,仍放不下就贴着月球外缘排成竖列;离接触点较远的标签用细引线连回去。
func writeLocalOccultationOverviewLabels(
b *strings.Builder,
labels []localOccultationOverviewLabel,
table *svgchart.LabelTable,
cx, cy, moonRadius float64,
color string,
) {
if len(labels) == 0 {
return
}
const fontSize = 11.0
spillTop := cy - moonRadius - 24
spill := 0
for _, item := range labels {
anchor, side := "start", 1.0
if item.x > cx {
anchor, side = "end", -1
}
baseY := item.y - 9
if item.y < cy-20 {
baseY = item.y + 15
}
candidates := []svgchart.LabelPlacement{{X: item.x + side*8, Y: baseY, Anchor: anchor}}
ux, uy := item.x-cx, item.y-cy
if norm := math.Hypot(ux, uy); norm > 1e-9 {
ux, uy = ux/norm, uy/norm
} else {
ux, uy = 0, -1
}
for step := 1; step <= 5; step++ {
reach := 12 + float64(step)*13
candidates = append(candidates,
svgchart.LabelPlacement{X: item.x + ux*reach + side*6, Y: item.y + uy*reach, Anchor: anchor},
svgchart.LabelPlacement{X: item.x + ux*reach - side*6, Y: item.y + uy*reach, Anchor: svgchart.MirrorLabelAnchor(anchor)},
)
}
for step := 1; step <= 4; step++ {
candidates = append(candidates,
svgchart.LabelPlacement{X: item.x + side*8, Y: baseY - float64(step)*15, Anchor: anchor},
svgchart.LabelPlacement{X: item.x + side*8, Y: baseY + float64(step)*15, Anchor: anchor},
)
}
placement, placed := table.Place(item.text, fontSize, candidates)
if !placed {
placement = svgchart.LabelPlacement{X: cx + side*(moonRadius+30), Y: spillTop + float64(spill)*15, Anchor: anchor}
for spill < 40 && table.OverlapsText(placement.X, placement.Y, fontSize, item.text, placement.Anchor) {
spill++
placement.Y = spillTop + float64(spill)*15
}
table.ReserveText(placement.X, placement.Y, fontSize, item.text, placement.Anchor)
spill++
}
if math.Hypot(placement.X-item.x, placement.Y-item.y) > 16 {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="0.7" opacity="0.7"/>`,
item.x, item.y, placement.X, placement.Y, color)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="%s" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="%.0f" font-weight="700" text-anchor="%s">%s</text>`,
placement.X, placement.Y, color, fontSize, placement.Anchor, html.EscapeString(item.text))
}
}
+88
View File
@@ -0,0 +1,88 @@
package svg
import (
"testing"
"time"
"b612.me/astro/moon"
)
// 总览区接触标签契约:掠掩时四次接触只差几十角秒,标签必须自动避让排开,既不互相压叠、也不压到方位字、
// 月球圆盘与右侧接触时刻栏;被推远的标签要用引线连回接触点。2026-09-14 保山月掩金星就是这种掠掩样本。
const localOccultationOverviewLabelFill = "#792d28"
func localPlanetOccultationOverviewLabels(t *testing.T, diagram string) []occultationSVGText {
t.Helper()
labels := []occultationSVGText{}
for _, text := range occultationSVGTexts(t, diagram) {
if text.fill == localOccultationOverviewLabelFill {
labels = append(labels, text)
}
}
return labels
}
func localOccultationOverviewLabelOverlaps(labels []occultationSVGText) [][2]string {
overlaps := [][2]string{}
for first := 0; first < len(labels); first++ {
for second := first + 1; second < len(labels); second++ {
a := occultationSVGTextBox(labels[first])
b := occultationSVGTextBox(labels[second])
if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] {
overlaps = append(overlaps, [2]string{labels[first].value, labels[second].value})
}
}
}
return overlaps
}
func grazingVenusOccultation(t *testing.T) moon.PlanetOccultationInfo {
t.Helper()
location := time.FixedZone("CST", 8*3600)
events, err := moon.FindPlanetOccultations(
time.Date(2026, time.September, 14, 0, 0, 0, 0, location),
time.Date(2026, time.September, 15, 0, 0, 0, 0, location),
moon.OccultationVenus, 99.1611, 25.1120, 1650, moon.OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(events))
}
return events[0]
}
func TestLocalPlanetOccultationOverviewLabelsAvoidEachOther(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
for _, tc := range []struct {
name string
info moon.PlanetOccultationInfo
width int
heigh int
}{
{"保山掠掩金星", grazingVenusOccultation(t), 935, 683},
{"土星全掩", localSaturnOccultation(t), 920, 720},
} {
diagram, err := LocalPlanetOccultationSVG(tc.info, LocalPlanetOccultationSVGOptions{
Width: tc.width, Height: tc.heigh, Location: location,
})
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
labels := localPlanetOccultationOverviewLabels(t, diagram)
if len(labels) < 2 {
t.Fatalf("%s:总览区只有 %d 个接触标签", tc.name, len(labels))
}
if overlaps := localOccultationOverviewLabelOverlaps(labels); len(overlaps) > 0 {
t.Fatalf("%s:接触标签互相压叠 %v", tc.name, overlaps)
}
for _, text := range labels {
box := occultationSVGTextBox(text)
if box[0] < 0 || box[2] > float64(tc.width) || box[1] < 0 || box[3] > float64(tc.heigh) {
t.Fatalf("%s:标签「%s」越出画布 %v", tc.name, text.value, box)
}
}
}
}
+8
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgmap"
@@ -59,9 +60,16 @@ func PlanetOccultationPathSVG(
path moon.PlanetOccultationPath,
options PlanetOccultationSVGOptions,
) (string, error) {
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")
}
if err := validatePlanetOccultationPath(path); err != nil {
return "", err
}
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
path = moon.PlanetOccultationPathInUT1(path)
}
targetID := path.TargetID
if targetID == "" {
targetID = path.Planet.String()
+33 -22
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"errors"
"fmt"
"html"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/moon"
@@ -55,6 +57,11 @@ type LocalPlanetOccultationSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 UTC+8。
// Location controls displayed event times. Nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale.
TimeScale astro.TimeScale
}
type localPlanetOccultationEventFrame struct {
@@ -101,12 +108,19 @@ func LocalPlanetOccultationSVG(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) (string, error) {
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")
}
if err := validateLocalPlanetOccultationInfo(info); err != nil {
return "", err
}
if err := validateLocalPlanetOccultationSVGOptions(options); err != nil {
return "", err
}
if options.TimeScale == astro.TimeScaleUT1 {
options.Location = time.UTC
}
options = normalizeLocalPlanetOccultationSVGOptions(options)
diagram := moon.PlanetOccultationDiagram(info, moon.PlanetOccultationDiagramOptions{
StepDays: options.Step.Hours() / 24,
@@ -114,6 +128,9 @@ func LocalPlanetOccultationSVG(
if len(diagram.Frames) == 0 {
return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalPlanetOccultationInfo)
}
if options.TimeScale == astro.TimeScaleUT1 {
info = moon.PlanetOccultationInfoInUT1(info)
}
return renderLocalPlanetOccultationSVG(info, diagram, options), nil
}
@@ -293,7 +310,15 @@ func writeLocalPlanetOccultationOverview(
mapY := func(value float64) float64 { return cy - value*scale }
moonRadius := localPlanetOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
table := &svgchart.LabelTable{}
table.ReserveText(layout.overviewLeft, layout.overviewTop-10, 14, overviewTitle, "start")
table.Reserve(layout.panelX-12, layout.overviewTop-16, layout.width-(layout.panelX-12), layout.height-layout.overviewTop)
table.Reserve(cx-moonRadius, cy-moonRadius, 2*moonRadius, 2*moonRadius)
for _, event := range events {
radius := math.Max(6, event.frame.PlanetRadiusArcsec*scale)
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 {
@@ -305,6 +330,7 @@ func writeLocalPlanetOccultationOverview(
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
"local-planet-occultation-moon-overview")
overviewLabels := make([]localOccultationOverviewLabel, 0, len(events))
for _, event := range events {
x := mapX(event.frame.PlanetXArcsec)
y := mapY(event.frame.PlanetYArcsec)
@@ -312,9 +338,10 @@ func writeLocalPlanetOccultationOverview(
writeLocalPlanetOccultationDisk(b, x, y, event.frame.PlanetRadiusArcsec*scale, true, "overview-planet-outline", event.label)
}
if event.label != "C2" && event.label != "C3" {
writeLocalPlanetOccultationOverviewLabel(b, event, x, y, cx, cy)
overviewLabels = append(overviewLabels, localOccultationOverviewLabel{text: event.name, x: x, y: y})
}
}
writeLocalOccultationOverviewLabels(b, overviewLabels, table, cx, cy, moonRadius, "#792d28")
}
func localPlanetOccultationSVGExtent(frames []moon.PlanetOccultationDiagramFrame) float64 {
@@ -425,23 +452,6 @@ func writeLocalPlanetOccultationDisk(
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)
}
func writeLocalPlanetOccultationOverviewLabel(
b *strings.Builder,
event localPlanetOccultationEventFrame,
x, y, cx, cy float64,
) {
dx, dy, anchor := 8.0, -9.0, "start"
if x > cx {
dx, anchor = -8, "end"
}
if y < cy-20 {
dy = 15
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#792d28" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(event.name))
}
func writeLocalPlanetOccultationContacts(
b *strings.Builder,
events []localPlanetOccultationEventFrame,
@@ -548,8 +558,9 @@ func writeLocalPlanetOccultationFooter(
options LocalPlanetOccultationSVGOptions,
) {
directionLines, footerLines := localOccultationFooterLines(
localPlanetOccultationSVGDirectionText(options), options.DirectionText != "",
localPlanetOccultationSVGFooterNote(info, options), options.FooterNote != "",
localPlanetOccultationSVGDirectionText(options),
localPlanetOccultationSVGFooterNote(info, options),
timenote.Scale(options.TimeScale, info.Greatest, options.Location, 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>`,
@@ -672,7 +683,7 @@ func localPlanetOccultationSVGSummaryText(
return fmt.Sprintf("Site %s | elevation %.0f m | %s | C1-C4 duration %s", coordinates, info.Observer.Height,
localPlanetOccultationSVGTypeName(info.Type, options.Language), duration)
}
return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | C1-C4 历时 %s", coordinates, info.Observer.Height,
return fmt.Sprintf("观测点 %s | 椭球高 %.0f 米 | %s | C1-C4 历时 %s", coordinates, info.Observer.Height,
localPlanetOccultationSVGTypeName(info.Type, options.Language), duration)
}
+3
View File
@@ -20,6 +20,7 @@ type occultationSVGText struct {
parent string
parentClass string
value string
fill string
x, y float64
fontSize float64
anchor string
@@ -67,6 +68,8 @@ func occultationSVGTexts(t *testing.T, diagram string) []occultationSVGText {
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "text-anchor":
text.anchor = attribute.Value
case "fill":
text.fill = attribute.Value
}
}
texts = append(texts, text)
+151
View File
@@ -0,0 +1,151 @@
package svg
import (
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/moon"
)
// 月掩五族的 UT1 选项:渲染成功、详细图声明尺度、非 UTC 时区报错,且绝不改写调用方传进来的 path。
func TestOccultationSVGTimeScaleUT1(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
path := occultationTestStarPath(t)
before := path.Start.Time
starSVG, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatalf("恒星月掩 UT1 详细图渲染失败: %v", err)
}
assertOccultationUT1Declaration(t, "恒星月掩详细图", starSVG)
if !path.Start.Time.Equal(before) {
t.Error("渲染器不得改写调用方传入的 path")
}
starPathSVG, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatalf("恒星月掩 UT1 路径图渲染失败: %v", err)
}
assertOccultationUT1Declaration(t, "恒星月掩全球路径图", starPathSVG)
if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil {
t.Error("UT1 配非 UTC 时区应报错")
}
if _, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil {
t.Error("UT1 配非 UTC 时区应报错(详细图)")
}
planetPath := samplePlanetOccultationPath()
if _, err := PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1}); err != nil {
t.Fatalf("行星月掩 UT1 详细图渲染失败: %v", err)
}
if _, err := PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil {
t.Error("行星月掩 UT1 配非 UTC 时区应报错")
}
// 行星全球路径图与恒星侧同契约:UT1 配非 UTC 必须报错,配 UTC 必须声明时标。
if _, err := PlanetOccultationPathSVG(planetPath, PlanetOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil {
t.Error("行星月掩全球路径图 UT1 配非 UTC 时区应报错")
}
planetPathSVG, err := PlanetOccultationPathSVG(planetPath, PlanetOccultationSVGOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatalf("行星月掩 UT1 全球路径图渲染失败: %v", err)
}
assertOccultationUT1Declaration(t, "行星月掩全球路径图", planetPathSVG)
info := localHR4799Occultation(t)
if _, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.UTC}); err != nil {
t.Fatalf("地方月掩 UT1 渲染失败: %v", err)
}
if _, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); err == nil {
t.Error("地方月掩 UT1 配非 UTC 时区应报错")
}
}
// assertOccultationUT1Declaration 钉住 UT1 声明是可解析的 <text> 元素并写出 DUT1:曾经的裸文本写入不会渲染。
func assertOccultationUT1Declaration(t *testing.T, name, diagram string) {
t.Helper()
for _, text := range occultationSVGTexts(t, diagram) {
if strings.Contains(text.value, "DUT1 = UT1−UTC = ") {
return
}
}
t.Errorf("%s 应在图内的 <text> 元素里声明 UT1 与 DUT1 差值", name)
}
// 换算器必须保留"该阶段不存在"的零值语义。
func TestOccultationInfoConvertersPreserveZero(t *testing.T) {
info := moon.StarOccultationInfo{Greatest: time.Date(2025, 1, 5, 17, 0, 0, 0, time.UTC)}
got := moon.StarOccultationInfoInUT1(info)
if !got.Immersion.IsZero() || !got.Emersion.IsZero() {
t.Error("不存在的阶段应保持零值")
}
if !got.Greatest.After(info.Greatest) {
t.Error("UT1 时刻应领先民用时刻")
}
}
// 详细版历表面板(ΔT、月距、天平动、月亮地心坐标)必须与输出时标无关:注入一个偏移 1 小时的
// TT−UTC 覆盖,让"把 UT1 读数当民用时刻"的写法必然算出不同历表值;修复后两次渲染的
// 非时刻文字必须逐个相同。
func TestOccultationDetailedPanelIgnoresTimeScale(t *testing.T) {
astro.SetTTMinusUTC(func(jd float64) float64 {
return basic.TTMinusUTCSecondsDefault(jd) + 3600
})
defer astro.SetTTMinusUTC(nil)
path := occultationTestStarPath(t)
utc, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{})
if err != nil {
t.Fatalf("UTC 详细图渲染失败: %v", err)
}
ut1, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), OccultationDetailedSVGOptions{TimeScale: astro.TimeScaleUT1})
if err != nil {
t.Fatalf("UT1 详细图渲染失败: %v", err)
}
utcValues := occultationPanelValues(t, utc)
ut1Values := occultationPanelValues(t, ut1)
if len(utcValues) == 0 {
t.Fatal("未解析到面板文字")
}
if len(utcValues) != len(ut1Values) {
t.Fatalf("两图文字数不同: %d vs %d", len(utcValues), len(ut1Values))
}
for index := range utcValues {
if utcValues[index] != ut1Values[index] {
t.Errorf("第 %d 条文字随时标改变: %q vs %q", index, utcValues[index], ut1Values[index])
}
}
}
// occultationPanelValues 取出不随时标变化的文字:跳过时刻、时区声明与包含它们的行。
func occultationPanelValues(t *testing.T, diagram string) []string {
t.Helper()
values := make([]string, 0, 32)
for _, text := range occultationSVGTexts(t, diagram) {
value := strings.TrimSpace(text.value)
if value == "" || strings.Contains(value, "UT1") || strings.Contains(value, "DUT1") ||
strings.Contains(value, "UTC") || strings.Contains(value, "时区") || strings.Contains(value, "图中时刻") {
continue
}
if occultationTimeLike(value) {
continue
}
values = append(values, value)
}
return values
}
func occultationTimeLike(value string) bool {
for _, field := range strings.Fields(value) {
trimmed := strings.Trim(field, "(),")
if len(trimmed) >= 5 && trimmed[2] == ':' && trimmed[0] >= '0' && trimmed[0] <= '9' {
return true
}
if len(trimmed) >= 8 && trimmed[4] == '-' && trimmed[7] == '-' {
return true
}
}
return false
}