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
+232
View File
@@ -0,0 +1,232 @@
package svg
import (
"encoding/xml"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,站心图的页脚各行必须等行距。
// 这两条都是排版不变量,与具体事件无关,因此在这里按图种各取一个样本固定下来。
const chartBottomMarginMinimum = 6.0
type chartTextElement struct {
x float64
y float64
fontSize float64
fill string
value string
}
func chartTextElements(t *testing.T, document string) []chartTextElement {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(document))
elements := []chartTextElement{}
stack := []string{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("chart is not valid XML: %v", err)
}
switch typed := token.(type) {
case xml.StartElement:
stack = append(stack, typed.Name.Local)
if typed.Name.Local != "text" {
continue
}
element := chartTextElement{}
for _, attribute := range typed.Attr {
switch attribute.Name.Local {
case "x":
element.x, _ = strconv.ParseFloat(attribute.Value, 64)
case "y":
element.y, _ = strconv.ParseFloat(attribute.Value, 64)
case "font-size":
element.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "fill":
element.fill = attribute.Value
}
}
elements = append(elements, element)
case xml.EndElement:
stack = stack[:len(stack)-1]
case xml.CharData:
if len(stack) > 0 && stack[len(stack)-1] == "text" && len(elements) > 0 {
elements[len(elements)-1].value += string(typed)
}
}
}
return elements
}
func chartBottomMarginViolations(t *testing.T, document string, height int) []string {
t.Helper()
violations := []string{}
for _, element := range chartTextElements(t, document) {
if strings.TrimSpace(element.value) == "" {
continue
}
_, below := svgchart.EstimatedTextExtents(element.fontSize)
if margin := float64(height) - (element.y + below); margin < chartBottomMarginMinimum {
violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", element.value, margin, chartBottomMarginMinimum))
}
}
return violations
}
func chartFooterCases(t *testing.T) []struct {
name string
height int
document string
} {
t.Helper()
cst := time.FixedZone("CST", 8*3600)
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
long := footerFitLongText("footer ")
cases := []struct {
name string
height int
document string
}{}
add := func(name string, height int, document string, ok bool) {
if !ok {
t.Fatalf("%s: chart missing", name)
}
cases = append(cases, struct {
name string
height int
document string
}{name, height, document})
}
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
add("站心日食", 692, solar, ok)
solarLong, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 640, Height: 520, Location: cst, FooterNote: long})
add("站心日食 窄画布长说明", 520, solarLong, ok)
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
add("站心月食", 640, lunar, ok)
lunarLong, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 640, Height: 520, Language: "zh", Location: cst, FooterNote: long})
add("站心月食 窄画布长说明", 520, lunarLong, ok)
detailed, ok := LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1000, Language: "zh", Location: cst, FooterNote: long})
add("月食详图 长说明", 1000, detailed, ok)
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
add("月食地图 长说明", 640, mapChart, ok)
solarMap, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC, FooterNote: long})
add("日食地图 长说明", 640, solarMap, ok)
return cases
}
func TestChartTextKeepsBottomMargin(t *testing.T) {
for _, tc := range chartFooterCases(t) {
if violations := chartBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 {
t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0])
}
}
}
func TestLocalDiagramFooterLinesAreEvenlySpaced(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := []struct {
name string
height int
document string
}{}
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
if !ok {
t.Fatal("站心日食:缺图")
}
cases = append(cases, struct {
name string
height int
document string
}{"站心日食", 692, solar})
lunar, ok := LunarEclipseSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
if !ok {
t.Fatal("站心月食:缺图")
}
cases = append(cases, struct {
name string
height int
document string
}{"站心月食", 640, lunar})
for _, tc := range cases {
baselines := []float64{}
for _, element := range chartTextElements(t, tc.document) {
if element.fontSize != 12 || (element.fill != "#333" && element.fill != "#555") {
continue
}
if strings.TrimSpace(element.value) == "" {
continue
}
baselines = append(baselines, element.y)
}
if len(baselines) < 3 {
t.Fatalf("%s:页脚只有 %d 行,样本太少", tc.name, len(baselines))
}
sort.Float64s(baselines)
step := svgchart.FooterLineHeight(12)
for index := 1; index < len(baselines); index++ {
if gap := baselines[index] - baselines[index-1]; math.Abs(gap-step) > 0.01 {
t.Fatalf("%s:页脚第 %d 行与上一行间距 %.3f,应为 %.1f(基线 %v)", tc.name, index+1, gap, step, baselines)
}
}
if margin := float64(tc.height) - baselines[len(baselines)-1]; math.Abs(margin-localDiagramFooterBottomPadding) > 0.01 {
t.Fatalf("%s:页脚末行基线距底边 %.3f,应为 %.1f(须落在图框内)", tc.name, margin, localDiagramFooterBottomPadding)
}
}
}
// 页脚时标声明契约:调用方给超长自定义说明时,时标声明仍须留在页脚里(截断可以吃掉说明,不能吃掉声明)。
func TestChartFooterKeepsTimeScaleDeclaration(t *testing.T) {
long := footerFitLongText("scale ")
cst := time.FixedZone("CST", 8*3600)
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
cases := []struct {
name string
document string
ok bool
}{}
solar, ok := LocalSolarEclipseSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 118.0894, 24.4798, 0,
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"站心日食", solar, ok})
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"站心月食", lunar, ok})
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"月食地图", mapChart, ok})
for _, tc := range cases {
if !tc.ok {
t.Fatalf("%s:缺图", tc.name)
}
if !strings.Contains(tc.document, "显示时区") && !strings.Contains(tc.document, "图中时刻为 UTC") {
t.Errorf("%s:超长说明把时标声明挤掉了", tc.name)
}
}
}
+68 -26
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
@@ -66,6 +68,11 @@ type LunarEclipseSVGOptions struct {
// Location 是图中显示时刻的时区;nil 时使用 UTC+8。
// Location is the display timezone for chart times; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
}
type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
@@ -149,6 +156,12 @@ func lunarEclipseSVG(
calculator lunarEclipseSVGCalculator,
finder lunarEclipseSVGFinder,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
@@ -162,7 +175,7 @@ func lunarEclipseSVG(
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
return renderLunarEclipseSVG(info, diagram, options), true
return renderLunarEclipseSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, diagram, options), true
}
func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions {
@@ -319,11 +332,21 @@ func writeLunarEclipseEventLabels(
func renderLunarEclipseSVG(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
) string {
headerTexts := lunarEclipseSVGHeaderTexts(info, options)
layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts))
headerTexts := lunarEclipseSVGHeaderTexts(info, geometry, options)
headerBottom := lunarEclipseSVGHeaderBottom(headerTexts)
direction := lunarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, float64(options.Width)-80, 12)
}
footerLines := lunarEclipseSVGFooterLines(direction, info, options, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), float64(options.Height), 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
// 页脚高度按实际行数算,再留一点与图区的空,说明文字不会顶到画布底边。
layout := lunarEclipseSVGLayoutFor(diagram, options, headerBottom, footerOccupied+10)
title := lunarEclipseSVGTitleText(info, options)
var b strings.Builder
@@ -351,7 +374,7 @@ func renderLunarEclipseSVG(
writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels)
writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY)
writeLunarEclipseFooter(&b, info, options, layout)
writeLunarEclipseFooter(&b, footerLines, footerBaselines)
b.WriteString(`</svg>`)
return b.String()
@@ -360,7 +383,7 @@ func renderLunarEclipseSVG(
func lunarEclipseSVGLayoutFor(
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
headerBottom float64,
headerBottom, footerHeight float64,
) lunarEclipseSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
@@ -372,7 +395,7 @@ func lunarEclipseSVGLayoutFor(
diagramRight = width - margin
}
topReserved := math.Max(166, headerBottom+24)
bottomReserved := 82.0
bottomReserved := footerHeight
extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72
scale := math.Min((diagramRight-diagramLeft)/(2*extent), (height-topReserved-bottomReserved)/(2*extent))
if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
@@ -413,11 +436,15 @@ func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOpti
return lunarEclipseSVGTitle(info, options.Language)
}
func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string {
func lunarEclipseSVGHeaderTexts(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
options LunarEclipseSVGOptions,
) []string {
lines := []string{
lunarEclipseSVGSummaryText(info, options),
lunarEclipseSVGMaximumTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(geometry, options),
lunarEclipseSVGDurationTextValue(info, options),
lunarEclipseSVGMetaTextValue(info, options),
}
@@ -484,6 +511,10 @@ func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string {
}
func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string {
return lunarEclipseSVGFooterNoteTextBase(options)
}
func lunarEclipseSVGFooterNoteTextBase(options LunarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -997,29 +1028,40 @@ func writeLunarEclipseContacts(
}
}
func writeLunarEclipseFooter(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
layout lunarEclipseSVGLayout,
) {
_ = info
direction := lunarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, layout.width-80, 12)
// lunarEclipseSVGFooterLines 返回方向行在前的页脚各行:行数上限由图区下限与画布高度决定。
func lunarEclipseSVGFooterLines(direction string, info LunarEclipseInfo, options LunarEclipseSVGOptions, headerBottom float64) []string {
const (
fontSize = 12.0
diagramFloor = 160.0
stageGap = 10.0
)
height := float64(options.Height)
lineHeight := svgchart.FooterLineHeight(fontSize)
padding := localDiagramFooterBottomPadding
maxLines := svgchart.TextLineLimit(fontSize, lineHeight, height-headerBottom-diagramFloor-stageGap-padding-lineHeight)
if cap := int(height * 0.20 / lineHeight); maxLines > cap {
maxLines = cap
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-54, html.EscapeString(direction))
// 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。
if maxLines < 1 {
maxLines = 1
}
note := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
lines := []string{lunarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
maxWidth := layout.width - 80
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12,
svgchart.BaselineLineLimit(12, 15, layout.height-34, layout.height-4))
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-80, fontSize)
}
lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-80, fontSize, maxLines)
return append([]string{direction}, lines...)
}
func writeLunarEclipseFooter(b *strings.Builder, lines []string, baselines []float64) {
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-34+float64(index)*15, html.EscapeString(line))
fill := "#555"
if index == 0 {
fill = "#333"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, baselines[index], fill, html.EscapeString(line))
}
}
+41 -22
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
@@ -33,9 +35,19 @@ type LunarEclipseDetailedSVGOptions struct {
// Location 控制显示时刻的时区;nil 使用 UTC+8。
// Location controls the display timezone; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
// DisablePenumbralPhase 关闭底图可见性分区里的半影阶段,含义与 LunarEclipseMapSVGOptions 的同名字段一致;
// 零值即画半影。
// DisablePenumbralPhase turns the penumbral phase off in the base map partition, with the same
// meaning as the LunarEclipseMapSVGOptions field of that name; the zero value draws it.
DisablePenumbralPhase bool
// Title 与 FooterNote 为空时自动生成。
// Empty Title and FooterNote use automatic text.
Title string
@@ -98,6 +110,12 @@ func lunarEclipseDetailedSVG(
finder lunarEclipseSVGFinder,
model eclipsecore.LunarEclipseShadowModel,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseDetailedSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
@@ -115,7 +133,8 @@ func lunarEclipseDetailedSVG(
return "", false
}
return renderLunarEclipseDetailedSVG(
info, diagram, geometry(diagram.Eclipse.Maximum), timeToTTJDE(date), options, model,
lunarEclipseDisplayInfo(info, options.TimeScale), info,
diagram, geometry(diagram.Eclipse.Maximum), diagram.Eclipse.Maximum, options, model,
), true
}
@@ -189,7 +208,7 @@ func validLunarEclipseDetailedSize(width, height int, info LunarEclipseInfo, opt
if (w-2*margin-2*columnGap)/3 < lunarEclipseDetailedMinColumnWidth {
return false
}
view := lunarEclipseDetailedViewFor(options)
view := lunarEclipseDetailedViewFor(info, options)
if solarEclipsePanelsOverlap(lunarEclipseDetailedPanelCells(view, info, options)) {
return false
}
@@ -275,7 +294,7 @@ func lunarEclipseDetailedPanelCells(
}
}
func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView {
func lunarEclipseDetailedViewFor(info LunarEclipseInfo, options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView {
width, height := float64(options.Width), float64(options.Height)
margin := math.Max(30, math.Min(52, width*0.044))
view := lunarEclipseDetailedView{
@@ -304,7 +323,8 @@ func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEc
Projection: svgmap.ProjectionEquirectangular,
}
view.legendY = mapTop + mapHeight + lunarEclipseDetailedLegendGap
view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language))
view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language,
lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase)))
if view.legendRows < 1 {
view.legendRows = 1
}
@@ -319,13 +339,14 @@ func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEc
func renderLunarEclipseDetailedSVG(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
shadow basic.LunarEclipseShadowGeometry,
tt float64,
options LunarEclipseDetailedSVGOptions,
model eclipsecore.LunarEclipseShadowModel,
) string {
view := lunarEclipseDetailedViewFor(options)
view := lunarEclipseDetailedViewFor(info, options)
english := options.Language == lunarEclipseSVGLanguageEnglish
title := options.Title
if title == "" {
@@ -353,13 +374,14 @@ func renderLunarEclipseDetailedSVG(
diagramTop: view.diagramTop, diagramEnd: view.diagramBottom,
diagramLeft: view.margin, diagramRight: view.width - view.margin,
}
penumbralBands := lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase)
// 不动的元素先占位:数据块、底图、图例与页脚;示意图标注只在示意图带内选位置。
labels := &svgchart.LabelTable{}
for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) {
labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height)
}
labels.Reserve(view.mapFrame.X, view.mapFrame.Y, view.mapFrame.Width, view.mapFrame.Height)
for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language) {
for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language, penumbralBands) {
for _, item := range row {
labels.ReserveText(item.x, item.y, lunarEclipseDetailedMapLegendFontSize, item.text, "start")
}
@@ -372,7 +394,7 @@ func renderLunarEclipseDetailedSVG(
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
writeLunarEclipseMapDefinitions(&b, view.mapFrame, info)
writeLunarEclipseMapDefinitions(&b, view.mapFrame, geometry, penumbralBands)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
@@ -388,9 +410,9 @@ func renderLunarEclipseDetailedSVG(
writeLunarEclipseDetailedGeocentricBlocks(&b, labels, tt, view, options)
writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels)
writeLunarEclipseDetailedPanels(&b, info, shadow, scale, view, options)
writeLunarEclipseMapRegions(&b, view.mapFrame, info)
writeLunarEclipseMapRegions(&b, view.mapFrame, geometry, penumbralBands)
view.mapFrame.WriteFrame(&b)
writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language)
writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language, penumbralBands)
writeLunarEclipseDetailedFooter(&b, view, options, model)
b.WriteString(`</svg>`)
return b.String()
@@ -435,16 +457,7 @@ func writeLunarEclipseDetailedSummary(
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Arial, sans-serif" font-size="12.5" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+float64(index)*22, html.EscapeString(line))
}
zoneNote := "图中时刻为 UT"
if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 {
zoneNote = fmt.Sprintf("图中时刻为 %s(UT%s)", zone, formatZoneOffset(offset))
}
if english {
zoneNote = "All times are UT"
if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 {
zoneNote = fmt.Sprintf("All times are %s (UT%s)", zone, formatZoneOffset(offset))
}
}
zoneNote := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle")
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+4*22, html.EscapeString(zoneNote))
@@ -479,7 +492,7 @@ func writeLunarEclipseDetailedGeocentricBlocks(
}
sunRa, sunDec := basic.SunApparentRaDec(tt)
moonRa, moonDec := basic.HMoonTrueRaDec(tt)
sunSd := basic.SunSemidiameter(tt)
sunSd := basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusStandard)
moonSd := basic.MoonSemidiameter(tt)
blocks := [2][]svgchart.PanelRow{
{
@@ -603,6 +616,10 @@ func writeLunarEclipseArcMinuteScaleBar(
// lunarEclipseDetailedFooterText 是页脚说明:默认模型会在极浅半影上回退 Chauvenet,必须写出实际用的那套。
func lunarEclipseDetailedFooterText(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string {
return lunarEclipseDetailedFooterTextBase(options, model)
}
func lunarEclipseDetailedFooterTextBase(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -623,10 +640,12 @@ func writeLunarEclipseDetailedFooter(
lines := svgchart.TruncateTextLines(
svgchart.WrapText(lunarEclipseDetailedFooterText(options, model), maxWidth, lunarEclipseDetailedFooterFontSize),
maxWidth, lunarEclipseDetailedFooterFontSize,
svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, 15, view.footerY, view.height-4))
svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, svgchart.FooterLineHeight(lunarEclipseDetailedFooterFontSize),
view.footerY, view.height-svgchart.FooterBottomPadding(lunarEclipseDetailedFooterFontSize)))
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="%.0f">%s</text>`,
view.margin, view.footerY+float64(index)*15, lunarEclipseDetailedFooterFontSize, html.EscapeString(line))
view.margin, view.footerY+float64(index)*svgchart.FooterLineHeight(lunarEclipseDetailedFooterFontSize),
lunarEclipseDetailedFooterFontSize, html.EscapeString(line))
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestLunarEclipseDetailedSVGCombinesBothCharts(t *testing.T) {
// 地影几何要与 NASA 月食图逐项吻合:2009 之外的 2025-03-14 全食,Gamma 0.3481、P./U. Radius 1.1899/0.6537。
func TestLunarEclipseShadowGeometryMatchesNASABulletin(t *testing.T) {
date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC)
result := basic.LunarEclipse(basic.TD2UT(basic.Date2JDE(date), true))
result := basic.LunarEclipse(basic.UTC2TT(basic.Date2JD(date)))
geometry := basic.LunarEclipseShadowGeometryAt(result.Maximum)
t.Logf("gamma=%.4f P.Radius=%.4f U.Radius=%.4f umbral=%.4f penumbral=%.4f",
geometry.Gamma, geometry.PenumbralRadiusDegrees, geometry.UmbralRadiusDegrees,
@@ -0,0 +1,75 @@
package svg
import (
"html"
"math"
"regexp"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 详细版"食甚时的月亮(地心坐标)"块必须用食甚时刻的力学时求值,与 NASA 月食图同一时刻;
// 输入日期的时刻(当天 0 时或 12 时)会让赤经赤纬整体偏掉。
func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := []struct {
name string
date time.Time
ra string
dec string
}{
// 对应 NASA 图上的 Moon at Greatest Eclipse:2026-03-03 为 10h56m15.0s / +06°24'05.2",
// 2025-03-14 为 11h38m23.0s / +02°40'54.6",与本库的差在 0.3 角秒内。
{"2026-03-03", time.Date(2026, 3, 3, 0, 0, 0, 0, cst), "10h56m15.1s", "+06°24&#39;05.2&#34;"},
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40&#39;54.3&#34;"},
}
for _, want := range cases {
doc, ok := LunarEclipseDetailedSVG(want.date, LunarEclipseDetailedSVGOptions{
Language: "zh", Location: cst})
if !ok {
t.Fatalf("%s: missing chart", want.name)
}
ra, dec := lunarGeocentricMoonBlock(t, doc)
if ra != want.ra || dec != want.dec {
t.Errorf("%s: 食甚坐标 RA=%s Dec=%s, want RA=%s Dec=%s", want.name, ra, dec, want.ra, want.dec)
}
info, ok := eclipsecore.LunarEclipseOnDate(want.date)
if !ok {
t.Fatalf("%s: missing eclipse", want.name)
}
maximumRA, maximumDec := basic.HMoonTrueRaDec(timeToTTJDE(info.Maximum))
if got, wantValue := ra, formatSolarEclipseRA(maximumRA); got != wantValue {
t.Errorf("%s: RA=%s 不是食甚时刻的 %s", want.name, got, wantValue)
}
if got, wantValue := dec, html.EscapeString(formatSolarEclipseDec(maximumDec)); got != wantValue {
t.Errorf("%s: Dec=%s 不是食甚时刻的 %s", want.name, got, wantValue)
}
inputRA, inputDec := basic.HMoonTrueRaDec(timeToTTJDE(want.date))
if math.Abs(maximumRA-inputRA) < 1 {
t.Fatalf("%s: 该用例区分不出输入日期与食甚时刻", want.name)
}
if dec == html.EscapeString(formatSolarEclipseDec(inputDec)) {
t.Errorf("%s: Dec 仍等于输入日期时刻的值 %s", want.name, formatSolarEclipseDec(inputDec))
}
}
}
func lunarGeocentricMoonBlock(t *testing.T, doc string) (string, string) {
t.Helper()
block := regexp.MustCompile(`食甚时的月亮(地心坐标)(.*?)</g>`).FindStringSubmatch(doc)
if block == nil {
t.Fatal("geocentric Moon block not found")
}
value := func(label string) string {
pattern := regexp.MustCompile(regexp.QuoteMeta(label) + `</text><text[^>]*text-anchor="end">([^<]*)</text>`)
match := pattern.FindStringSubmatch(block[1])
if match == nil {
t.Fatalf("row %s not found in the geocentric Moon block", label)
}
return match[1]
}
return value("赤经 R.A."), value("赤纬 Dec.")
}
+253 -21
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/lunarhorizon"
@@ -32,9 +34,20 @@ type LunarEclipseMapSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// 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 false) and declares the scale in the footer.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false.
Projection EclipseMapProjection
// DisablePenumbralPhase 关闭半影阶段,回到只按 P1/P4 分区的四类可见区:零值画半影(补画 U1–U4
// 地平边界、单独着色只看得见半影的月出与月落带「半影月出」「半影月落」,并在时刻行里标出本影接触)。
// DisablePenumbralPhase falls back to the four-way P1/P4-only partition: the zero value draws the
// penumbral phase, adding the U1-U4 horizons, the penumbra-only moonrise and moonset bands as
// separate legend entries, and the umbral contact times.
DisablePenumbralPhase bool
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
@@ -71,12 +84,18 @@ func lunarEclipseMapSVG(
if !ok || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseMapSVGOptions(date, options)
projection := internalEclipseMapProjection(options.Projection)
if projection == "" {
projection = svgmap.ProjectionEquirectangular
}
return renderLunarEclipseMapSVG(info, options, projection), true
return renderLunarEclipseMapSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, options, projection), true
}
func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapSVGOptions) LunarEclipseMapSVGOptions {
@@ -99,10 +118,15 @@ func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapS
func renderLunarEclipseMapSVG(
info eclipsecore.LunarEclipseInfo,
geometry eclipsecore.LunarEclipseInfo,
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
) string {
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92)
if projection == svgmap.ProjectionOrthographic {
// 正射视点取食甚时刻的月下点:视点固定 (0°,0°) 会把半个可见半球让给与本次月食无关的区域。
frame.CenterLongitude, frame.CenterLatitude = lunarEclipseOrthographicCenter(geometry.Maximum)
}
title := options.Title
if title == "" {
date := info.Maximum.In(options.Location).Format("2006-01-02")
@@ -116,8 +140,9 @@ func renderLunarEclipseMapSVG(
var builder strings.Builder
fmt.Fprintf(&builder, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
penumbralBands := lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase)
builder.WriteString(`<defs>`)
writeLunarEclipseMapDefinitions(&builder, frame, info)
writeLunarEclipseMapDefinitions(&builder, frame, geometry, penumbralBands)
builder.WriteString(`</defs>`)
builder.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&builder, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
@@ -130,17 +155,28 @@ func renderLunarEclipseMapSVG(
float64(options.Width)/2, html.EscapeString(titleText))
writeLunarEclipseMapSummary(&builder, info, options)
writeLunarEclipseMapRegions(&builder, frame, info)
writeLunarEclipseMapRegions(&builder, frame, geometry, penumbralBands)
frame.WriteFrame(&builder)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language)
writeLunarEclipseMapFooter(&builder, frame, options, projection)
legendRows := lunarEclipseMapLegendRows(frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, len(legendRows))
builder.WriteString(`</svg>`)
return builder.String()
}
// lunarEclipseMapDrawsPenumbralBands 只在该场月食确有本影阶段且开关打开时才区分半影带。
func lunarEclipseMapDrawsPenumbralBands(info eclipsecore.LunarEclipseInfo, includePenumbral bool) bool {
return includePenumbral && info.HasPartial
}
// 可见性由 P1、食甚、P4 三刻的月球地平状态共同界定:全程可见要求三刻都在地平上,
// 只在食甚前后露出地平的地点仍看得见本影食,算带食月落;三刻都不见才算不可见。
func writeLunarEclipseVisibilityMasks(builder *strings.Builder, frame svgmap.Frame) {
writeLunarEclipseVisibilityMask(builder, "lunar-not-visible-mask", frame,
"lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-p4-mask", frame,
"lunar-visible-p1-shape", "lunar-visible-p4-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-maximum-mask", frame, "lunar-visible-maximum-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p4-mask", frame, "lunar-visible-p4-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-mask", frame, "lunar-visible-p1-shape")
}
@@ -180,7 +216,7 @@ func eclipseMapFrame(width, height int, projection svgmap.Projection, top, botto
}
func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
points := basic.MoonHorizon(basic.Date2JDE(value.UTC()), 360)
points := basic.MoonHorizon(basic.Date2JD(value.UTC()), 360)
boundary := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]}
@@ -204,6 +240,12 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []
}
boundary = lunarhorizon.RefineWithin(boundary, value, tolerance)
}
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return lunarEclipseGeoPolygonPath(boundary, frame), closedBoundary
}
// lunarEclipseGeoPolygonPath 把经纬度环投影成 SVG 面路径,反经线与画布边缘按共享片段切开。
func lunarEclipseGeoPolygonPath(boundary []svgmap.GeoPoint, frame svgmap.Frame) string {
var builder strings.Builder
for _, fragment := range svgmap.PolygonFragments(boundary, frame.Clip()) {
if frame.IsPolar() {
@@ -211,8 +253,7 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []
}
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
}
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return builder.String(), closedBoundary
return builder.String()
}
func lunarEclipsePolarHorizonClosure(points []svgmap.GeoPoint) []svgmap.GeoPoint {
@@ -313,14 +354,44 @@ func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.Luna
}
fmt.Fprintf(builder, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(text))
if line := lunarEclipseMapUmbralContactLine(info, options); line != "" {
fmt.Fprintf(builder, `<text x="%.3f" y="100" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(line))
}
}
// lunarEclipseMapUmbralContactLine 给出本影接触时刻行;开关关闭或没有本影阶段时返回空串。
func lunarEclipseMapUmbralContactLine(info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions) string {
if options.DisablePenumbralPhase || !info.HasPartial {
return ""
}
parts := []string{"U1 " + info.PartialStart.In(options.Location).Format("15:04:05")}
if info.HasTotal {
parts = append(parts,
"U2 "+info.TotalStart.In(options.Location).Format("15:04:05"),
"U3 "+info.TotalEnd.In(options.Location).Format("15:04:05"))
}
parts = append(parts, "U4 "+info.PartialEnd.In(options.Location).Format("15:04:05"))
prefix := "本影阶段 "
if options.Language == "en" {
prefix = "Umbral phases "
}
return prefix + strings.Join(parts, " | ")
}
// lunarEclipseMapLegendRows 给出可见性图例的标注位次:每行按实测文本宽度排布并居中在地图框下方。
func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string) [][]solarEclipseCardinalLabel {
func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string, penumbralBands bool) [][]solarEclipseCardinalLabel {
labels := []string{"全程可见", "带食月出", "带食月落", "不可见"}
if language == "en" {
labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"}
}
if penumbralBands {
moonrise, moonset := "半影月出", "半影月落"
if language == "en" {
moonrise, moonset = "Penumbra moonrise", "Penumbra moonset"
}
labels = []string{labels[0], labels[1], moonrise, labels[2], moonset, labels[3]}
}
width := func(text string) float64 {
return lunarEclipseDetailedMapLegendIconWidth +
svgchart.EstimatedTextWidth(text, lunarEclipseDetailedMapLegendFontSize)
@@ -378,10 +449,13 @@ func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language
return rows
}
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string) {
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string, penumbralBands bool) {
colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"}
if penumbralBands {
colors = []string{"#5e846d", "#4e9da0", "#7488cc", "#e2aa4b", "#ab7fb5", "#747b7d"}
}
index := 0
for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language) {
for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language, penumbralBands) {
for _, entry := range row {
color := colors[0]
if index < len(colors) {
@@ -401,6 +475,8 @@ func writeLunarEclipseMapFooter(
frame svgmap.Frame,
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
legendRows int,
) {
text := options.FooterNote
if text == "" {
@@ -410,17 +486,28 @@ func writeLunarEclipseMapFooter(
text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
baseline := float64(options.Height) - 38
// 页脚末行要落在白色图框内:图框在画布内缩 18px,11px 字的末行基线留 26px(框线 + 间隙 + 字下伸部)。
const footerBottomPadding = 28.0
// 首行基线再让出一个字下伸部,末行才刚好落在留白之上(BaselineLineLimit 按行下沿计行)。
baseline := float64(options.Height) - footerBottomPadding - svgchart.FooterLineHeight(11) - 4
// 图例换成多行时页脚要跟着下移,否则窄画布上两行文字会叠在一起;下行仍留在图框内。
if legendRows > 1 {
if shifted := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 15; shifted > baseline {
baseline = math.Min(shifted, float64(options.Height)-footerBottomPadding)
}
}
// 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。
note := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
lines := []string{text}
if options.FooterNote != "" {
maxWidth := float64(options.Width) - frame.X - 24
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 11), maxWidth, 11,
svgchart.BaselineLineLimit(11, 15, baseline, float64(options.Height)-4))
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-frame.X-24, 11)
}
lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-frame.X-24, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline,
float64(options.Height)-footerBottomPadding))
for index, line := range lines {
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, baseline+float64(index)*15, html.EscapeString(line))
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}
@@ -432,9 +519,17 @@ func normalizeDegree180(value float64) float64 {
return value - 180
}
// lunarEclipseOrthographicCenter 给出月食正射图的视点:食甚时刻的月下点(月球位于天顶的地点)。
func lunarEclipseOrthographicCenter(at time.Time) (float64, float64) {
jd := basic.Date2JD(at.UTC())
tt := basic.UTC2TT(jd)
ra, dec := basic.HMoonTrueRaDec(tt)
return normalizeDegree180(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec
}
// writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。
// 独立全球图和 详细版式组合图共用,形状按传入的图框重建。
func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo, penumbralBands bool) {
b.WriteString(frame.ClipDefinition("lunar-map-clip"))
startPath, _ := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
endPath, _ := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
@@ -445,10 +540,96 @@ func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, inf
b.WriteString(`<clipPath id="lunar-visible-p4"><use href="#lunar-visible-p4-shape"/></clipPath>`)
b.WriteString(`<clipPath id="lunar-visible-maximum"><use href="#lunar-visible-maximum-shape"/></clipPath>`)
writeLunarEclipseVisibilityMasks(b, frame)
if penumbralBands {
writeLunarEclipseUmbralVisibilityShapes(b, frame, info)
}
}
// writeLunarEclipseMapRegions 画底图与四类可见性区域,末尾补两条地平边界线。
func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
// writeLunarEclipseUmbralVisibilityShapes 输出 U1、U4 的月球可见半球形状,以及"本影阶段任意时刻
// 在地平上"的取样半球、裁剪路径与排除掩膜;底带取并集内部,半影带取外部。
func writeLunarEclipseUmbralVisibilityShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
u1Path, _ := lunarEclipseVisibilityPath(info.PartialStart, frame)
u4Path, _ := lunarEclipseVisibilityPath(info.PartialEnd, frame)
fmt.Fprintf(b, `<path id="lunar-visible-u1-shape" d="%s"/>`, u1Path)
fmt.Fprintf(b, `<path id="lunar-visible-u4-shape" d="%s"/>`, u4Path)
b.WriteString(`<clipPath id="lunar-visible-u1"><use href="#lunar-visible-u1-shape"/></clipPath>`)
b.WriteString(`<clipPath id="lunar-visible-u4"><use href="#lunar-visible-u4-shape"/></clipPath>`)
writeLunarEclipseUmbralVisibleShapes(b, frame, info)
}
// writeLunarEclipseUmbralVisibleShapes 输出"本影阶段任意时刻在地平上"的取样半球:本影区间按固定档位
// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域——
// 实测 1932-03-22 西经 75.5° 一带上边界 0.5° 经度内从 +19.9° 掉到 −27.2°,1° 列的弦会切掉十几度)。
// 这些形状同时用于底带的裁剪(取并集)与半影带的掩膜(取补集),两侧严丝合缝。
func writeLunarEclipseUmbralVisibleShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
instants := lunarEclipseUmbralVisibilityInstants(info)
if len(instants) == 0 {
// 兜底:算不出本影可见区时至少挖掉两端接触(宁可少扣,也不让底带无裁剪地铺满)。
b.WriteString(`<clipPath id="lunar-visible-umbral"><use href="#lunar-visible-u1-shape"/><use href="#lunar-visible-u4-shape"/></clipPath>`)
writeLunarEclipseVisibilityMask(b, "lunar-not-umbral-visible-mask", frame,
"lunar-visible-u1-shape", "lunar-visible-u4-shape")
return
}
// 两端复用已经写好的精细半球形状,中间的取样用粗轮廓:单个圆盘在球面上的偏差约 0.035°,
// 在 1200 像素宽的世界图上不到 0.1 像素,而每个形状能省下三分之二的字节。
ids := []string{"lunar-visible-u1-shape"}
for index := 1; index < len(instants)-1; index++ {
id := fmt.Sprintf("lunar-visible-umbral-%d", index)
fmt.Fprintf(b, `<path id="%s" d="%s"/>`, id, lunarEclipseMaskHorizonPath(instants[index], frame))
ids = append(ids, id)
}
ids = append(ids, "lunar-visible-u4-shape")
var clip strings.Builder
clip.WriteString(`<clipPath id="lunar-visible-umbral">`)
for _, id := range ids {
fmt.Fprintf(&clip, `<use href="#%s"/>`, id)
}
clip.WriteString(`</clipPath>`)
b.WriteString(clip.String())
writeLunarEclipseVisibilityMask(b, "lunar-not-umbral-visible-mask", frame, ids...)
}
// lunarEclipseMaskHorizonPath 掩膜用的可见半球轮廓:90 点基础圆,等距圆柱投影下再按 0.2° 容差
// 细化,避免极区一段地平弧投影后横跨近 180° 经度、弦切出地平线以外的假可见帽。
func lunarEclipseMaskHorizonPath(value time.Time, frame svgmap.Frame) string {
points := basic.MoonHorizon(basic.Date2JD(value.UTC()), 90)
boundary := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
if frame.Projection == svgmap.ProjectionEquirectangular {
boundary = lunarhorizon.RefineWithin(boundary, value, 0.2)
}
return lunarEclipseGeoPolygonPath(boundary, frame)
}
// lunarEclipseUmbralVisibilityInstants 本影区间按 15 分钟档位取样,至少两端、至多 12 刻:中间取样用
// 粗轮廓,圆盘分辨率约 0.035°,合起来残余的掠射窗口深度约 0.05°(约 0.15 像素);要逐点精确判定
// 请用站点 API 或 GeoJSON。
func lunarEclipseUmbralVisibilityInstants(info eclipsecore.LunarEclipseInfo) []time.Time {
const (
step = 15 * time.Minute
limit = 12
)
duration := info.PartialEnd.Sub(info.PartialStart)
if duration <= 0 {
return nil
}
count := int(duration/step) + 1
if count < 2 {
count = 2
}
if count > limit {
count = limit
}
instants := make([]time.Time, count)
for index := range instants {
instants[index] = info.PartialStart.Add(duration * time.Duration(index) / time.Duration(count-1))
}
return instants
}
func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo, penumbralBands bool) {
_, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
_, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
frame.WriteOcean(b)
@@ -456,10 +637,61 @@ func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info ec
frame.WriteLand(b, "lunar-map-clip")
fmt.Fprintf(b, `<g class="lunar-visibility-regions" data-source="%s" clip-path="url(#lunar-map-clip)">`, eclipseMapSourceLunarVisibilityRegions)
writeLunarEclipseUnavailableRegion(b, frame)
b.WriteString(`<use class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/>`)
b.WriteString(`<use class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#4e9da0" fill-opacity="0.34"/>`)
writeLunarEclipseBaseRegion(b, penumbralBands, "lunar-visible-umbral",
`<use class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/>`)
writeLunarEclipseBaseRegion(b, penumbralBands, "lunar-visible-umbral",
`<use class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#4e9da0" fill-opacity="0.34"/>`)
b.WriteString(`<g clip-path="url(#lunar-visible-p4)"><g clip-path="url(#lunar-visible-maximum)"><use class="entire-eclipse-region" href="#lunar-visible-p1-shape" fill="#5e846d" fill-opacity="0.34"/></g></g>`)
// 两端可见、食甚却在地平下的高纬月落-月出带:两端可见推不出全程可见,归入带食月落。
b.WriteString(`<g clip-path="url(#lunar-visible-p4)"><g mask="url(#lunar-not-maximum-mask)"><use class="visible-at-p1-below-maximum moonset-region" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/></g></g>`)
// 两端都在地平下、只有食甚前后在地平上的极区透镜:月落必然落在食甚之后,与带食月落同类着色。
b.WriteString(`<g mask="url(#lunar-not-p1-p4-mask)"><use class="eclipse-at-maximum moonset-region" href="#lunar-visible-maximum-shape" fill="#e2aa4b" fill-opacity="0.34"/></g>`)
if penumbralBands {
writeLunarEclipsePenumbralRegions(b)
}
b.WriteString(`</g>`)
writeEclipseMapGeoLine(b, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries)
writeEclipseMapGeoLine(b, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries)
if penumbralBands {
writeLunarEclipseUmbralHorizonLines(b, frame, info)
}
}
// writeLunarEclipseBaseRegion 画一条月出/月落底带;半影带开启时只保留"本影阶段真的能看见"的部分,
// 半影段交给半影带:两条带用同一区域的正反面裁切,既不叠色也不留缝。
func writeLunarEclipseBaseRegion(b *strings.Builder, penumbralBands bool, clip, element string) {
if penumbralBands {
fmt.Fprintf(b, `<g clip-path="url(#%s)">%s</g>`, clip, element)
return
}
b.WriteString(element)
}
// writeLunarEclipsePenumbralRegions 只着色只看得见半影的两条带(月落侧与月出侧):主端在地平上,
// 本影阶段整段在地平下,再扣掉另一端的可见区(极区下中天会让两端同时可见)。
func writeLunarEclipsePenumbralRegions(b *strings.Builder) {
b.WriteString(`<g mask="url(#lunar-not-umbral-visible-mask)"><use class="penumbra-only-region penumbra-moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#ab7fb5" fill-opacity="0.34"/></g>`)
b.WriteString(`<g mask="url(#lunar-not-umbral-visible-mask)"><use class="penumbra-only-region penumbra-moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#7488cc" fill-opacity="0.34"/></g>`)
}
// writeLunarEclipseUmbralHorizonLines 补画本影接触点的地平边界,边界之间就是各阶段被月出月落切掉的区间。
func writeLunarEclipseUmbralHorizonLines(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
type horizonContact struct {
class string
at time.Time
}
contacts := []horizonContact{{"u1-horizon", info.PartialStart}}
if info.HasTotal {
contacts = append(contacts, horizonContact{"u2-horizon", info.TotalStart}, horizonContact{"u3-horizon", info.TotalEnd})
}
contacts = append(contacts, horizonContact{"u4-horizon", info.PartialEnd})
for _, contact := range contacts {
if contact.at.IsZero() {
continue
}
_, boundary := lunarEclipseVisibilityPath(contact.at, frame)
writeEclipseMapGeoLine(b, frame, boundary, contact.class, "#5f4b8b", 1.1, "3 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries)
}
}
// lunarEclipseInfoUT1Labels 把民用时刻换成同一物理时刻的 UT1 时刻。零值 time.Time 表示该阶段
+149
View File
@@ -0,0 +1,149 @@
package svg
import (
"fmt"
"math"
"regexp"
"strconv"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
// 月食正射图的两条契约:
// 1. 视点取食甚时刻的月下点("食甚可见半球"的边界就是圆盘边缘),否则视点固定在 (0°,0°),
// 会把半个可见半球让给与本次月食无关的区域;
// 2. 去掉圆盘边缘 2 px 内的固有亚像素带后,四类分区的着色必须与三刻地平几何一致。
func TestLunarEclipseMapSVGOrthographicCentering(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
// 本用例核对投影几何与四类可见区的物理判据,半影带另有覆盖性用例。
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC,
Projection: EclipseMapProjectionOrthographic, DisablePenumbralPhase: true,
})
if !ok {
t.Fatalf("%s: missing orthographic map", date.Format("2006-01-02"))
}
centreX, centreY, radius := lunarOrthographicDisc(t, doc)
partition := lunarMapPartitionOf(t, doc)
// 食甚可见半球的边界是视界大圆:它的投影环应当以圆盘中心为圆心。
ringCentroidX, ringCentroidY, ok := lunarOrthographicRingCentroid(partition, "lunar-visible-maximum-shape")
if !ok {
t.Fatalf("%s: no maximum-visible ring", date.Format("2006-01-02"))
}
if offset := math.Hypot(ringCentroidX-centreX, ringCentroidY-centreY); offset > 1.5 {
t.Errorf("%s: greatest-visible ring is %.3f px off the disc centre", date.Format("2006-01-02"), offset)
}
if err := lunarOrthographicInteriorMatchesPhysics(t, date, doc, partition, centreX, centreY, radius); err != nil {
t.Errorf("%s: %v", date.Format("2006-01-02"), err)
}
}
}
// lunarOrthographicDisc 从图内读出圆盘圆心与半径。
func lunarOrthographicDisc(t *testing.T, doc string) (float64, float64, float64) {
t.Helper()
match := regexp.MustCompile(`<circle class="map-frame" cx="([-0-9.]+)" cy="([-0-9.]+)" r="([-0-9.]+)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatal("disc frame not found")
}
x, _ := strconv.ParseFloat(match[1], 64)
y, _ := strconv.ParseFloat(match[2], 64)
radius, _ := strconv.ParseFloat(match[3], 64)
return x, y, radius
}
// lunarOrthographicRingCentroid 给出投影环顶点的均值,用作"该环是否为以盘心为圆心的圆"的判据。
func lunarOrthographicRingCentroid(partition lunarPartition, shape string) (float64, float64, bool) {
rings := partition.shapes[shape]
if len(rings) == 0 || len(rings[0]) == 0 {
return 0, 0, false
}
sumX, sumY := 0.0, 0.0
for _, vertex := range rings[0] {
sumX += vertex[0]
sumY += vertex[1]
}
return sumX / float64(len(rings[0])), sumY / float64(len(rings[0])), true
}
// lunarOrthographicInteriorMatchesPhysics 逐格比对物理分类与着色,只豁免盘缘 2 px 内的固有亚像素带:
// 越靠近视界,地平线在屏幕上越被压扁,整条边界都缩进不到一个像素,任何多边形表示在那里都无分辨率可言。
func lunarOrthographicInteriorMatchesPhysics(
t *testing.T,
date time.Time,
doc string,
partition lunarPartition,
centreX, centreY, radius float64,
) error {
t.Helper()
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing eclipse")
}
frame := eclipseMapFrame(960, 640, svgmap.ProjectionOrthographic, 142, 92)
frame.CenterLongitude, frame.CenterLatitude = lunarEclipseOrthographicCenter(info.Maximum)
counts := map[string]int{}
mismatches := 0
var first string
for longitude := -179.5; longitude < 180; longitude += 1 {
for latitude := -89.5; latitude < 90; latitude += 1 {
x, y, projectable := frame.Project(longitude, latitude)
if !projectable {
continue
}
if math.Hypot(x-centreX, y-centreY) > radius-2 {
continue
}
h1 := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude)
hm := lunarPartitionAltitude(info.Maximum, longitude, latitude)
h4 := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude)
if math.Abs(h1) < 0.05 || math.Abs(hm) < 0.05 || math.Abs(h4) < 0.05 {
continue
}
expected := lunarOrthographicExpectedClass(h1, hm, h4)
category, _ := partition.painted(x, y)
counts[expected]++
if category != expected {
mismatches++
if first == "" {
first = fmt.Sprintf("lon=%s lat=%s h=(%s,%s,%s) expected=%s got=%s",
lunarPartitionFormat(longitude), lunarPartitionFormat(latitude),
lunarPartitionFormat(h1), lunarPartitionFormat(hm), lunarPartitionFormat(h4),
expected, category)
}
}
}
}
for _, class := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset, lunarPartitionCategoryMoonrise} {
if counts[class] == 0 {
t.Errorf("orthographic disc has no %s cell away from the rim", class)
}
}
if mismatches != 0 {
return fmt.Errorf("%d interior cells carry the wrong class, first %s", mismatches, first)
}
return nil
}
func lunarOrthographicExpectedClass(h1, hm, h4 float64) string {
switch {
case h1 > 0 && hm > 0 && h4 > 0:
return lunarPartitionCategoryEntire
case h1 > 0 && h4 > 0:
return lunarPartitionCategoryMoonset
case h1 > 0:
return lunarPartitionCategoryMoonset
case h4 > 0:
return lunarPartitionCategoryMoonrise
case hm > 0:
return lunarPartitionCategoryMoonset
}
return lunarPartitionCategoryUnavailable
}
+457
View File
@@ -0,0 +1,457 @@
package svg
import (
"encoding/xml"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 月食全球可见图的分区契约:四类区域对全球网格穷尽且互斥,"不可见"只覆盖 P1、食甚、P4 三刻都在
// 地平下的地点,"全程可见"恰好等于 P1、食甚、P4 三刻都在地平上的地点。
// Partition contract: the four regions tile the globe exhaustively and exclusively.
const (
lunarPartitionCategoryEntire = "entire"
lunarPartitionCategoryMoonset = "moonset"
lunarPartitionCategoryMoonrise = "moonrise"
lunarPartitionCategoryUnavailable = "unavailable"
lunarPartitionCategoryPenumbra = "penumbra-only"
)
type lunarPartitionLayer struct {
category string
shape string
masks []string
clips []string
}
type lunarPartition struct {
frame svgFrameBox
shapes map[string][][][2]float64
masks map[string][]string
clips map[string][]string
layers []lunarPartitionLayer
}
func lunarMapPartitionOf(t *testing.T, doc string) lunarPartition {
t.Helper()
partition := lunarPartition{
frame: lunarPenumbralTestFrame(t, doc),
shapes: map[string][][][2]float64{},
masks: map[string][]string{},
clips: map[string][]string{},
}
type frame struct {
masks []string
clips []string
}
var stack []frame
inRegions := false
var container, containerID string
decoder := xml.NewDecoder(strings.NewReader(doc))
for {
token, err := decoder.Token()
if err != nil {
break
}
switch typed := token.(type) {
case xml.StartElement:
class := lunarPartitionAttr(typed, "class")
reference := strings.TrimPrefix(lunarPartitionAttr(typed, "href"), "#")
mask := lunarPartitionURLID(lunarPartitionAttr(typed, "mask"))
clip := lunarPartitionURLID(lunarPartitionAttr(typed, "clip-path"))
switch typed.Name.Local {
case "path":
if id := lunarPartitionAttr(typed, "id"); id != "" {
partition.shapes[id] = lunarPenumbralTestRings(t, lunarPartitionAttr(typed, "d"))
}
case "mask", "clipPath":
container, containerID = typed.Name.Local, lunarPartitionAttr(typed, "id")
case "use":
switch container {
case "mask":
if reference != "" {
partition.masks[containerID] = append(partition.masks[containerID], reference)
}
case "clipPath":
if reference != "" {
partition.clips[containerID] = append(partition.clips[containerID], reference)
}
}
case "rect", "circle":
if container == "clipPath" && containerID != "" && partition.clips[containerID] == nil {
partition.clips[containerID] = []string{}
}
case "g":
if strings.Contains(class, "lunar-visibility-regions") {
inRegions = true
}
inherited := frame{}
if len(stack) > 0 {
inherited = stack[len(stack)-1]
}
if mask != "" {
inherited.masks = append(append([]string(nil), inherited.masks...), mask)
}
if clip != "" {
inherited.clips = append(append([]string(nil), inherited.clips...), clip)
}
stack = append(stack, inherited)
}
if inRegions && (typed.Name.Local == "use" || typed.Name.Local == "rect" || typed.Name.Local == "circle") {
current := frame{}
if len(stack) > 0 {
current = stack[len(stack)-1]
}
if mask != "" {
current.masks = append(append([]string(nil), current.masks...), mask)
}
if clip != "" {
current.clips = append(append([]string(nil), current.clips...), clip)
}
partition.layers = append(partition.layers, lunarPartitionLayer{
category: lunarPartitionCategory(class, typed.Name.Local),
shape: reference,
masks: current.masks,
clips: current.clips,
})
}
case xml.EndElement:
if typed.Name.Local == "mask" || typed.Name.Local == "clipPath" {
container, containerID = "", ""
}
if typed.Name.Local == "g" {
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
if len(stack) == 0 {
inRegions = false
}
}
}
}
if len(partition.layers) == 0 {
t.Fatal("visibility region layers not found")
}
return partition
}
func lunarPartitionAttr(element xml.StartElement, name string) string {
for _, attribute := range element.Attr {
if attribute.Name.Local == name {
return attribute.Value
}
}
return ""
}
func lunarPartitionURLID(value string) string {
value = strings.TrimSuffix(strings.TrimPrefix(value, "url(#"), ")")
return value
}
func lunarPartitionCategory(class, element string) string {
switch {
case strings.Contains(class, "entire-eclipse-region"):
return lunarPartitionCategoryEntire
case strings.Contains(class, "penumbra-only-region"):
return lunarPartitionCategoryPenumbra
case strings.Contains(class, "moonset-region"):
return lunarPartitionCategoryMoonset
case strings.Contains(class, "moonrise-region"):
return lunarPartitionCategoryMoonrise
case strings.Contains(class, "eclipse-unavailable-region"):
return lunarPartitionCategoryUnavailable
}
return element + ":" + class
}
// covers 逐点复算一层是否落在该点:形状、外层 clip-path(多形状取并)与嵌套 mask 都要通过,
// mask 里的黑色形状按挖空处理。
func (partition lunarPartition) covers(layer lunarPartitionLayer, x, y float64) bool {
if layer.shape != "" && !partition.inside(layer.shape, x, y) {
return false
}
// clipPath 里的多个形状取并集:落在其中任意一个之内就算通过。
for _, clip := range layer.clips {
shapes := partition.clips[clip]
if len(shapes) == 0 {
continue
}
inside := false
for _, shape := range shapes {
if partition.inside(shape, x, y) {
inside = true
break
}
}
if !inside {
return false
}
}
for _, maskID := range layer.masks {
for _, excluded := range partition.masks[maskID] {
if partition.inside(excluded, x, y) {
return false
}
}
}
return true
}
func (partition lunarPartition) inside(shapeID string, x, y float64) bool {
return lunarPenumbralTestInside(partition.shapes[shapeID], x, y)
}
// painted 返回覆盖该点的最上层区域(SVG 后画的盖住先画的)与覆盖层数。
func (partition lunarPartition) painted(x, y float64) (string, int) {
category, count := "", 0
for _, layer := range partition.layers {
if partition.covers(layer, x, y) {
category, count = layer.category, count+1
}
}
return category, count
}
// partitionGrid 遍历 1°×1° 全球网格(64800 点,格心取 ±0.5°)。
func (partition lunarPartition) grid(visit func(longitude, latitude float64, x, y float64, category string, covered int)) {
for longitude := -179.5; longitude < 180; longitude += 1 {
for latitude := -89.5; latitude < 90; latitude += 1 {
x, y, projectable := partition.frame.Project(longitude, latitude)
if !projectable {
continue
}
category, count := partition.painted(x, y)
visit(longitude, latitude, x, y, category, count)
}
}
}
func lunarPartitionAltitude(at time.Time, longitude, latitude float64) float64 {
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
}
func TestLunarEclipseMapSVGVisibilityRegionsTileTheGlobe(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
} {
// 只检查四类底图的分区:半影带另有覆盖性用例。
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
counts := map[string]int{}
uncovered, covered, multiple := 0, 64800, 0
var firstUncovered string
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
counts[category]++
if category == "" {
uncovered++
if firstUncovered == "" {
firstUncovered = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
}
return
}
if layers > 1 {
multiple++
}
})
t.Logf("%s %s: %v", date.Format("2006-01-02"), countsString(counts), "uncovered="+lunarPartitionFormat(float64(uncovered)))
if uncovered != 0 {
t.Errorf("%s: %d of %d grid points carry no region colour, first at %s",
date.Format("2006-01-02"), uncovered, covered, firstUncovered)
}
if multiple != 0 {
t.Errorf("%s: %d grid points are painted by more than one region layer", date.Format("2006-01-02"), multiple)
}
for _, category := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable} {
if counts[category] == 0 {
t.Errorf("%s: region %s covers no grid point", date.Format("2006-01-02"), category)
}
}
}
}
// 打开半影阶段后,"仅见半影"带与带食月出/带食月落必须互斥:两种半透明底色叠在一起会混出图例里
// 没有的颜色,所以底带要被同源的 U1/U4 形状裁掉;同时裁切不能切出没有着色的缝。
func TestLunarEclipseMapSVGPenumbralPhaseKeepsCoverage(t *testing.T) {
date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC)
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing penumbral-phase map")
}
partition := lunarMapPartitionOf(t, doc)
uncovered, penumbraOnly, doubleTinted := 0, 0, 0
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
if category == "" {
uncovered++
return
}
if category != lunarPartitionCategoryPenumbra {
return
}
penumbraOnly++
for _, layer := range partition.layers {
if layer.category == lunarPartitionCategoryPenumbra || !partition.covers(layer, x, y) {
continue
}
if layer.category == lunarPartitionCategoryMoonset || layer.category == lunarPartitionCategoryMoonrise {
doubleTinted++
return
}
}
})
if uncovered != 0 {
t.Errorf("penumbral-phase map leaves %d grid points without a region colour", uncovered)
}
if penumbraOnly == 0 || doubleTinted != 0 {
t.Errorf("penumbra-only bands cover %d points, %d of them still tinted by a moonrise/moonset band", penumbraOnly, doubleTinted)
}
}
// 与 NASA/EclipseWise 世界图同口径:月球在食甚时刻已在地平上的地点,无论月出月落落在食内哪一段,
// 都属于可见区,绝不能画成"不可见";反之不可见区必须三刻都在地平下。
func TestLunarEclipseMapSVGNotVisibleFollowsHorizon(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
}
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
// 0.02° 容差内的地点落在边界上,任何一类都不算错。
wronglyInvisible, upAtMaximumInvisible, wronglyVisible := 0, 0, 0
var sample string
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
start := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude)
maximum := lunarPartitionAltitude(info.Maximum, longitude, latitude)
end := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude)
if math.Abs(start) < 0.02 || math.Abs(maximum) < 0.02 || math.Abs(end) < 0.02 {
return
}
invisible := category == lunarPartitionCategoryUnavailable
wantInvisible := start < 0 && maximum < 0 && end < 0
if invisible && maximum > 0 {
upAtMaximumInvisible++
if sample == "" {
sample = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
}
}
if invisible != wantInvisible {
wronglyInvisible++
}
if !invisible && start > 0 && maximum > 0 && end > 0 && category != lunarPartitionCategoryEntire {
wronglyVisible++
}
})
t.Logf("%s: 食甚在地平上却画成不可见 %d 点%s;与三刻地平判据不符 %d 点;全程在地平上却不在全程可见区 %d 点",
date.Format("2006-01-02"), upAtMaximumInvisible, sample, wronglyInvisible, wronglyVisible)
if upAtMaximumInvisible != 0 {
t.Errorf("%s: %d grid points whose Moon is above the horizon at greatest eclipse %s are painted as not visible",
date.Format("2006-01-02"), upAtMaximumInvisible, sample)
}
if wronglyInvisible != 0 || wronglyVisible != 0 {
t.Errorf("%s: visibility regions disagree with the horizon at P1/greatest/P4 for %d + %d grid points",
date.Format("2006-01-02"), wronglyInvisible, wronglyVisible)
}
}
}
// "全程可见"必须同时满足 P1、食甚、P4 三刻月亮都在地平上:两端可见推不出中途可见,
// 高纬下中天会让月亮在食甚前后落到地平下,这类点归入带食月落。
// Visible throughout requires the Moon above the horizon at P1, greatest and P4 alike:
// visible at both contacts does not imply visible in between.
func TestLunarEclipseMapSVGEntireRegionRequiresGreatestVisibility(t *testing.T) {
for _, date := range []time.Time{
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
holes, holeArea, missing, mislabelled, partialBand := 0, 0.0, 0, 0, 0
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
inP1 := partition.inside("lunar-visible-p1-shape", x, y)
inP4 := partition.inside("lunar-visible-p4-shape", x, y)
inMaximum := partition.inside("lunar-visible-maximum-shape", x, y)
cell := math.Cos(latitude * math.Pi / 180)
switch {
case inP1 && inP4 && inMaximum && category != lunarPartitionCategoryEntire:
holes++
holeArea += cell
case !(inP1 && inP4 && inMaximum) && category == lunarPartitionCategoryEntire:
missing++
case inP1 && inP4 && !inMaximum && category == lunarPartitionCategoryMoonset:
partialBand++
}
// 几何复核:三刻中任一刻月亮在地平下(留 0.05° 余量)就不得涂成全程可见。
if category == lunarPartitionCategoryEntire {
if lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) < -0.05 ||
lunarPartitionAltitude(info.Maximum, longitude, latitude) < -0.05 ||
lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) < -0.05 {
mislabelled++
}
}
})
t.Logf("%s: p1∩p4∩max 内未着全程可见色 %d 点(%.1f 平方度),全程可见区越界 %d 点,中间不可见带 %d 点,误标 %d 点",
date.Format("2006-01-02"), holes, holeArea, missing, partialBand, mislabelled)
if holes != 0 || missing != 0 || mislabelled != 0 {
t.Errorf("%s: entire-eclipse region is not exactly p1∩p4∩max (holes=%d, extra=%d, mislabelled=%d)",
date.Format("2006-01-02"), holes, missing, mislabelled)
}
}
}
func lunarPartitionFormat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func countsString(counts map[string]int) string {
keys := []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable, "", lunarPartitionCategoryPenumbra}
parts := make([]string, 0, len(keys))
for _, key := range keys {
if counts[key] == 0 {
continue
}
name := key
if name == "" {
name = "uncovered"
}
parts = append(parts, name+"="+lunarPartitionFormat(float64(counts[key])))
}
return strings.Join(parts, " ")
}
+1 -1
View File
@@ -35,7 +35,7 @@ func TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility(t *
if len(rings) == 0 {
t.Fatal("visibility path has no rings")
}
jd := basic.Date2JDE(info.PenumbralStart)
jd := basic.Date2JD(info.PenumbralStart)
// 只取足够深入地平线以下的见证点:−89.9° 处月心高度约 −0.054°,而更靠近极点的
// −89.99° 只有约 −0.004°,落在可见性判定本身的数值容差里,不能作为回归依据。
// Only a witness clearly below the horizon is used: at -89.9 degrees the Moon centre sits
+15 -3
View File
@@ -47,11 +47,13 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
for _, want := range []string{
`mask id="lunar-not-visible-mask"`,
`mask id="lunar-not-p1-p4-mask"`,
`mask id="lunar-not-p4-mask"`,
`mask id="lunar-not-p1-mask"`,
`class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)"`,
`class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)"`,
`class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)"`,
`class="eclipse-at-maximum moonset-region" href="#lunar-visible-maximum-shape"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("lunar-eclipse map does not render exclusive visibility regions: missing %q", want)
@@ -60,8 +62,18 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
if strings.Contains(diagram, `class="entire-eclipse-region"`) && strings.Contains(diagram, `fill-opacity="0.70"`) {
t.Fatal("entire-eclipse region still uses the opaque stacked-overlay style")
}
if !strings.Contains(diagram, `clipPath id="lunar-visible-maximum"`) {
t.Fatal("entire-eclipse region is not constrained by greatest-eclipse visibility")
// 全程可见区必须同时受 P1、P4 与食甚可见区裁剪:两端可见推不出中途可见,
// 中间落到地平下的高纬带另画带食月落,不能再被算成全程可见。
if !strings.Contains(diagram, `<clipPath id="lunar-visible-maximum"><use href="#lunar-visible-maximum-shape"/></clipPath>`) ||
!strings.Contains(diagram, `<g clip-path="url(#lunar-visible-p4)"><g clip-path="url(#lunar-visible-maximum)"><use class="entire-eclipse-region"`) {
t.Fatal("entire-eclipse region is not cut by greatest-eclipse visibility")
}
if !strings.Contains(diagram, `<g mask="url(#lunar-not-maximum-mask)"><use class="visible-at-p1-below-maximum moonset-region"`) {
t.Fatal("partly visible moonset band is missing")
}
if !strings.Contains(diagram, `mask id="lunar-not-visible-mask" maskUnits="userSpaceOnUse"`) ||
!strings.Contains(diagram, `<use href="#lunar-visible-maximum-shape" fill="#000000"/>`) {
t.Fatal("not-visible region is not bounded by greatest-eclipse visibility")
}
}
@@ -99,7 +111,7 @@ func TestLunarEclipseVisibilityPathMatchesTopocentricHorizon(t *testing.T) {
if len(rings) == 0 || len(rings) > 3 {
t.Fatalf("projection=%s subpaths=%d", projection, len(rings))
}
jd := basic.Date2JDE(at)
jd := basic.Date2JD(at)
for _, point := range boundary {
if altitude := basic.HMoonHeight(jd, point.Longitude, point.Latitude, 0); math.Abs(altitude) > 1e-9 {
t.Fatalf("horizon altitude=%g", altitude)
+20 -6
View File
@@ -4,6 +4,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
@@ -21,8 +22,11 @@ const (
LunarEclipseTotal = eclipsecore.LunarEclipseTotal
)
func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo {
return LunarEclipseInfo{
func lunarEclipseInfoFromBasic(
result basic.LunarEclipseResult,
location *time.Location,
) LunarEclipseInfo {
info := LunarEclipseInfo{
Type: mapBasicLunarEclipseType(result.Type),
PenumbralMagnitude: result.PenumbralMagnitude,
UmbralMagnitude: result.Magnitude,
@@ -38,6 +42,16 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L
HasPartial: result.HasPartial,
HasTotal: result.HasTotal,
}
return info
}
// lunarEclipseDisplayInfo 给出写图用的时刻副本;几何必须用未换算的 UTC 时刻,
// 否则把 UT1 读数当民用时刻会平移地平线、月下点与地心坐标块。
func lunarEclipseDisplayInfo(info LunarEclipseInfo, scale astro.TimeScale) LunarEclipseInfo {
if scale == astro.TimeScaleUT1 {
return eclipsecore.LunarEclipseInfoInUT1(info)
}
return info
}
func lunarEclipseContactPointsFromBasic(
@@ -128,13 +142,13 @@ func ttJDEToTime(ttJDE float64, location *time.Location) time.Time {
if ttJDE == 0 {
return time.Time{}
}
utcJDE := basic.TD2UT(ttJDE, false)
return basic.JDE2DateByZone(utcJDE, location, false)
utcJD := basic.TT2UTC(ttJDE)
return basic.JD2DateByZone(utcJD, location, false)
}
func timeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
utcJD := basic.Date2JD(date.UTC())
return basic.UTC2TT(utcJD)
}
func normalizeDegree360(angle float64) float64 {
+419
View File
@@ -0,0 +1,419 @@
package svg
import (
"crypto/md5"
"encoding/hex"
"math"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 半影阶段开关的契约:零值输出与不区分半影阶段的基线逐字节一致,打开后补画 U1–U4 地平边界、
// 单独着色只看得见半影的月出月落带,且每条边界仍落在它自己的接触时刻上。
// Contract of the penumbral-phase switch: DisablePenumbralPhase reproduces the penumbral-agnostic baseline
// byte for byte, while the enabled value adds the U1-U4 horizons and the penumbra-only bands, with
// every boundary still sitting on its own contact instant.
var lunarPenumbralPhaseBaseline = []struct {
name string
digest string
length int
}{
{"2026-03-03 全食 960x640 zh CST", "4e83ea42b18c703bdde6715f572169d5", 243483},
{"2026-03-03 全食 960x640 en CST", "21696a72ec3bc77a21752e2d675a230b", 243544},
{"2026-03-03 全食 1200x800 zh UTC", "e5a72a8f0b73449f85b4e433f6054069", 247413},
{"2026-03-03 全食 1200x800 en UTC", "1a0386abae194bdbee0bd5c0cb7a5a15", 247482},
{"2025-03-14 全食 960x640 zh CST", "84c39bb9f893678c1cdf354ba8acf850", 250237},
{"2025-03-14 全食 1200x800 zh UTC", "dad36f434ba2bfd7369a430b6f00e567", 255213},
{"2026-03-03 北极投影 zh", "8ee2f75f0f7c617b136576635d908cb4", 159000},
{"2026-03-03 南极投影 zh", "931e502692cdc5b0d09e8f91629f1b2f", 84934},
{"2016-08-18 半影 1200x800 zh UTC", "6d9c60787cc1b703abdd13c4b75a2a92", 241387},
{"2029-01-01 全食 960x640 en CST", "95c0a9d618bda4c0510fa827e104ceaa", 231567},
{"2026-03-03 详细版 zh", "31795334a05621f3a5a0beff1b63b5bd", 270844},
{"2026-03-03 详细版 en", "c5ee0c76f875c3c5fe4a7ff29a90a653", 270992},
{"2029-01-01 详细版 zh", "b0521aa165454262df4344168c9100e9", 257156},
{"2016-08-18 详细版 en", "0c5fdc6c04f993504ae970c2e8861ccb", 261488},
}
func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
render := lunarPenumbralPhaseBaselineRenderers()
if len(render) != len(lunarPenumbralPhaseBaseline) {
t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render))
}
for _, want := range lunarPenumbralPhaseBaseline {
draw, ok := render[want.name]
if !ok {
t.Fatalf("baseline case %q has no renderer", want.name)
}
doc, rendered := draw()
if !rendered {
t.Fatalf("%s: chart missing", want.name)
}
sum := md5.Sum([]byte(doc))
if got := hex.EncodeToString(sum[:]); got != want.digest || len(doc) != want.length {
t.Fatalf("%s: md5=%s bytes=%d, want md5=%s bytes=%d",
want.name, got, len(doc), want.digest, want.length)
}
}
}
func lunarPenumbralPhaseBaselineRenderers() map[string]func() (string, bool) {
cst := time.FixedZone("CST", 8*3600)
utc := time.UTC
return map[string]func() (string, bool){
"2026-03-03 全食 960x640 zh CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 全食 960x640 en CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 全食 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2026-03-03 全食 1200x800 en UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "en", Location: utc, DisablePenumbralPhase: true})
},
"2025-03-14 全食 960x640 zh CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2025-03-14 全食 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2026-03-03 北极投影 zh": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionNorthPolar, DisablePenumbralPhase: true})
},
"2026-03-03 南极投影 zh": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionSouthPolar, DisablePenumbralPhase: true})
},
"2016-08-18 半影 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2029-01-01 全食 960x640 en CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 详细版 zh": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 详细版 en": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2029-01-01 详细版 zh": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2016-08-18 详细版 en": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2016, 8, 18, 12, 0, 0, 0, utc),
LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true})
},
}
}
func TestLunarEclipseMapSVGPenumbralPhaseAddsUmbralContacts(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
date := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
options := LunarEclipseMapSVGOptions{Language: "zh", Location: cst}
on, ok := LunarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing penumbral-phase lunar-eclipse map")
}
options.DisablePenumbralPhase = true
off, ok := LunarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing lunar-eclipse map")
}
for _, want := range []string{
`class="u1-horizon"`, `class="u2-horizon"`, `class="u3-horizon"`, `class="u4-horizon"`,
`class="penumbra-only-region penumbra-moonset-region"`, `class="penumbra-only-region penumbra-moonrise-region"`,
`mask id="lunar-not-umbral-visible-mask"`, `clip-path="url(#lunar-visible-umbral)"`,
`id="lunar-visible-umbral-1"`,
`id="lunar-visible-u1-shape"`, `id="lunar-visible-u4-shape"`,
"半影月出", "半影月落", "本影阶段 U1",
} {
if !strings.Contains(on, want) {
t.Fatalf("penumbral-phase map missing %q", want)
}
if strings.Contains(off, want) {
t.Fatalf("default map must not contain %q", want)
}
}
if err := validateEclipseMapXML(on); err != nil {
t.Fatalf("penumbral-phase map is not valid XML: %v", err)
}
for _, id := range []string{"lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape"} {
shape := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`)
a, b := shape.FindStringSubmatch(off), shape.FindStringSubmatch(on)
if a == nil || b == nil || a[1] != b[1] {
t.Fatalf("shape %s changed when the penumbral phase is enabled", id)
}
}
english, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Language: "en", Location: cst})
if !ok || !strings.Contains(english, "Penumbra moonrise") || !strings.Contains(english, "Penumbra moonset") ||
!strings.Contains(english, "Umbral phases U1") {
t.Fatal("English penumbral-phase map misses its legend entry or contact row")
}
// 纯半影月食没有本影接触,按 NASA 口径不该凭空生成本影边界。
onlyPenumbral, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing penumbral-only lunar-eclipse map")
}
for _, unwanted := range []string{"u1-horizon", "penumbra-only-region", "半影月出", "半影月落"} {
if strings.Contains(onlyPenumbral, unwanted) {
t.Fatalf("penumbral-only eclipse must not gain %q", unwanted)
}
}
onlyPenumbralDefault, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
if !ok || onlyPenumbralDefault != onlyPenumbral {
t.Fatal("penumbral-only eclipse must render identically with and without the switch")
}
}
// 同判:U1/U4 形状等价于「该接触时刻月球中心在地平线上」,半影带等价于对应的三段高度判据,
// 每条地平边界线都落在自己的接触时刻上(P1 线不能落在 U1 时刻)。
func TestLunarEclipseMapSVGPenumbraBandsMatchHorizonCriterion(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
} {
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatalf("%s: missing umbral lunar eclipse", date.Format("2006-01-02"))
}
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 1200, Height: 800, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing penumbral-phase map", date.Format("2006-01-02"))
}
frame := lunarPenumbralTestFrame(t, doc)
p1 := lunarPenumbralTestShape(t, doc, "lunar-visible-p1-shape")
p4 := lunarPenumbralTestShape(t, doc, "lunar-visible-p4-shape")
u1 := lunarPenumbralTestShape(t, doc, "lunar-visible-u1-shape")
u4 := lunarPenumbralTestShape(t, doc, "lunar-visible-u4-shape")
for _, line := range []struct {
class string
at time.Time
other time.Time
}{
{"p1-horizon", info.PenumbralStart, info.PartialStart},
{"p4-horizon", info.PenumbralEnd, info.PartialEnd},
{"u1-horizon", info.PartialStart, info.PenumbralStart},
{"u4-horizon", info.PartialEnd, info.PenumbralEnd},
} {
maxOnContact := 0.0
var otherAltitudes []float64
vertices := lunarPenumbralTestLine(t, doc, line.class)
if len(vertices) < 30 {
t.Fatalf("%s: %s has %d vertices", date.Format("2006-01-02"), line.class, len(vertices))
}
for _, vertex := range vertices {
longitude, latitude := lunarPenumbralTestUnproject(frame, vertex[0], vertex[1])
maxOnContact = math.Max(maxOnContact, math.Abs(lunarPenumbralTestAltitude(line.at, longitude, latitude)))
otherAltitudes = append(otherAltitudes, math.Abs(lunarPenumbralTestAltitude(line.other, longitude, latitude)))
}
if maxOnContact > 0.01 {
t.Fatalf("%s: %s deviates %.4f deg from its own contact", date.Format("2006-01-02"), line.class, maxOnContact)
}
// 两条地平线必然相交于两点,交点是整条线唯一贴近另一组接触的地方;中位数说明其余部分分得很开。
sort.Float64s(otherAltitudes)
median := otherAltitudes[len(otherAltitudes)/2]
if median < 2 {
t.Fatalf("%s: %s sits only %.3f deg (median) from the other contact", date.Format("2006-01-02"), line.class, median)
}
t.Logf("%s %s: 与自身接触最大偏差 %.5f 度,与另一组接触 |alt| 中位数 %.3f 度(最小 %.3f)",
date.Format("2006-01-02"), line.class, maxOnContact, median, otherAltitudes[0])
}
judged, mismatches := 0, 0
for longitude := -179.0; longitude < 180; longitude += 2 {
for latitude := -89.0; latitude < 90; latitude += 2 {
x, y, projectable := frame.Project(longitude, latitude)
if !projectable {
continue
}
altitudes := [4]float64{
lunarPenumbralTestAltitude(info.PenumbralStart, longitude, latitude),
lunarPenumbralTestAltitude(info.PartialStart, longitude, latitude),
lunarPenumbralTestAltitude(info.PartialEnd, longitude, latitude),
lunarPenumbralTestAltitude(info.PenumbralEnd, longitude, latitude),
}
nearHorizon := false
for _, altitude := range altitudes {
if math.Abs(altitude) < 0.05 {
nearHorizon = true
}
}
if nearHorizon {
continue
}
judged++
if lunarPenumbralTestInside(u1, x, y) != (altitudes[1] > 0) {
t.Errorf("%s: U1 shape disagrees with the U1 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude)
}
if lunarPenumbralTestInside(u4, x, y) != (altitudes[2] > 0) {
t.Errorf("%s: U4 shape disagrees with the U4 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude)
}
// SVG 组合规则:月落带 = P1 可见、P4 与 U1 不可见;月出带 = P4 可见、P1 与 U4 不可见。
svgMoonset := lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(u1, x, y)
svgMoonrise := lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(u4, x, y)
wantMoonset := altitudes[0] > 0 && altitudes[1] <= 0 && altitudes[3] <= 0
wantMoonrise := altitudes[3] > 0 && altitudes[2] <= 0 && altitudes[0] <= 0
if svgMoonset != wantMoonset {
mismatches++
}
if svgMoonrise != wantMoonrise {
mismatches++
}
}
}
if judged < 5000 {
t.Fatalf("%s: too few judged samples (%d)", date.Format("2006-01-02"), judged)
}
if mismatches != 0 {
t.Fatalf("%s: penumbra band boundaries disagree with the horizon criterion at %d of %d judged sites",
date.Format("2006-01-02"), mismatches, judged)
}
}
}
func lunarPenumbralTestAltitude(at time.Time, longitude, latitude float64) float64 {
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
}
func lunarPenumbralTestFrame(t *testing.T, doc string) svgFrameBox {
t.Helper()
match := regexp.MustCompile(`<clipPath id="lunar-map-clip"><rect x="([-0-9.]+)" y="([-0-9.]+)" width="([-0-9.]+)" height="([-0-9.]+)"`).FindStringSubmatch(doc)
if match == nil {
// 圆盘版式(极区/正射)画的是圆,用 map-frame 圆的外接方形当图框。
disk := regexp.MustCompile(`<circle class="map-frame" cx="([-0-9.]+)" cy="([-0-9.]+)" r="([-0-9.]+)"`).FindStringSubmatch(doc)
if disk == nil {
t.Fatal("map frame not found")
}
cx, _ := strconv.ParseFloat(disk[1], 64)
cy, _ := strconv.ParseFloat(disk[2], 64)
radius, _ := strconv.ParseFloat(disk[3], 64)
return svgFrameBox{x: cx - radius, y: cy - radius, width: 2 * radius, height: 2 * radius}
}
values := make([]float64, 4)
for index := range values {
values[index], _ = strconv.ParseFloat(match[index+1], 64)
}
return svgFrameBox{x: values[0], y: values[1], width: values[2], height: values[3]}
}
type svgFrameBox struct{ x, y, width, height float64 }
func (frame svgFrameBox) Project(longitude, latitude float64) (float64, float64, bool) {
return frame.x + (longitude+180)/360*frame.width, frame.y + (90-latitude)/180*frame.height, true
}
func lunarPenumbralTestUnproject(frame svgFrameBox, x, y float64) (float64, float64) {
return (x-frame.x)/frame.width*360 - 180, 90 - (y-frame.y)/frame.height*180
}
func lunarPenumbralTestShape(t *testing.T, doc, id string) [][][2]float64 {
t.Helper()
match := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatalf("shape %s not found", id)
}
return lunarPenumbralTestRings(t, match[1])
}
func lunarPenumbralTestLine(t *testing.T, doc, class string) [][2]float64 {
t.Helper()
match := regexp.MustCompile(`class="` + class + `" d="([^"]*)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatalf("boundary %s not found", class)
}
rings := lunarPenumbralTestRings(t, match[1])
if len(rings) == 0 {
t.Fatalf("boundary %s has no vertices", class)
}
return rings[0]
}
func lunarPenumbralTestRings(t *testing.T, path string) [][][2]float64 {
t.Helper()
var rings [][][2]float64
for _, chunk := range strings.Split(path, "Z") {
fields := strings.Fields(chunk)
var ring [][2]float64
for index := 0; index < len(fields); {
if fields[index] == "M" || fields[index] == "L" {
x, _ := strconv.ParseFloat(fields[index+1], 64)
y, _ := strconv.ParseFloat(fields[index+2], 64)
ring = append(ring, [2]float64{x, y})
index += 3
continue
}
index++
}
if len(ring) >= 3 {
rings = append(rings, ring)
}
}
return rings
}
func lunarPenumbralTestInside(rings [][][2]float64, x, y float64) bool {
for _, ring := range rings {
if pointInLunarVisibilityPolygon(x, y, ring) {
return true
}
}
return false
}
// 仅见半影的两条带必须排除"本影区间里月亮曾在地平上"的站点:只挖 U1/U4 两刻会漏掉两刻之间的窗口,
// 按经度插值又会把近乎子午线方向的边界切进真实区域(1932-03-22 西经 75.5° 一带 0.5° 经度内上边界
// 从 +19.9° 掉到 −27.2°)。掩膜按 15 分钟档位取样整球可见半球,残余的掠射窗口深度约 0.05°
// (约 0.15 像素);深度更浅、只持续几分钟的窗口只由站点 API 与 GeoJSON 精确判定。
func TestLunarEclipseMapSVGPenumbralBandsExcludeUmbralWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 1200, Height: 800, Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing lunar-eclipse map")
}
partition := lunarMapPartitionOf(t, doc)
for _, witness := range []struct {
name string
lon, lat float64
want string
}{
{"U1 已在地平上(+0.0041 度)", -91.75, -88.75, lunarPartitionCategoryMoonrise},
{"U1 高度 +0.21 度(陡边界,1° 列会切进去)", -75.5, 6.5, lunarPartitionCategoryMoonset},
{"本影极值 -2.25 度(宽余量)", -68.75, -70.15, lunarPartitionCategoryPenumbra},
} {
x, y, projectable := partition.frame.Project(witness.lon, witness.lat)
if !projectable {
t.Fatalf("%s: 投影失败", witness.name)
}
category, _ := partition.painted(x, y)
if category != witness.want {
t.Fatalf("%s (%.4f,%.4f) category=%q want %q", witness.name, witness.lon, witness.lat, category, witness.want)
}
}
}
+191
View File
@@ -0,0 +1,191 @@
package svg
import (
"regexp"
"strings"
"testing"
"time"
"b612.me/astro"
eclipsecore "b612.me/astro/eclipse"
)
// UT1 出图:换算不能碰零值(零值表示"该阶段不存在"),且非 UTC 时区应明确失败而不是画错图。
func TestLunarEclipseUT1LabelsPreserveZeroPhases(t *testing.T) {
info := eclipsecore.LunarEclipseInfo{
Maximum: time.Date(2025, 9, 7, 18, 11, 0, 0, time.UTC),
}
got := eclipsecore.LunarEclipseInfoInUT1(info)
if !got.TotalStart.IsZero() {
t.Fatalf("不存在的阶段应保持零值, got %v", got.TotalStart)
}
if delta := got.Maximum.Sub(info.Maximum).Seconds(); delta <= 0 {
t.Fatalf("UT1 时刻应领先民用时刻, got %g 秒", delta)
}
}
// assertUT1Declaration 钉住 UT1 声明必须落在 <text> 元素内,并写出 DUT1 差值。
func assertUT1Declaration(t *testing.T, name, svg string) {
t.Helper()
declaration := "DUT1 = UT1−UTC = "
idx := strings.Index(svg, declaration)
if idx < 0 {
t.Errorf("%s 应在图内声明 UT1 并写出 DUT1 差值", name)
return
}
rest := svg[idx:]
end := strings.Index(rest, "</text>")
if end < 0 || strings.Contains(rest[:end], "<") {
t.Errorf("%s 的 UT1 声明必须落在 <text> 元素内", name)
}
}
func TestLunarEclipseMapSVGTimeScaleOption(t *testing.T) {
date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)
utcSVG, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{})
if !ok {
t.Fatal("UTC 渲染应成功")
}
ut1SVG, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1})
if !ok {
t.Fatal("UT1 渲染应成功")
}
assertUT1Declaration(t, "月食全球图", ut1SVG)
if strings.Contains(utcSVG, "世界时") {
t.Fatal("UTC 图不应带 UT1 图注")
}
cst := time.FixedZone("CST", 8*3600)
if _, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok {
t.Fatal("UT1 配非 UTC 时区应返回 false")
}
if _, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.UTC}); !ok {
t.Fatal("UT1 配 UTC 时区应成功")
}
}
// 三个月食入口共用同一个展示副本:不存在的阶段(零值)不得被换算成真实时刻,
// 且展示副本只影响文字,几何仍走原 UTC 数据。
func TestLunarEclipseUT1LabelHelperPreservesZero(t *testing.T) {
real := time.Date(2025, 9, 7, 18, 11, 0, 0, time.UTC)
info := LunarEclipseInfo{Maximum: real, PenumbralStart: real}
display := lunarEclipseDisplayInfo(info, astro.TimeScaleUT1)
if !display.TotalStart.IsZero() {
t.Fatalf("零值阶段应原样保留, got %v", display.TotalStart)
}
if !display.Maximum.After(real) {
t.Fatalf("UT1 时刻应领先民用时刻: %v vs %v", display.Maximum, real)
}
if utc := lunarEclipseDisplayInfo(info, astro.TimeScaleUTC); !utc.Maximum.Equal(real) {
t.Fatalf("UTC 口径不应换算: %v vs %v", utc.Maximum, real)
}
}
func TestLunarEclipseChartTimeScaleGuard(t *testing.T) {
date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)
cst := time.FixedZone("CST", 8*3600)
renderers := map[string]func(time.Time, *time.Location) (string, bool){
"diagram": func(d time.Time, loc *time.Location) (string, bool) {
return LunarEclipseSVG(d, LunarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc})
},
"detailed": func(d time.Time, loc *time.Location) (string, bool) {
return LunarEclipseDetailedSVG(d, LunarEclipseDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc})
},
"map": func(d time.Time, loc *time.Location) (string, bool) {
return LunarEclipseMapSVG(d, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc})
},
}
for name, render := range renderers {
if _, ok := render(date, cst); ok {
t.Errorf("%s: UT1 配非 UTC 时区应返回 false", name)
}
if _, ok := render(date, time.UTC); !ok {
t.Errorf("%s: UT1 配 UTC 时区应成功", name)
}
if _, ok := render(date, nil); !ok {
t.Errorf("%s: UT1 下 nil Location 应被强制为 UTC 并成功", name)
}
}
}
// 日食两族:UT1 选项必须成功、在图注里声明尺度,并且拒绝非 UTC 时区。
func TestSolarEclipseChartTimeScaleOption(t *testing.T) {
date := time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC)
cst := time.FixedZone("CST", 8*3600)
mapSVG, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1})
if !ok {
t.Fatal("日食地图 UT1 渲染应成功")
}
assertUT1Declaration(t, "日食地图", mapSVG)
if _, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok {
t.Error("日食地图 UT1 配非 UTC 时区应返回 false")
}
localSVG, ok := LocalSolarEclipseSVG(date, -96.8, 32.8, 0, LocalSolarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1})
if !ok {
t.Fatal("站心日食 UT1 渲染应成功")
}
assertUT1Declaration(t, "站心日食图", localSVG)
if _, ok := LocalSolarEclipseSVG(date, -96.8, 32.8, 0, LocalSolarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok {
t.Error("站心日食 UT1 配非 UTC 时区应返回 false")
}
}
// 月食组合图与详细版的图注也要声明尺度。
func TestLunarEclipseChartsDeclareUT1(t *testing.T) {
date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)
for name, svg := range map[string]string{
"diagram": func() string {
text, _ := LunarEclipseSVG(date, LunarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1})
return text
}(),
"detailed": func() string {
text, _ := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{TimeScale: astro.TimeScaleUT1})
return text
}(),
} {
if svg == "" {
t.Fatalf("%s: UT1 渲染应成功", name)
}
assertUT1Declaration(t, name, svg)
}
}
// UT1 输出只允许改文字:把 <text> 元素整体去掉后,UTC 与 UT1 的同名图必须逐字节相同。
// 几何一旦用了 UT1 读数(把 UT1 当民用时刻),地平线、月下点与地心坐标块都会平移。
func TestLunarEclipseUT1ChartsKeepGeometry(t *testing.T) {
date := time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC)
renderers := map[string]func(time.Time, astro.TimeScale) (string, bool){
"map": func(at time.Time, scale astro.TimeScale) (string, bool) {
return LunarEclipseMapSVG(at, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Location: time.UTC, TimeScale: scale})
},
"detailed": func(at time.Time, scale astro.TimeScale) (string, bool) {
return LunarEclipseDetailedSVG(at, LunarEclipseDetailedSVGOptions{
Width: 1000, Height: 1414, Location: time.UTC, TimeScale: scale})
},
"diagram": func(at time.Time, scale astro.TimeScale) (string, bool) {
return LunarEclipseSVG(at, LunarEclipseSVGOptions{
Width: 920, Height: 720, Location: time.UTC, TimeScale: scale})
},
}
textElements := regexp.MustCompile(`(?s)<text[^>]*>.*?</text>`)
for name, render := range renderers {
utc, ok := render(date, astro.TimeScaleUTC)
if !ok {
t.Fatalf("%s: UTC render failed", name)
}
ut1, ok := render(date, astro.TimeScaleUT1)
if !ok {
t.Fatalf("%s: UT1 render failed", name)
}
if strings.Contains(ut1, "UT1") == false {
t.Errorf("%s: UT1 chart does not declare the scale", name)
}
utcGeometry := textElements.ReplaceAllString(utc, "")
ut1Geometry := textElements.ReplaceAllString(ut1, "")
if utcGeometry != ut1Geometry {
t.Errorf("%s: switching to UT1 changed chart geometry", name)
}
}
}
+91 -18
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgchart"
@@ -64,6 +66,11 @@ type LocalSolarEclipseSVGOptions struct {
// Location 是图中显示时刻的时区;nil 时使用 UTC+8。
// Location is the display timezone for chart times; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// 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 false) and declares the scale in the footer.
TimeScale astro.TimeScale
}
type localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult
@@ -106,6 +113,12 @@ func localSolarEclipseSVG(
calculator localSolarEclipseSVGCalculator,
finder localSolarEclipseSVGFinder,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLocalSolarEclipseSVGOptions(options)
diagram := calculator(
solarEclipseTimeToTTJDE(date),
@@ -117,7 +130,7 @@ func localSolarEclipseSVG(
if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 {
return "", false
}
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location)
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location, options.TimeScale)
if finder != nil {
coreInfo := finder(info.GreatestEclipse, lon, lat, height)
info.HasSaros = coreInfo.HasSaros
@@ -153,6 +166,14 @@ func renderLocalSolarEclipseSVG(
headerBottom := localSolarEclipseSVGHeaderBottom(headerTexts)
width := float64(options.Width)
height := float64(options.Height)
direction := localSolarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
scaleNote := timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language)
footerLines := localSolarEclipseSVGFooterLines(direction, localSolarEclipseSVGFooterNoteText(options), scaleNote, width, height, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), height, 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
margin := math.Max(30, math.Min(46, width*0.05))
panelWidth := math.Max(230, math.Min(280, width*0.28))
diagramLeft := margin
@@ -160,7 +181,8 @@ func renderLocalSolarEclipseSVG(
if diagramRight-diagramLeft < width*0.48 {
diagramRight = width - margin
}
footerHeight := 72.0
// 页脚与图区之间留一点空,页脚高度按实际行数算出来,说明文字再多也不会顶到画布底边。
footerHeight := footerOccupied + 10
stageHeight := math.Max(160, math.Min(210, height*0.27))
stageTop := height - stageHeight - footerHeight
if stageTop < headerBottom+160 {
@@ -250,22 +272,13 @@ func renderLocalSolarEclipseSVG(
}
writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight)
direction := localSolarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, height-54, html.EscapeString(direction))
// 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。
lines := []string{localSolarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
maxWidth := width - 80
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12,
svgchart.BaselineLineLimit(12, 15, height-34, height-4))
}
for index, line := range lines {
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, height-34+float64(index)*15, html.EscapeString(line))
for index, line := range footerLines {
fill := "#555"
if index == 0 {
fill = "#333"
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, footerBaselines[index], fill, html.EscapeString(line))
}
writeLocalSolarEclipseContacts(&b, info, options, panelX, math.Max(154, cy-92))
b.WriteString(`</svg>`)
@@ -359,6 +372,10 @@ func localSolarEclipseSVGDirectionTextValue(options LocalSolarEclipseSVGOptions)
}
func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) string {
return localSolarEclipseSVGFooterNoteTextBase(options)
}
func localSolarEclipseSVGFooterNoteTextBase(options LocalSolarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -368,6 +385,62 @@ func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) str
return "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。"
}
// localSolarEclipseSVGFooterLines 折行页脚说明并按图区下限与画布高度截断行数,返回方向行在前的页脚各行。
// localDiagramFooterBottomPadding 是站心图页脚末行基线距画布底边的留白:图框在画布内缩 18px,
// 留白取 18px 框线 + 4px 间隙 + 12px 字的字下伸部,末行因此落在框线之内而不是压在框线上。
const localDiagramFooterBottomPadding = 26.0
func localSolarEclipseSVGFooterLines(direction, noteText, scaleNote string, width, height, headerBottom float64) []string {
const (
fontSize = 12.0
stageFloor = 160.0
stageGap = 10.0
)
lineHeight := svgchart.FooterLineHeight(fontSize)
padding := localDiagramFooterBottomPadding
// 先扣掉底边留白、方向行与图区下限,剩下的才是说明行能用的高度;再按画布高度两成封顶。
maxLines := svgchart.TextLineLimit(fontSize, lineHeight,
height-headerBottom-stageFloor-stageGap-padding-lineHeight)
if cap := int(height * 0.20 / lineHeight); maxLines > cap {
maxLines = cap
}
if maxLines < 1 {
maxLines = 1
}
maxWidth := width - 80
// 默认把时标声明并进说明一起均衡折行(行宽最接近方向说明),在放得下的行数里挑最均衡的一种。
mergedText := strings.TrimSpace(noteText + " " + scaleNote)
lines := svgchart.WrapTextBalanced(mergedText, maxWidth, fontSize)
for slots := 2; slots <= maxLines; slots++ {
candidate := svgchart.WrapTextBalancedLines(mergedText, maxWidth, fontSize, slots)
if len(candidate) > maxLines {
break
}
if solarEclipseLocalFooterSpread(direction, candidate) < solarEclipseLocalFooterSpread(direction, lines) {
lines = candidate
}
}
if len(lines) > maxLines {
// 说明太长折不下:截断说明,把时标声明单独留成最后一行。
notesOnly := svgchart.WrapTextBalanced(noteText, maxWidth, fontSize)
lines = svgchart.FooterLinesWithScale(notesOnly, scaleNote, maxWidth, fontSize, maxLines)
}
return append([]string{direction}, lines...)
}
// solarEclipseLocalFooterSpread 返回页脚各行估算宽度的最大最小比,用于在两种折行方案里取更整齐的一种。
func solarEclipseLocalFooterSpread(direction string, lines []string) float64 {
minimum, maximum := svgchart.EstimatedTextWidth(direction, 12), svgchart.EstimatedTextWidth(direction, 12)
for _, line := range lines {
width := svgchart.EstimatedTextWidth(line, 12)
minimum, maximum = math.Min(minimum, width), math.Max(maximum, width)
}
if minimum <= 0 {
return math.Inf(1)
}
return maximum / minimum
}
func localSolarEclipseSVGHeaderLineY(index int) float64 {
return 86 + float64(index)*23
}
@@ -0,0 +1,129 @@
package svg
import (
"encoding/xml"
"io"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 站心日食页脚契约:方向说明与补充说明(含时标声明)同字号排成三行,行宽接近、不超可用宽度,
// 且显示时区的偏移只写一次。
const (
solarEclipseLocalFooterDirectionFill = "#333"
solarEclipseLocalFooterNoteFill = "#555"
// 页脚与页眉同色,靠字号区分。
solarEclipseLocalFooterFontSize = 12
)
type solarEclipseLocalFooterText struct {
fill string
fontSize float64
value string
}
func solarEclipseLocalFooterTexts(t *testing.T, diagram string) []solarEclipseLocalFooterText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
texts := []solarEclipseLocalFooterText{}
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 := solarEclipseLocalFooterText{}
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.fontSize != solarEclipseLocalFooterFontSize {
continue
}
if text.fill != solarEclipseLocalFooterDirectionFill && text.fill != solarEclipseLocalFooterNoteFill {
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 solarEclipseLocalFooterDiagram(t *testing.T, language string, location *time.Location) string {
t.Helper()
diagram, ok := LocalSolarEclipseSVG(
time.Date(2012, time.May, 21, 6, 10, 0, 0, time.UTC),
118.0894, 24.4798, 0,
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Language: language, Location: location},
)
if !ok {
t.Fatalf("%s %s: no local solar eclipse diagram", language, location)
}
return diagram
}
func TestLocalSolarEclipseFooterIsEvenAndNamesTheZoneOnce(t *testing.T) {
const width = 920
for _, location := range []*time.Location{
time.FixedZone("UTC+08:00", 8*3600),
time.FixedZone("CST", 8*3600),
} {
for _, language := range []string{"zh", "en"} {
diagram := solarEclipseLocalFooterDiagram(t, language, location)
lines := solarEclipseLocalFooterTexts(t, diagram)
if len(lines) != 3 {
t.Fatalf("%s %s: footer lines = %d, want the direction line plus two note lines (%#v)",
language, location, len(lines), lines)
}
minimum, maximum := math.Inf(1), 0.0
note := []string{}
for _, line := range lines {
lineWidth := svgchart.EstimatedTextWidth(line.value, line.fontSize)
minimum, maximum = math.Min(minimum, lineWidth), math.Max(maximum, lineWidth)
if lineWidth > width-80 {
t.Fatalf("%s %s: footer line %q width %.1f exceeds %d", language, location, line.value, lineWidth, width-80)
}
if strings.Contains(line.value, "…") {
t.Fatalf("%s %s: default footer text is truncated: %q", language, location, line.value)
}
if line.fill == solarEclipseLocalFooterNoteFill {
note = append(note, line.value)
}
}
if spread := maximum / minimum; spread > 1.15 {
t.Fatalf("%s %s: footer spread = %.3f, want <= 1.15 (%#v)", language, location, spread, lines)
}
noteText := strings.Join(note, " ")
if language == "en" {
if !strings.Contains(noteText, "shown in") {
t.Fatalf("en %s: footer note lost the time zone declaration: %q", location, noteText)
}
} else if !strings.Contains(noteText, "显示时区") {
t.Fatalf("zh %s: footer note lost the time zone declaration: %q", location, noteText)
}
if got := strings.Count(noteText, "UTC+08:00"); got != 1 {
t.Fatalf("%s %s: footer note names UTC+08:00 %d times, want once: %q", language, location, got, noteText)
}
}
}
}
+43 -33
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
@@ -50,6 +52,11 @@ type SolarEclipseMapSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// 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 false) and declares the scale in the footer.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false.
Projection EclipseMapProjection
@@ -159,6 +166,12 @@ func solarEclipseMapSVG(
if !validEclipseMapProjection(options.Projection) {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeSolarEclipseMapSVGOptions(date, options)
// 日期门与核心一致:当天没有日食就不出图。偏食足迹是“取最近一次”语义,
// 缺这道门会把邻近日期的图当成当天的图交出去。
@@ -198,14 +211,25 @@ func solarEclipseMapSVG(
if calculators.panel != nil {
geocentric, hasGeocentric = calculators.panel(date)
}
// 几何(直射点、闭合弧、等时线取值)必须用民用时刻算;只有写出的文字换时标。
// 时刻标记按输出时标的整点取点(那里本就是另一个物理时刻),位置随整点标注移动。
civilPartial := partial
if options.TimeScale == astro.TimeScaleUT1 {
global = eclipsecore.SolarEclipseInfoInUT1(global)
greatestTimes = eclipsecore.TimeLabelsInUT1(greatestTimes)
partial = eclipsecore.SolarEclipsePartialFootprintsInUT1(partial)
central = eclipsecore.SolarEclipsePathInUT1(central)
local = eclipsecore.LocalSolarEclipseInfoInUT1(local)
geocentric = eclipsecore.SolarEclipseGeocentricPanelInUT1(geocentric)
}
plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric,
options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial))
// 数据块行距或图例带放不下时拒绝该画布,而不是把压叠的文字画出来。
if !plan.fits() {
return "", false
}
return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric,
options, projection), true
return plan.render(civilPartial, partial, central, hasCentral,
local, hasLocal, geocentric, hasGeocentric, options, projection), true
}
// solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。
@@ -414,10 +438,11 @@ func renderSolarEclipseMapSVG(
) string {
plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric,
options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial))
return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection)
return plan.render(partial, partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection)
}
func (plan solarEclipseMapPlan) render(
civilPartial eclipsecore.SolarEclipsePartialFootprintsInfo,
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
@@ -436,7 +461,7 @@ func (plan solarEclipseMapPlan) render(
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
}
partialPath, partialSource := solarEclipsePartialSweepPath(partial, frame)
partialPath, partialSource := solarEclipsePartialSweepPath(civilPartial, frame)
// 一张已放置矩形表:固定元素先占位,地图标注再按候选偏移避让。
labels := &svgchart.LabelTable{}
@@ -465,7 +490,7 @@ func (plan solarEclipseMapPlan) render(
writeSolarEclipseRiseSetCurves(&builder, partial.RiseSetCurves, frame)
writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options, labels)
// 点标注比等值线标注重要:先把食甚、接触、直射点与中心线时刻占到位置,等值线标注再让开。
pointLabels := solarEclipseMapPlacePointLabels(partial, central, hasCentral, frame, options, labels)
pointLabels := solarEclipseMapPlacePointLabels(civilPartial, partial, central, hasCentral, frame, options, labels)
writeSolarEclipseGreatestTimeContours(&builder, partial.GreatestTimeContours, frame, options, labels)
magnitudeAxis, magnitudeNormal, hasMagnitudeAxis := solarEclipseMagnitudeLabelAxis(central)
if !hasMagnitudeAxis {
@@ -769,8 +794,8 @@ func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector {
func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
ttJDE := solarEclipseTimeToTTJDE(value)
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
ut1JDE := basic.TT2UT1(ttJDE)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(ut1JDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
@@ -896,7 +921,7 @@ func solarEclipseCentralEnvelopeCoversPath(
return false
}
limitToleranceKM := solarEclipseCentralEnvelopeAxisMissToleranceKM
if width := path.Eclipse.PathWidthKM; width > 0 && !math.IsInf(width, 1) {
if width := path.Eclipse.PathWidthKM; path.Eclipse.PathWidthDefined && width > 0 && !math.IsInf(width, 1) {
limitToleranceKM = math.Max(limitToleranceKM, solarEclipseCentralEnvelopeLimitMissFraction*width)
}
for _, series := range [][]eclipsecore.SolarEclipsePathPoint{path.NorthernLimit, path.SouthernLimit} {
@@ -1107,7 +1132,7 @@ func writeSolarEclipseMapSummary(
summaryX, html.EscapeString(text))
details := make([]string, 0, 4)
if info.HasCentral {
if info.PathWidthDefined {
if options.Language == "en" {
details = append(details, fmt.Sprintf("central path width %.1f km", info.PathWidthKM))
} else {
@@ -1167,33 +1192,12 @@ func writeSolarEclipseMapSummary(
summaryX, conjunctionY, html.EscapeString(conjunctionText))
}
// 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。
zoneNote := solarEclipseTimeZoneNote(maximum, options.Language)
zoneNote := timenote.Scale(options.TimeScale, maximum, options.Location, options.Language)
labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle")
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
summaryX, conjunctionY+20, html.EscapeString(zoneNote))
}
func solarEclipseTimeZoneNote(maximum time.Time, language string) string {
name, offsetSeconds := maximum.Zone()
if offsetSeconds == 0 {
if language == "en" {
return "All times are UT"
}
return "图中时刻为 UT"
}
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := offsetSeconds / 3600
minutes := (offsetSeconds % 3600) / 60
if language == "en" {
return fmt.Sprintf("All times are %s (UT%s%02d:%02d)", name, sign, hours, minutes)
}
return fmt.Sprintf("图中时刻为 %s(UT%s%02d:%02d)", name, sign, hours, minutes)
}
func formatSolarEclipseMapDuration(value time.Duration) string {
seconds := int(math.Round(value.Seconds()))
if seconds < 0 {
@@ -1274,6 +1278,10 @@ const (
)
func solarEclipseMapFooterText(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string {
return solarEclipseMapFooterTextBase(options, projection)
}
func solarEclipseMapFooterTextBase(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -1301,11 +1309,13 @@ func writeSolarEclipseMapFooter(
maxWidth := float64(options.Width) - footerX - layout.margin
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, solarEclipseMapFooterFontSize),
maxWidth, solarEclipseMapFooterFontSize,
svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, 15, baseline, float64(options.Height)-4))
svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, svgchart.FooterLineHeight(solarEclipseMapFooterFontSize),
baseline, float64(options.Height)-svgchart.FooterBottomPadding(solarEclipseMapFooterFontSize)))
}
for index, line := range lines {
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="%.0f">%s</text>`,
footerX, baseline+float64(index)*15, solarEclipseMapFooterFontSize, html.EscapeString(line))
footerX, baseline+float64(index)*svgchart.FooterLineHeight(solarEclipseMapFooterFontSize),
solarEclipseMapFooterFontSize, html.EscapeString(line))
}
}
+4 -4
View File
@@ -114,16 +114,16 @@ func TestSolarEclipseMapOrthographicIsExplicitOnly(t *testing.T) {
}
}
// 图上时刻都按展示时区,必须显式写出与 UT 的偏差,且 详细版式的所有内容都不得溢出图框。
// 图上时刻必须同时写明时标(UTC)与展示时区相对它的偏差,且详细版式的所有内容都不得溢出图框。
func TestSolarEclipseMapStatesTimeZoneOffset(t *testing.T) {
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
cases := []struct {
location *time.Location
want string
}{
{location: time.UTC, want: "图中时刻为 UT"},
{location: time.FixedZone("CST", 8*3600), want: "UT+08:00"},
{location: time.FixedZone("EST", -5*3600), want: "UT-05:00"},
{location: time.UTC, want: "图中时刻为 UTC"},
{location: time.FixedZone("CST", 8*3600), want: "UTC+08:00"},
{location: time.FixedZone("EST", -5*3600), want: "UTC-05:00"},
}
for _, test := range cases {
options := SolarEclipseMapSVGOptions{
@@ -58,7 +58,7 @@ func solarEclipseContractLevelDiff(got, want []string) string {
// 导出 TT/UTC 换算把力学时儒略日换回 UTC 时刻,口径与核心写进 PartialBegin/EndOnEarth 的一致。
func solarEclipseContractTimeFromTT(ttJDE float64) time.Time {
return basic.JDE2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false)
return basic.JD2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false)
}
func TestSolarEclipseGreatestTimeLevelsContract(t *testing.T) {
@@ -160,8 +160,8 @@ func TestSolarEclipseGreatestTimeLevelsOnRealEclipseContract(t *testing.T) {
t.Fatal("missing 2009-07-22 eclipse")
}
// 同一力学时时刻经导出换算回 UTC,不依赖 eclipse 包内部的 TT→UTC 实现。
beginTT := basic.TD2UT(basic.Date2JDE(reported.PartialBeginOnEarth.UTC()), true)
endTT := basic.TD2UT(basic.Date2JDE(reported.PartialEndOnEarth.UTC()), true)
beginTT := basic.UTC2TT(basic.Date2JD(reported.PartialBeginOnEarth.UTC()))
endTT := basic.UTC2TT(basic.Date2JD(reported.PartialEndOnEarth.UTC()))
info := eclipsecore.SolarEclipseInfo{
PartialBeginOnEarth: solarEclipseContractTimeFromTT(beginTT),
PartialEndOnEarth: solarEclipseContractTimeFromTT(endTT),
+2 -1
View File
@@ -350,6 +350,7 @@ func solarEclipseContactMarkerSpecs(info eclipsecore.SolarEclipsePartialFootprin
// solarEclipseMapPlacePointLabels 按重要性放置点标注:食甚最先占位,其余标注再让开。
func solarEclipseMapPlacePointLabels(
civilPartial eclipsecore.SolarEclipsePartialFootprintsInfo,
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
@@ -376,7 +377,7 @@ func solarEclipseMapPlacePointLabels(
}, frame)
}
result.contacts = solarEclipseMapPlaceContactLabels(partial, frame, labels)
result.subsolar = solarEclipseMapPlaceSubsolarLabel(partial.Eclipse, frame, options.Language, labels)
result.subsolar = solarEclipseMapPlaceSubsolarLabel(civilPartial.Eclipse, frame, options.Language, labels)
if hasCentral {
result.times = solarEclipseMapPlaceTimeLabels(central, frame, options, labels)
}
+5 -2
View File
@@ -69,11 +69,14 @@ func solarEclipseContactRows(
contact("U4 "+name("本影外切", "umbra ends"), partial.U4),
}
info := partial.Eclipse
// 非中心食没有中心线,带宽与中心食时长无从谈起,留空而不是写 0。
// 非中心食没有中心线,单侧极限的中心带只擦到地球边缘:两者带宽都无定义,留空而不是写 0 或发散值。
// 中心食时长不受影响,单侧极限事件同样有目录口径的时长。
pathWidth, centralDuration := "—", "—"
if info.HasCentral {
pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM)
centralDuration = formatSolarEclipseMapDuration(info.CentralDuration)
if info.PathWidthDefined {
pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM)
}
}
circumstances = []svgchart.PanelRow{
{Label: name("食甚", "Greatest"), Value: info.GreatestEclipse.In(options.Location).Format("15:04:05")},
@@ -0,0 +1,198 @@
package svg
import (
"math"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgmap"
)
type projectedFillCase struct {
name string
date time.Time
pole float64
projection geodata.Projection
}
// 偏食可见域的填充必须与球面并集同域:极冠环在等经纬图上沿地图上、下边缘闭合、
// 接缝两侧各贴自己那一侧的边缘,在正射球面图上沿视界圆盘闭合;
// 把窗口两端折到同侧会让闭合边横穿整幅图并丢掉极冠。
func TestSolarEclipseProjectedPartialFillMatchesUnion(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := make([]projectedFillCase, 0, 6)
for _, item := range []projectedFillCase{
{"2012-05-21 北极冠", time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 90, ""},
{"2021-12-04 南极冠", time.Date(2021, 12, 4, 12, 0, 0, 0, cst), -90, ""},
{"2035-09-02 无冠", time.Date(2035, 9, 2, 12, 0, 0, 0, cst), 0, ""},
} {
for _, projection := range []geodata.Projection{
geodata.ProjectionEquirectangular, geodata.ProjectionOrthographic,
} {
item := item
item.projection = projection
cases = append(cases, item)
}
}
for _, item := range cases {
item := item
t.Run(item.name+"/"+string(item.projection), func(t *testing.T) {
info, ok := eclipse.SolarEclipsePartialFootprints(item.date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 5 * time.Minute, BoundaryPoints: 180,
})
if !ok {
t.Fatal("missing partial footprints")
}
polygons, ok := solarEclipsePartialBandPolygons(info)
if !ok || len(polygons) == 0 {
t.Fatal("missing partial-band union")
}
options := SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}
layout := solarEclipseMapLayoutFor(options, item.projection,
geodata.GeoPoint{Longitude: info.Eclipse.GreatestLongitude, Latitude: info.Eclipse.GreatestLatitude})
frame := layout.frame
rings := projectedPartialRings(t, polygons, frame)
if item.projection == geodata.ProjectionEquirectangular {
for _, ring := range rings {
for index := range ring {
point, next := ring[index], ring[(index+1)%len(ring)]
if math.Abs(next[0]-point[0]) <= frame.Width/2 || math.Abs(next[1]-point[1]) >= 1 {
continue
}
if frame.Y-point[1] > 1 && point[1]-(frame.Y+frame.Height) > 1 {
t.Fatalf("填充边横穿整幅图:y=%.3f x=%.3f→%.3f", point[1], point[0], next[0])
}
}
}
}
projected := func(longitude, latitude float64) (float64, float64, bool) {
x, y, ok := frame.Project(longitude, latitude)
if !ok {
return 0, 0, false
}
if item.projection != geodata.ProjectionOrthographic {
return x, y, true
}
radius := math.Min(frame.Width, frame.Height) / 2
if math.Hypot(x-(frame.X+frame.Width/2), y-(frame.Y+frame.Height/2)) > radius+1 {
return 0, 0, false
}
return x, y, true
}
polarSamples := 0
if item.pole != 0 {
latitude := math.Copysign(89.5, item.pole)
for longitude := -180.0; longitude < 180; longitude += 5 {
point := geodata.GeoPoint{Longitude: longitude, Latitude: latitude}
if !geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{point})[0] {
continue
}
x, y, ok := projected(point.Longitude, point.Latitude)
if !ok {
continue
}
if !planeFillContains(rings, x, y) {
t.Fatalf("纬度 %.1f 经度 %.1f 在球面并集内但未被填充", latitude, longitude)
}
polarSamples++
}
if polarSamples < 48 {
t.Fatalf("极冠样本只有 %d 个", polarSamples)
}
}
checked, skipped := 0, 0
for latitude := -85.0; latitude <= 85; latitude += 5 {
for longitude := -180.0; longitude < 180; longitude += 10 {
point := geodata.GeoPoint{Longitude: longitude, Latitude: latitude}
x, y, ok := projected(point.Longitude, point.Latitude)
if !ok {
continue
}
if planeFillBoundaryDistance(rings, x, y) < 3 {
skipped++
continue
}
spherical := geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{point})[0]
if plane := planeFillContains(rings, x, y); plane != spherical {
t.Fatalf("%.1f %.1f 填充=%v 球面并集=%v", longitude, latitude, plane, spherical)
}
checked++
}
}
if checked < 400 || skipped > checked/4 {
t.Fatalf("一致性样本 checked=%d skipped=%d", checked, skipped)
}
})
}
}
func projectedPartialRings(t *testing.T, polygons [][]geodata.GeoPoint, frame svgmap.Frame) [][][2]float64 {
t.Helper()
var rings [][][2]float64
for _, polygon := range polygons {
points := make([]geodata.GeoPoint, len(polygon))
copy(points, polygon)
for _, fragment := range svgmap.PolygonFragments(points, frame.Clip()) {
ring := make([][2]float64, 0, len(fragment))
for _, point := range fragment {
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
ring = nil
break
}
ring = append(ring, [2]float64{x, y})
}
if len(ring) >= 3 {
rings = append(rings, ring)
}
}
}
if len(rings) == 0 {
t.Fatal("no projected fragment")
}
return rings
}
func planeFillContains(rings [][][2]float64, x, y float64) bool {
winding := 0
for _, ring := range rings {
for index := range ring {
first, second := ring[index], ring[(index+1)%len(ring)]
if first[1] <= y {
if second[1] > y && (second[0]-first[0])*(y-first[1])-(x-first[0])*(second[1]-first[1]) > 0 {
winding++
}
} else if second[1] <= y && (second[0]-first[0])*(y-first[1])-(x-first[0])*(second[1]-first[1]) < 0 {
winding--
}
}
}
return winding != 0
}
func planeFillBoundaryDistance(rings [][][2]float64, x, y float64) float64 {
distance := math.Inf(1)
for _, ring := range rings {
for index := range ring {
first, second := ring[index], ring[(index+1)%len(ring)]
distance = math.Min(distance, planeSegmentDistance(x, y, first, second))
}
}
return distance
}
func planeSegmentDistance(x, y float64, first, second [2]float64) float64 {
dx, dy := second[0]-first[0], second[1]-first[1]
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, ((x-first[0])*dx+(y-first[1])*dy)/length))
}
return math.Hypot(x-(first[0]+fraction*dx), y-(first[1]+fraction*dy))
}
+55
View File
@@ -0,0 +1,55 @@
package svg
import (
"regexp"
"strings"
"testing"
"time"
"b612.me/astro"
)
// 太阳能图的 UT1 输出只改时刻文字:直射点、闭合弧与等时线几何必须逐字节相同。
// 时刻标记按输出时标的整点取点(UT1 整点是另一个物理时刻),与文字一起从比较里剔除。
func TestSolarEclipseMapUT1KeepsGeometry(t *testing.T) {
for _, date := range []time.Time{
time.Date(2021, time.June, 10, 12, 0, 0, 0, time.UTC), // DUT1 较大,直射点位移可测
time.Date(1995, time.October, 24, 12, 0, 0, 0, time.UTC), // 双向极限事件
time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC),
} {
options := SolarEclipseMapSVGOptions{
Width: 1200, Height: 800, Location: time.UTC, TimeLabelStep: 30 * time.Minute,
}
utc, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatalf("%s: UTC render failed", date.Format("2006-01-02"))
}
ut1Options := options
ut1Options.TimeScale = astro.TimeScaleUT1
ut1, ok := SolarEclipseMapSVG(date, ut1Options)
if !ok {
t.Fatalf("%s: UT1 render failed", date.Format("2006-01-02"))
}
if !strings.Contains(ut1, "DUT1 = UT1−UTC") {
t.Errorf("%s: UT1 chart does not declare the DUT1 offset", date.Format("2006-01-02"))
}
cst := time.FixedZone("CST", 8*3600)
badOptions := ut1Options
badOptions.Location = cst
if _, ok := SolarEclipseMapSVG(date, badOptions); ok {
t.Errorf("%s: UT1 with a non-UTC location must fail", date.Format("2006-01-02"))
}
if stripSolarTimescaleText(utc) != stripSolarTimescaleText(ut1) {
t.Errorf("%s: switching to UT1 changed chart geometry", date.Format("2006-01-02"))
}
}
}
var (
solarTimescaleText = regexp.MustCompile(`(?s)<text[^>]*>.*?</text>`)
solarTimescaleMarkers = regexp.MustCompile(`(?s)<g class="solar-time-marker">.*?</g>`)
)
func stripSolarTimescaleText(doc string) string {
return solarTimescaleText.ReplaceAllString(solarTimescaleMarkers.ReplaceAllString(doc, ""), "")
}
@@ -0,0 +1,90 @@
package svg
import (
"strings"
"testing"
"time"
)
// 带宽无定义时地图面板与摘要行都按目录口径留空,不得打印发散的解析值。
func TestSolarEclipseMapHidesUndefinedPathWidth(t *testing.T) {
testCases := []struct {
name string
date time.Time
panelValue string
summary string
forbidden []string
}{
{
name: "-1404-01-07 single-sided limit",
date: time.Date(-1404, 1, 7, 0, 0, 0, 0, time.UTC),
panelValue: "—",
forbidden: []string{"20692", "中心食带宽 "},
},
{
name: "2003-05-31 single-sided limit with limit lines",
date: time.Date(2003, 5, 31, 0, 0, 0, 0, time.UTC),
panelValue: "—",
forbidden: []string{"4454", "中心食带宽 "},
},
{
name: "2024-04-08 total",
date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
panelValue: "198.6 km",
summary: "中心食带宽 198.6 km",
},
{
name: "2010-01-15 annular",
date: time.Date(2010, 1, 15, 0, 0, 0, 0, time.UTC),
panelValue: "335.0 km",
summary: "中心食带宽 335.0 km",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(tc.date, SolarEclipseMapSVGOptions{
Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute,
})
if !ok {
t.Fatalf("expected map")
}
value, found := solarEclipsePanelRowValue(diagram, "中心食带宽")
if !found {
t.Fatalf("panel row missing")
}
if value != tc.panelValue {
t.Fatalf("panel row %q want %q", value, tc.panelValue)
}
if tc.summary != "" && !strings.Contains(diagram, tc.summary) {
t.Fatalf("summary missing %q", tc.summary)
}
for _, unwanted := range tc.forbidden {
if strings.Contains(diagram, unwanted) {
t.Fatalf("diagram contains %q", unwanted)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("map is not valid XML: %v", err)
}
})
}
}
func solarEclipsePanelRowValue(diagram, label string) (string, bool) {
index := strings.Index(diagram, ">"+label+"</text>")
if index < 0 {
return "", false
}
rest := diagram[index+len(label)+len("></text>"):]
start := strings.Index(rest, ">")
if start < 0 {
return "", false
}
rest = rest[start+1:]
end := strings.Index(rest, "</text>")
if end < 0 {
return "", false
}
return rest[:end], true
}
+9 -4
View File
@@ -4,6 +4,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
@@ -31,9 +32,13 @@ func localSolarEclipseInfoFromDiagram(
diagram basic.LocalSolarEclipseDiagramResult,
lon, lat, height float64,
location *time.Location,
scale astro.TimeScale,
) LocalSolarEclipseInfo {
info := localSolarEclipseInfoFieldsFromBasic(diagram.Eclipse, lon, lat, height, location)
info.ContactPoints = localSolarEclipseContactPointsFromFrames(diagram.Frames, location)
if scale == astro.TimeScaleUT1 {
info = eclipsecore.LocalSolarEclipseInfoInUT1(info)
}
return info
}
@@ -131,13 +136,13 @@ func solarEclipseTTJDEToTime(ttJDE float64, location *time.Location) time.Time {
if ttJDE == 0 {
return time.Time{}
}
utcJDE := basic.TD2UT(ttJDE, false)
return basic.JDE2DateByZone(utcJDE, location, false)
utcJD := basic.TT2UTC(ttJDE)
return basic.JD2DateByZone(utcJD, location, false)
}
func solarEclipseTimeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
utcJD := basic.Date2JD(date.UTC())
return basic.UTC2TT(utcJD)
}
func localSolarEclipseVisibilityThreshold(height, latitude float64) float64 {