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
+39 -137
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/svgmap"
)
@@ -59,6 +60,9 @@ 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")
}
}
func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) {
@@ -79,118 +83,45 @@ func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) {
}
}
func TestLunarEclipseVisibilityPolygonsContainOnlyVisibleHemisphere(t *testing.T) {
tests := []struct {
name string
projection svgmap.Projection
center svgmap.GeoPoint
visible svgmap.GeoPoint
hidden svgmap.GeoPoint
}{
{
name: "equirectangular across antimeridian",
projection: svgmap.ProjectionEquirectangular,
center: svgmap.GeoPoint{Longitude: 170, Latitude: 12},
visible: svgmap.GeoPoint{Longitude: 170, Latitude: 12},
hidden: svgmap.GeoPoint{Longitude: -10, Latitude: -12},
},
{
name: "north polar center inside projection",
projection: svgmap.ProjectionNorthPolar,
center: svgmap.GeoPoint{Longitude: 30, Latitude: 20},
visible: svgmap.GeoPoint{Longitude: 30, Latitude: 80},
hidden: svgmap.GeoPoint{Longitude: -150, Latitude: 10},
},
{
name: "north polar center outside projection",
projection: svgmap.ProjectionNorthPolar,
center: svgmap.GeoPoint{Longitude: 30, Latitude: -20},
visible: svgmap.GeoPoint{Longitude: 30, Latitude: 10},
hidden: svgmap.GeoPoint{Longitude: 30, Latitude: 90},
},
{
name: "south polar center inside projection",
projection: svgmap.ProjectionSouthPolar,
center: svgmap.GeoPoint{Longitude: -45, Latitude: -20},
visible: svgmap.GeoPoint{Longitude: -45, Latitude: -80},
hidden: svgmap.GeoPoint{Longitude: 135, Latitude: -10},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frame := svgmap.Frame{Width: 360, Height: 360, Projection: test.projection}
polygons := lunarEclipseVisibilityPolygons(test.center, test.projection, 360)
if len(polygons) == 0 {
t.Fatal("visibility polygon is empty")
}
if !projectedPointInLunarVisibility(frame, polygons, test.visible) {
t.Fatalf("visible point %#v is outside the rendered region", test.visible)
}
if projectedPointInLunarVisibility(frame, polygons, test.hidden) {
t.Fatalf("hidden point %#v is inside the rendered region", test.hidden)
}
})
}
}
func TestLunarEclipseVisibilityPathDoesNotUseTriangleFan(t *testing.T) {
for _, projection := range []svgmap.Projection{
svgmap.ProjectionEquirectangular,
svgmap.ProjectionNorthPolar,
svgmap.ProjectionSouthPolar,
func TestLunarEclipseVisibilityPathMatchesTopocentricHorizon(t *testing.T) {
for _, at := range []time.Time{
time.Date(2026, 3, 3, 9, 0, 0, 0, time.UTC),
time.Date(2025, 9, 7, 16, 0, 0, 0, time.UTC),
time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC),
} {
frame := svgmap.Frame{Width: 720, Height: 360, Projection: projection}
if projection != svgmap.ProjectionEquirectangular {
frame.Width = 360
}
path := lunarEclipseVisibilityPathForCenter(
svgmap.GeoPoint{Longitude: 170, Latitude: 12}, frame,
)
if subpaths := strings.Count(path, "M "); subpaths != 1 {
t.Fatalf("%s visibility path has %d subpaths, want one continuous outline", projection, subpaths)
}
if strings.Contains(path, "NaN") || strings.Contains(path, "Inf") {
t.Fatalf("%s visibility path contains a non-finite coordinate", projection)
}
}
}
func TestLunarEclipseVisibilityPolygonsMatchSphericalHorizon(t *testing.T) {
tests := []struct {
projection svgmap.Projection
center svgmap.GeoPoint
}{
{svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: 170, Latitude: 18}},
{svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: -170, Latitude: -18}},
{svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: 170, Latitude: 0}},
{svgmap.ProjectionNorthPolar, svgmap.GeoPoint{Longitude: 35, Latitude: 18}},
{svgmap.ProjectionNorthPolar, svgmap.GeoPoint{Longitude: 35, Latitude: -18}},
{svgmap.ProjectionSouthPolar, svgmap.GeoPoint{Longitude: -70, Latitude: -18}},
{svgmap.ProjectionSouthPolar, svgmap.GeoPoint{Longitude: -70, Latitude: 18}},
}
for _, test := range tests {
frame := svgmap.Frame{Width: 720, Height: 360, Projection: test.projection}
if test.projection != svgmap.ProjectionEquirectangular {
frame.Width = 360
}
polygons := lunarEclipseVisibilityPolygons(test.center, test.projection, 360)
for latitude := -75.0; latitude <= 75; latitude += 15 {
if test.projection == svgmap.ProjectionNorthPolar && latitude <= 0 {
continue
for _, projection := range []svgmap.Projection{svgmap.ProjectionEquirectangular, svgmap.ProjectionNorthPolar, svgmap.ProjectionSouthPolar} {
frame := svgmap.Frame{Width: 36000, Height: 18000, Projection: projection}
path, boundary := lunarEclipseVisibilityPath(at, frame)
if strings.Contains(path, "NaN") || strings.Contains(path, "Inf") {
t.Fatal("nonfinite path")
}
if test.projection == svgmap.ProjectionSouthPolar && latitude >= 0 {
continue
rings := svgClosedPathRings(t, `<path d="`+path+`"/>`)
if len(rings) == 0 || len(rings) > 3 {
t.Fatalf("projection=%s subpaths=%d", projection, len(rings))
}
for longitude := -165.0; longitude <= 165; longitude += 30 {
point := svgmap.GeoPoint{Longitude: longitude, Latitude: latitude}
dot := lunarVisibilityDot(test.center, point)
if math.Abs(dot) < 0.02 {
continue
jd := basic.Date2JDE(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)
}
got := projectedPointInLunarVisibility(frame, polygons, point)
if got != (dot > 0) {
t.Fatalf("%s center=%#v point=%#v inside=%v dot=%.6f",
test.projection, test.center, point, got, dot)
}
for lon := -175.; lon < 180; lon += 20 {
for lat := -85.; lat < 90; lat += 10 {
x, y, ok := frame.Project(lon, lat)
if !ok {
continue
}
altitude := basic.HMoonHeight(jd, lon, lat, 0)
if math.Abs(altitude) < 0.05 {
continue
}
inside := false
for _, ring := range rings {
inside = inside || pointInLunarVisibilityPolygon(x, y, ring)
}
if inside != (altitude > 0) {
t.Fatalf("projection=%s at=%s site=(%v,%v) inside=%v altitude=%v", projection, at, lon, lat, inside, altitude)
}
}
}
}
@@ -218,26 +149,6 @@ func validateEclipseMapXML(value string) error {
}
}
func projectedPointInLunarVisibility(frame svgmap.Frame, polygons [][]svgmap.GeoPoint, point svgmap.GeoPoint) bool {
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
return false
}
for _, polygon := range polygons {
projected := make([][2]float64, 0, len(polygon))
for _, vertex := range polygon {
px, py, projectedOK := frame.Project(vertex.Longitude, vertex.Latitude)
if projectedOK {
projected = append(projected, [2]float64{px, py})
}
}
if pointInLunarVisibilityPolygon(x, y, projected) {
return true
}
}
return false
}
func pointInLunarVisibilityPolygon(x, y float64, polygon [][2]float64) bool {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
@@ -249,12 +160,3 @@ func pointInLunarVisibilityPolygon(x, y float64, polygon [][2]float64) bool {
}
return inside
}
func lunarVisibilityDot(center, point svgmap.GeoPoint) float64 {
centerLongitude := center.Longitude * math.Pi / 180
centerLatitude := center.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
latitude := point.Latitude * math.Pi / 180
return math.Sin(centerLatitude)*math.Sin(latitude) +
math.Cos(centerLatitude)*math.Cos(latitude)*math.Cos(longitude-centerLongitude)
}