feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
+243
-16
@@ -1,11 +1,14 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/svgmap"
|
||||
)
|
||||
|
||||
@@ -19,25 +22,70 @@ func TestSolarEclipseMapSVGTotalIncludesPartialAndCentralRegions(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
"日全食全球见食图", "偏食始", "偏食终", "偏食可见区", "全食带", "中心线",
|
||||
"全球见食范围与中心食带", "全球阶段", "中心食始", "中心食终", "食带宽",
|
||||
"太阳沙罗 139", "食甚点太阳高度", "中心食持续", "P2", "P3", "U1", "U4",
|
||||
"全球见食范围与中心食带", "中心食始", "中心食终", "中心食带宽",
|
||||
"沙罗序列 139", "食甚点太阳高度", "中心食带宽", "图中时刻为", "中心食持续", "P2", "P3", "U1", "U4",
|
||||
`class="partial-eclipse-region"`, `class="central-eclipse-band"`, `class="solar-center-line"`,
|
||||
`class="northern-central-limit"`, `class="southern-central-limit"`, `class="solar-greatest-marker"`,
|
||||
`class="solar-global-events"`, `class="solar-time-marker"`, `>17:00</text>`,
|
||||
`class="solar-greatest-terminator"`, `class="solar-penumbral-outline"`,
|
||||
`class="solar-central-shadow-outline"`, `class="solar-shadow-contact solar-contact-p1"`,
|
||||
`class="solar-axis-contact"`, `class="solar-subsolar-marker"`,
|
||||
`class="solar-time-marker"`, `>17:00</text>`,
|
||||
`class="solar-rise-set-boundary solar-start-rise"`, `stroke="#d97706"`,
|
||||
`class="solar-shadow-contact solar-contact-p1"`, `class="solar-magnitude-contour"`,
|
||||
`class="solar-axis-contact"`, `class="solar-subsolar-marker"`, `font-size="7"`, `opacity="0.72">太阳直射点</text>`,
|
||||
`class="land"`, "不含行政边界",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("total solar-eclipse map missing %q", want)
|
||||
}
|
||||
}
|
||||
// 瞬时半影/本影轮廓默认不画,否则会把地球盖住。
|
||||
for _, unwanted := range []string{`class="solar-penumbral-outline"`, `class="solar-central-shadow-outline"`} {
|
||||
if strings.Contains(diagram, unwanted) {
|
||||
t.Fatalf("default map must not draw %s", unwanted)
|
||||
}
|
||||
}
|
||||
if err := validateEclipseMapXML(diagram); err != nil {
|
||||
t.Fatalf("total solar-eclipse map is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipsePartialBandPolygonsContainSampledFootprints(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
date time.Time
|
||||
step time.Duration
|
||||
boundaries int
|
||||
}{
|
||||
{name: "2009 antimeridian", date: time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC), step: 2 * time.Minute, boundaries: 96},
|
||||
{name: "2010 polar fold", date: time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), step: 10 * time.Minute, boundaries: 24},
|
||||
{name: "2014 noncentral", date: time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), step: 10 * time.Minute, boundaries: 24},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
partial, ok := eclipsecore.SolarEclipsePartialFootprints(test.date, eclipsecore.SolarEclipsePartialFootprintOptions{
|
||||
Step: test.step, BoundaryPoints: test.boundaries,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar eclipse")
|
||||
}
|
||||
polygons, ok := solarEclipsePartialBandPolygons(partial)
|
||||
if !ok || len(polygons) == 0 {
|
||||
t.Fatal("expected authoritative partial-band polygons")
|
||||
}
|
||||
paths := make([][]geodata.GeoPoint, 0, len(partial.Footprints)*2)
|
||||
for _, footprint := range partial.Footprints {
|
||||
for _, boundary := range footprint.Boundaries {
|
||||
path := make([]geodata.GeoPoint, len(boundary))
|
||||
for index, point := range boundary {
|
||||
path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false); miss > 5 {
|
||||
t.Fatalf("sampled penumbral boundary protrudes %.2f km outside the authoritative band", miss)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) {
|
||||
diagram, ok := SolarEclipseMapSVG(
|
||||
time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
|
||||
@@ -54,7 +102,7 @@ func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) {
|
||||
if strings.Contains(diagram, `class="central-eclipse-band"`) || strings.Contains(diagram, `class="solar-center-line"`) {
|
||||
t.Fatal("partial-only map contains a central path")
|
||||
}
|
||||
if !strings.Contains(diagram, `class="solar-global-events"`) || !strings.Contains(diagram, "全球阶段") {
|
||||
if !strings.Contains(diagram, `class="solar-detailed-panel"`) || !strings.Contains(diagram, "食甚点的地方情况") {
|
||||
t.Fatal("partial-only map is missing global phase information")
|
||||
}
|
||||
if !strings.Contains(diagram, "全球见食范围") || strings.Contains(diagram, "全球见食范围与中心食带") {
|
||||
@@ -100,7 +148,7 @@ func TestSolarEclipseMapSVGCanDisableSampledShadowOutlines(t *testing.T) {
|
||||
if strings.Contains(diagram, "半影时刻线") || strings.Contains(diagram, "本影轮廓") {
|
||||
t.Fatal("disabled sampled shadow outlines remain in the legend")
|
||||
}
|
||||
if !strings.Contains(diagram, `class="solar-greatest-terminator"`) ||
|
||||
if !strings.Contains(diagram, `class="solar-rise-set-boundary solar-greatest-rise"`) ||
|
||||
!strings.Contains(diagram, `class="solar-shadow-contact solar-contact-u1"`) {
|
||||
t.Fatal("disabling sampled outlines removed required contact geometry")
|
||||
}
|
||||
@@ -114,19 +162,22 @@ func TestSolarEclipseMapSVGExplainsSampledShadowLines(t *testing.T) {
|
||||
Projection: EclipseMapProjectionEquirectangular,
|
||||
}
|
||||
normalized := normalizeSolarEclipseMapSVGOptions(date, options)
|
||||
if normalized.PenumbralOutlineStep != time.Hour {
|
||||
t.Fatalf("default penumbral outline step = %s, want 1h", normalized.PenumbralOutlineStep)
|
||||
if normalized.PenumbralOutlineStep != 0 {
|
||||
t.Fatalf("default penumbral outline step = %s, want it disabled", normalized.PenumbralOutlineStep)
|
||||
}
|
||||
explicit := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{PenumbralOutlineStep: 30 * time.Minute})
|
||||
if explicit.PenumbralOutlineStep != 30*time.Minute {
|
||||
t.Fatalf("explicit penumbral outline step = %s, want 30m", explicit.PenumbralOutlineStep)
|
||||
}
|
||||
// 显式请求时才画瞬时轮廓,图例也要给出采样间隔。
|
||||
options.PenumbralOutlineStep = time.Hour
|
||||
options.CentralShadowStep = 10 * time.Minute
|
||||
diagram, ok := SolarEclipseMapSVG(date, options)
|
||||
if !ok {
|
||||
t.Fatal("expected 2035 total solar-eclipse map")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"半影时刻线(60 分钟)", "食甚晨昏圈", "本影轮廓(10 分钟)", "P/U 影锥接触",
|
||||
"半影时刻线(60 分钟)", "初亏/食甚/复圆日升日落线", "本影轮廓(10 分钟)", "P/U 影锥接触",
|
||||
`class="solar-penumbral-time-label"`, `class="solar-map-legend"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
@@ -156,7 +207,7 @@ func TestSolarEclipseMapSVGAntarcticEventUsesSouthPolarProjection(t *testing.T)
|
||||
func TestSolarEclipseMapSVGAnnularLabelsCentralBand(t *testing.T) {
|
||||
diagram, ok := SolarEclipseMapSVG(
|
||||
time.Date(2023, 10, 14, 0, 0, 0, 0, time.UTC),
|
||||
SolarEclipseMapSVGOptions{PartialStep: 15 * time.Minute},
|
||||
SolarEclipseMapSVGOptions{PartialStep: 15 * time.Minute, CentralShadowStep: 10 * time.Minute},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected annular solar-eclipse map")
|
||||
@@ -169,6 +220,165 @@ func TestSolarEclipseMapSVGAnnularLabelsCentralBand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseMapSVG20100115TrimsExternalContactsFromTwoLimitSides(t *testing.T) {
|
||||
path, ok := eclipsecore.SolarEclipseCentralPath(
|
||||
time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC),
|
||||
eclipsecore.SolarEclipsePathOptions{Step: 2 * time.Minute, TargetSpacingKM: 700},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 2010 annular central path")
|
||||
}
|
||||
northern, southern, ok := solarEclipseTwoLimitPresentationLimits(path)
|
||||
if !ok {
|
||||
t.Fatal("expected physical-endpoint two-limit presentation geometry")
|
||||
}
|
||||
if len(northern) >= len(path.NorthernLimit) || len(southern) >= len(path.SouthernLimit) {
|
||||
t.Fatal("external-contact samples were not trimmed from the rendered limits")
|
||||
}
|
||||
start := path.CenterLine[0].Time
|
||||
end := path.CenterLine[len(path.CenterLine)-1].Time
|
||||
for name, points := range map[string][]eclipsecore.SolarEclipsePathPoint{
|
||||
"north": northern,
|
||||
"south": southern,
|
||||
} {
|
||||
if !points[0].Time.After(start) || !points[len(points)-1].Time.Before(end) {
|
||||
t.Fatalf("%s rendered limit extends outside the axis-contact interval", name)
|
||||
}
|
||||
}
|
||||
partial, ok := eclipsecore.SolarEclipsePartialFootprints(
|
||||
time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC),
|
||||
eclipsecore.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 2010 annular partial footprint model")
|
||||
}
|
||||
polygons, ok := solarEclipseTwoLimitBandPolygons(
|
||||
path, northern, southern, partial.CentralBandFootprints,
|
||||
)
|
||||
if !ok || len(polygons) != 1 || len(polygons[0]) <= len(northern)+len(southern) {
|
||||
t.Fatalf("physical endpoint sweep polygons=%d ok=%v, want one enriched central-band outline", len(polygons), ok)
|
||||
}
|
||||
minimumEndTurn := 180.0
|
||||
for index := 1; index+1 < len(polygons[0]); index++ {
|
||||
point := polygons[0][index]
|
||||
if point.Longitude < 120 || point.Longitude > 123 || point.Latitude < 36 || point.Latitude > 39 {
|
||||
continue
|
||||
}
|
||||
previous, next := polygons[0][index-1], polygons[0][index+1]
|
||||
incoming := math.Atan2(point.Latitude-previous.Latitude, point.Longitude-previous.Longitude)
|
||||
outgoing := math.Atan2(next.Latitude-point.Latitude, next.Longitude-point.Longitude)
|
||||
minimumEndTurn = math.Min(minimumEndTurn, math.Remainder((outgoing-incoming)*180/math.Pi, 360))
|
||||
}
|
||||
if minimumEndTurn < -30 {
|
||||
t.Fatalf("2010 eastern SVG central-band cap turns inward by %.1f degrees", minimumEndTurn)
|
||||
}
|
||||
diagram, ok := SolarEclipseMapSVG(
|
||||
time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC),
|
||||
SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 2010 annular SVG")
|
||||
}
|
||||
if strings.Contains(diagram, `<g class="solar-central-shadow-sweep"`) {
|
||||
t.Fatal("central eclipse renders a second endpoint sweep over the canonical central band")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseMapSVGNonCentralAnnularUsesContinuousShadowSweep(t *testing.T) {
|
||||
diagram, ok := SolarEclipseMapSVG(
|
||||
time.Date(2014, 4, 29, 0, 0, 0, 0, time.UTC),
|
||||
SolarEclipseMapSVGOptions{
|
||||
Width: 1200, Height: 800, Location: time.UTC,
|
||||
Projection: EclipseMapProjectionEquirectangular,
|
||||
PartialStep: 10 * time.Minute, CentralShadowStep: 10 * time.Minute,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 2014 non-central annular solar-eclipse map")
|
||||
}
|
||||
start := strings.Index(diagram, `<g class="solar-central-shadow-sweep"`)
|
||||
if start < 0 {
|
||||
t.Fatal("non-central annular map is missing the continuous central-shadow sweep")
|
||||
}
|
||||
end := strings.Index(diagram[start:], `</g>`)
|
||||
if end < 0 {
|
||||
t.Fatal("central-shadow sweep group is not closed")
|
||||
}
|
||||
group := diagram[start : start+end]
|
||||
if got := strings.Count(group, `<path d="`); got != 1 {
|
||||
t.Fatalf("non-central annular sweep rendered %d disconnected paths, want one continuous path", got)
|
||||
}
|
||||
if strings.Contains(diagram, `class="central-eclipse-band"`) || strings.Contains(diagram, `class="solar-center-line"`) {
|
||||
t.Fatal("non-central annular map contains a central path")
|
||||
}
|
||||
if strings.Contains(diagram, `class="solar-central-shadow-outline"`) {
|
||||
t.Fatal("non-central annular map renders diagnostic open shadow arcs as interior boundary lines")
|
||||
}
|
||||
for _, want := range []string{"环食带", "P/U 影锥接触"} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("non-central annular legend is missing %q", want)
|
||||
}
|
||||
}
|
||||
if err := validateEclipseMapXML(diagram); err != nil {
|
||||
t.Fatalf("non-central annular map is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseMapSVG20430409NonCentralTotalUsesCriticalEnvelope(t *testing.T) {
|
||||
diagram, ok := SolarEclipseMapSVG(
|
||||
time.Date(2043, 4, 9, 0, 0, 0, 0, time.UTC),
|
||||
SolarEclipseMapSVGOptions{
|
||||
Width: 1200, Height: 800, Location: time.UTC,
|
||||
Projection: EclipseMapProjectionEquirectangular,
|
||||
PartialStep: 2 * time.Minute, CentralShadowStep: 2 * time.Minute,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 2043 non-central total solar-eclipse map")
|
||||
}
|
||||
start := strings.Index(diagram, `<g class="solar-central-shadow-sweep" data-source="besselian-critical-envelope"`)
|
||||
if start < 0 {
|
||||
t.Fatal("2043 map is missing the critical-envelope central band")
|
||||
}
|
||||
end := strings.Index(diagram[start:], `</g>`)
|
||||
if end < 0 {
|
||||
t.Fatal("2043 critical-envelope group is not closed")
|
||||
}
|
||||
if got := strings.Count(diagram[start:start+end], `<path d="`); got != 1 {
|
||||
t.Fatalf("2043 critical envelope rendered %d disconnected paths, want one", got)
|
||||
}
|
||||
if strings.Contains(diagram, `class="solar-center-line"`) ||
|
||||
strings.Contains(diagram, `class="solar-central-shadow-outline"`) {
|
||||
t.Fatal("2043 non-central map contains a center line or diagnostic interior shadow arcs")
|
||||
}
|
||||
if err := validateEclipseMapXML(diagram); err != nil {
|
||||
t.Fatalf("2043 non-central total map is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseMapSVG19500318UsesOpenSweepFallback(t *testing.T) {
|
||||
diagram, ok := SolarEclipseMapSVG(
|
||||
time.Date(1950, time.March, 18, 0, 0, 0, 0, time.UTC),
|
||||
SolarEclipseMapSVGOptions{
|
||||
Width: 1200, Height: 800, Location: time.UTC,
|
||||
Projection: EclipseMapProjectionEquirectangular,
|
||||
PartialStep: 5 * time.Minute,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected 1950 non-central annular solar-eclipse map")
|
||||
}
|
||||
if !strings.Contains(diagram, `<g class="solar-central-shadow-sweep"`) {
|
||||
t.Fatal("1950 map is missing the validated open central-shadow sweep")
|
||||
}
|
||||
if strings.Contains(diagram, `class="central-eclipse-band"`) || strings.Contains(diagram, `class="solar-center-line"`) {
|
||||
t.Fatal("1950 non-central map contains a central path")
|
||||
}
|
||||
if err := validateEclipseMapXML(diagram); err != nil {
|
||||
t.Fatalf("1950 non-central map is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolarEclipseMapSVG2012DoesNotFillAntimeridianSpikes(t *testing.T) {
|
||||
cst := time.FixedZone("UTC+8", 8*60*60)
|
||||
date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst)
|
||||
@@ -183,9 +393,15 @@ func TestSolarEclipseMapSVG2012DoesNotFillAntimeridianSpikes(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("expected 2012 annular solar-eclipse map")
|
||||
}
|
||||
// 等经纬图现在按食甚经度居中,测试必须用与实际渲染相同的中心。
|
||||
centre, ok := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date)
|
||||
if !ok {
|
||||
t.Fatal("expected 2012 annular solar-eclipse info")
|
||||
}
|
||||
frame := solarEclipseMapLayoutFor(
|
||||
normalizeSolarEclipseMapSVGOptions(date, options),
|
||||
svgmap.ProjectionEquirectangular,
|
||||
svgmap.GeoPoint{Longitude: centre.GreatestLongitude, Latitude: centre.GreatestLatitude},
|
||||
).frame
|
||||
|
||||
for _, point := range []svgmap.GeoPoint{
|
||||
@@ -196,9 +412,14 @@ func TestSolarEclipseMapSVG2012DoesNotFillAntimeridianSpikes(t *testing.T) {
|
||||
t.Fatalf("known invisible point %#v is inside the rendered partial-eclipse region", point)
|
||||
}
|
||||
}
|
||||
visible := svgmap.GeoPoint{Longitude: 0, Latitude: 85}
|
||||
if !solarPartialRegionContainsGeoPoint(t, diagram, frame, visible) {
|
||||
t.Fatalf("known visible Arctic point %#v is outside the rendered partial-eclipse region", visible)
|
||||
// 正例取确实落在偏食区内的点:0°E/85°N 的上边界只到约 70.7°N,它在界外。
|
||||
for _, visible := range []svgmap.GeoPoint{
|
||||
{Longitude: 140, Latitude: 60},
|
||||
{Longitude: centre.GreatestLongitude, Latitude: centre.GreatestLatitude},
|
||||
} {
|
||||
if !solarPartialRegionContainsGeoPoint(t, diagram, frame, visible) {
|
||||
t.Fatalf("known visible point %#v is outside the rendered partial-eclipse region", visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,12 +463,18 @@ func solarPartialRegionContainsGeoPoint(
|
||||
point svgmap.GeoPoint,
|
||||
) bool {
|
||||
t.Helper()
|
||||
const prefix = `<path class="partial-eclipse-region" d="`
|
||||
const prefix = `<path class="partial-eclipse-region"`
|
||||
start := strings.Index(diagram, prefix)
|
||||
if start < 0 {
|
||||
t.Fatal("partial-eclipse SVG path is missing")
|
||||
}
|
||||
value := diagram[start+len(prefix):]
|
||||
const marker = ` d="`
|
||||
dataStart := strings.Index(value, marker)
|
||||
if dataStart < 0 {
|
||||
t.Fatal("partial-eclipse SVG path carries no geometry")
|
||||
}
|
||||
value = value[dataStart+len(marker):]
|
||||
end := strings.IndexByte(value, '"')
|
||||
if end < 0 {
|
||||
t.Fatal("partial-eclipse SVG path is malformed")
|
||||
|
||||
Reference in New Issue
Block a user