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