feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user