feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+185 -61
View File
@@ -9,6 +9,8 @@ import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/lunarhorizon"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
)
@@ -101,8 +103,6 @@ func renderLunarEclipseMapSVG(
projection svgmap.Projection,
) string {
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92)
startPath, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
endPath, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
title := options.Title
if title == "" {
date := info.Maximum.In(options.Location).Format("2006-01-02")
@@ -117,32 +117,22 @@ func renderLunarEclipseMapSVG(
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))
builder.WriteString(`<defs>`)
builder.WriteString(frame.ClipDefinition("lunar-map-clip"))
fmt.Fprintf(&builder, `<path id="lunar-visible-p1-shape" d="%s"/>`, startPath)
fmt.Fprintf(&builder, `<path id="lunar-visible-p4-shape" d="%s"/>`, endPath)
builder.WriteString(`<clipPath id="lunar-visible-p4"><use href="#lunar-visible-p4-shape"/></clipPath>`)
writeLunarEclipseVisibilityMasks(&builder, frame)
writeLunarEclipseMapDefinitions(&builder, frame, info)
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"/>`,
options.Width-44, options.Height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
}
fmt.Fprintf(&builder, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(title))
float64(options.Width)/2, html.EscapeString(titleText))
writeLunarEclipseMapSummary(&builder, info, options)
frame.WriteOcean(&builder)
frame.WriteGraticule(&builder, "lunar-map-clip")
frame.WriteLand(&builder, "lunar-map-clip")
fmt.Fprintf(&builder, `<g class="lunar-visibility-regions" clip-path="url(#lunar-map-clip)">`)
writeLunarEclipseUnavailableRegion(&builder, frame)
builder.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"/>`)
builder.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"/>`)
builder.WriteString(`<g clip-path="url(#lunar-visible-p4)"><use class="entire-eclipse-region" href="#lunar-visible-p1-shape" fill="#5e846d" fill-opacity="0.34"/></g>`)
builder.WriteString(`</g>`)
writeEclipseMapGeoLine(&builder, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip")
writeEclipseMapGeoLine(&builder, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip")
writeLunarEclipseMapRegions(&builder, frame, info)
frame.WriteFrame(&builder)
writeLunarEclipseMapLegend(&builder, frame, options.Language)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language)
writeLunarEclipseMapFooter(&builder, frame, options, projection)
builder.WriteString(`</svg>`)
return builder.String()
@@ -190,35 +180,58 @@ func eclipseMapFrame(width, height int, projection svgmap.Projection, top, botto
}
func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
center := lunarEclipseSubpoint(value)
boundary := svgmap.SphericalCircle(center, 90, 360)
path := lunarEclipseVisibilityPathForCenter(center, frame)
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return path, closedBoundary
}
func lunarEclipseVisibilityPathForCenter(center svgmap.GeoPoint, frame svgmap.Frame) string {
var builder strings.Builder
for _, polygon := range svgmap.VisibleHemispherePolygons(center, frame.Projection, 360) {
appendEclipseMapPolygonPath(&builder, frame, polygon)
points := basic.MoonHorizon(basic.Date2JDE(value.UTC()), 360)
boundary := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
return builder.String()
if frame.Projection == svgmap.ProjectionEquirectangular {
// 等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近 180° 经度,
// 只连端点会在极区切出地平线以外的假可见帽(1904-09-24 实测 −0.054°,约 6 km)。
// 极射投影没有这个畸变,保持原输出。
// Equirectangular projection stretches longitude near a pole: a 1-degree spherical
// horizon arc can span almost 180 degrees there, so joining endpoints alone cuts a fake
// visible cap outside the horizon (measured -0.054 deg, about 6 km, on 1904-09-24).
// Polar projections have no such distortion and keep their existing output.
// 门限取画布上约 0.03 像素:极区细分再多也画不出来,而 GeoJSON 用的 0.002°
// 在 812 像素宽的世界图上相当于 0.005 像素,会让 SVG 无谓地大三分之一。
// The tolerance is about 0.03 pixel on the canvas: finer subdivision cannot be drawn,
// while the GeoJSON tolerance of 0.002 deg is 0.005 pixel on this 812-pixel-wide world
// map and would inflate the SVG by a third for nothing.
tolerance := 0.03 * 360 / frame.Width
if tolerance < lunarhorizon.DefaultToleranceDegrees {
tolerance = lunarhorizon.DefaultToleranceDegrees
}
boundary = lunarhorizon.RefineWithin(boundary, value, tolerance)
}
var builder strings.Builder
for _, fragment := range svgmap.PolygonFragments(boundary, frame.Clip()) {
if frame.IsPolar() {
fragment = lunarEclipsePolarHorizonClosure(fragment)
}
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
}
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return builder.String(), closedBoundary
}
func lunarEclipseVisibilityPolygons(
center svgmap.GeoPoint,
projection svgmap.Projection,
samples int,
) [][]svgmap.GeoPoint {
return svgmap.VisibleHemispherePolygons(center, projection, samples)
}
func lunarEclipseSubpoint(value time.Time) svgmap.GeoPoint {
ttJDE := timeToTTJDE(value)
ra, dec := basic.HMoonTrueRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
func lunarEclipsePolarHorizonClosure(points []svgmap.GeoPoint) []svgmap.GeoPoint {
var result []svgmap.GeoPoint
for index, point := range points {
result = append(result, point)
next := points[(index+1)%len(points)]
if math.Abs(point.Latitude) > 1e-9 || math.Abs(next.Latitude) > 1e-9 {
continue
}
// The equatorial clipping edge is an arc on a polar map, not a chord.
// The Moon-visible part of the equator is always the minor arc.
delta := math.Remainder(next.Longitude-point.Longitude, 360)
steps := int(math.Ceil(math.Abs(delta)))
for step := 1; step < steps; step++ {
result = append(result, svgmap.GeoPoint{Longitude: point.Longitude + delta*float64(step)/float64(steps)})
}
}
return result
}
func appendEclipseMapPolygonPath(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) {
@@ -245,9 +258,9 @@ func writeEclipseMapGeoLine(
points []svgmap.GeoPoint,
className, color string,
strokeWidth float64,
dash, clipID string,
dash, clipID, dataSource string,
) {
for _, segment := range svgmap.PolylineSegments(points, frame.Projection) {
for _, segment := range svgmap.PolylineSegments(points, frame.Clip()) {
if len(segment) < 2 {
continue
}
@@ -263,8 +276,12 @@ func writeEclipseMapGeoLine(
}
fmt.Fprintf(builder, `%s %.3f %.3f `, command, x, y)
}
fmt.Fprintf(builder, `" clip-path="url(#%s)" fill="none" stroke="%s" stroke-width="%.2f" stroke-dasharray="%s" stroke-linecap="round"/>`,
clipID, color, strokeWidth, dash)
source := ""
if dataSource != "" {
source = ` data-source="` + html.EscapeString(dataSource) + `"`
}
fmt.Fprintf(builder, `" clip-path="url(#%s)"%s fill="none" stroke="%s" stroke-width="%.2f" stroke-dasharray="%s" stroke-linecap="round"/>`,
clipID, source, color, strokeWidth, dash)
}
}
@@ -298,19 +315,84 @@ func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.Luna
float64(options.Width)/2, html.EscapeString(text))
}
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, language string) {
// lunarEclipseMapLegendRows 给出可见性图例的标注位次:每行按实测文本宽度排布并居中在地图框下方。
func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string) [][]solarEclipseCardinalLabel {
labels := []string{"全程可见", "带食月出", "带食月落", "不可见"}
colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"}
if language == "en" {
labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"}
}
y := frame.Y + frame.Height + 31
itemWidth := frame.Width / 4
for index, label := range labels {
x := frame.X + float64(index)*itemWidth
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, colors[index])
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
x+24, y, html.EscapeString(label))
width := func(text string) float64 {
return lunarEclipseDetailedMapLegendIconWidth +
svgchart.EstimatedTextWidth(text, lunarEclipseDetailedMapLegendFontSize)
}
available := canvasWidth - 2*lunarEclipseDetailedPageInset
packed := make([][]string, 0, 2)
current := make([]string, 0, len(labels))
used := 0.0
for _, label := range labels {
item := width(label)
next := item
if len(current) > 0 {
next = used + lunarEclipseDetailedMapLegendGap + item
if next > available {
packed = append(packed, current)
current = make([]string, 0, len(labels))
next = item
}
}
current = append(current, label)
used = next
}
if len(current) > 0 {
packed = append(packed, current)
}
centreX := frame.X + frame.Width/2
baseY := frame.Y + frame.Height + 31
rows := make([][]solarEclipseCardinalLabel, 0, len(packed))
for index, row := range packed {
total := 0.0
for position, label := range row {
if position > 0 {
total += lunarEclipseDetailedMapLegendGap
}
total += width(label)
}
x := centreX - total/2
if x < lunarEclipseDetailedPageInset {
x = lunarEclipseDetailedPageInset
}
if limit := canvasWidth - lunarEclipseDetailedPageInset - total; x > limit {
x = limit
}
entries := make([]solarEclipseCardinalLabel, 0, len(row))
for _, label := range row {
entries = append(entries, solarEclipseCardinalLabel{
text: label,
x: x + lunarEclipseDetailedMapLegendIconWidth,
y: baseY + float64(index)*lunarEclipseDetailedMapLegendLineStep,
})
x += width(label) + lunarEclipseDetailedMapLegendGap
}
rows = append(rows, entries)
}
return rows
}
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string) {
colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"}
index := 0
for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language) {
for _, entry := range row {
color := colors[0]
if index < len(colors) {
color = colors[index]
}
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`,
entry.x-lunarEclipseDetailedMapLegendIconWidth, entry.y-8, color)
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
entry.x, entry.y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(entry.text))
index++
}
}
}
@@ -328,8 +410,18 @@ func writeLunarEclipseMapFooter(
text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, float64(options.Height)-38, html.EscapeString(text))
baseline := float64(options.Height) - 38
// 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。
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))
}
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))
}
}
func normalizeDegree180(value float64) float64 {
@@ -339,3 +431,35 @@ func normalizeDegree180(value float64) float64 {
}
return value - 180
}
// writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。
// 独立全球图和 详细版式组合图共用,形状按传入的图框重建。
func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
b.WriteString(frame.ClipDefinition("lunar-map-clip"))
startPath, _ := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
endPath, _ := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
maximumPath, _ := lunarEclipseVisibilityPath(info.Maximum, frame)
fmt.Fprintf(b, `<path id="lunar-visible-p1-shape" d="%s"/>`, startPath)
fmt.Fprintf(b, `<path id="lunar-visible-p4-shape" d="%s"/>`, endPath)
fmt.Fprintf(b, `<path id="lunar-visible-maximum-shape" d="%s"/>`, maximumPath)
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)
}
// writeLunarEclipseMapRegions 画底图与四类可见性区域,末尾补两条地平边界线。
func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
_, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
_, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
frame.WriteOcean(b)
frame.WriteGraticule(b, "lunar-map-clip")
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"/>`)
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>`)
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)
}