Files
astro/eclipse/svg/solar_map_globe_test.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

142 lines
4.9 KiB
Go

package svg
import (
"math"
"regexp"
"strconv"
"strings"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
func solarEclipseGlobeFrame(t *testing.T, date time.Time, options SolarEclipseMapSVGOptions) svgmap.Frame {
t.Helper()
info, ok := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date)
if !ok {
t.Fatal("missing eclipse")
}
return solarEclipseMapLayoutFor(
normalizeSolarEclipseMapSVGOptions(date, options),
svgmap.ProjectionOrthographic,
svgmap.GeoPoint{Longitude: info.GreatestLongitude, Latitude: info.GreatestLatitude},
).frame
}
var solarEclipseGlobeCoordinatePattern = regexp.MustCompile(`[ML](-?[0-9.]+) (-?[0-9.]+)`)
// 正射球面图上陆地必须被裁到圆盘内:背面的陆地若漏进来会折叠到盘内画错位置。
func TestSolarEclipseMapOrthographicClipsLandToDisk(t *testing.T) {
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
options := SolarEclipseMapSVGOptions{
Width: 1000, Height: 760, Location: time.UTC,
Projection: EclipseMapProjectionOrthographic, PartialStep: 10 * time.Minute,
GreatestTimeStep: 30 * time.Minute,
}
rendered, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing map")
}
if !strings.Contains(rendered, `<clipPath id="solar-map-clip"><circle`) {
t.Fatal("an orthographic map must clip to a circular disk")
}
for _, class := range []string{`class="land"`, `class="graticule"`, `class="solar-greatest-time-isoline"`} {
if !strings.Contains(rendered, class) {
t.Fatalf("missing %s on the globe", class)
}
}
const landMarker = `<path class="land" d="`
start := strings.Index(rendered, landMarker)
if start < 0 {
t.Fatal("missing land path")
}
rest := rendered[start+len(landMarker):]
path := rest[:strings.Index(rest, `"`)]
frame := solarEclipseGlobeFrame(t, date, options)
centerX := frame.X + frame.Width/2
centerY := frame.Y + frame.Height/2
radius := math.Min(frame.Width, frame.Height) / 2
matches := solarEclipseGlobeCoordinatePattern.FindAllStringSubmatch(path, -1)
if len(matches) < 2000 {
t.Fatalf("land path carries only %d vertices", len(matches))
}
outside := 0
for _, match := range matches {
x, errX := strconv.ParseFloat(match[1], 64)
y, errY := strconv.ParseFloat(match[2], 64)
if errX != nil || errY != nil {
t.Fatalf("unparsable land coordinate %q %q", match[1], match[2])
}
if math.Hypot(x-centerX, y-centerY) > radius+0.6 {
outside++
}
}
if outside != 0 {
t.Fatalf("%d of %d land vertices fall outside the disk", outside, len(matches))
}
t.Logf("land vertices=%d all inside the disk", len(matches))
}
// 正射投影下食甚点必须落在盘心,且背面点不可见。
func TestSolarEclipseMapOrthographicFrameGeometry(t *testing.T) {
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
options := SolarEclipseMapSVGOptions{
Width: 1000, Height: 760, Location: time.UTC,
Projection: EclipseMapProjectionOrthographic,
}
frame := solarEclipseGlobeFrame(t, date, options)
if frame.Width != frame.Height {
t.Fatalf("globe frame is not square: %gx%g", frame.Width, frame.Height)
}
x, y, ok := frame.Project(frame.CenterLongitude, frame.CenterLatitude)
if !ok || math.Hypot(x-(frame.X+frame.Width/2), y-(frame.Y+frame.Height/2)) > 1e-9 {
t.Fatalf("view centre projects to %g,%g ok=%v", x, y, ok)
}
antipode := svgmap.GeoPoint{
Longitude: frame.CenterLongitude + 180,
Latitude: -frame.CenterLatitude,
}
if _, _, visible := frame.Project(antipode.Longitude, antipode.Latitude); visible {
t.Fatal("the antipode must not project onto the globe")
}
}
// 球面版式只支持显式请求,自动投影仍按事件几何选择。
func TestSolarEclipseMapOrthographicIsExplicitOnly(t *testing.T) {
if !validEclipseMapProjection(EclipseMapProjectionOrthographic) {
t.Fatal("orthographic projection is rejected by validation")
}
if got := internalEclipseMapProjection(EclipseMapProjectionOrthographic); got != svgmap.ProjectionOrthographic {
t.Fatalf("orthographic maps to %q", got)
}
}
// 图上时刻都按展示时区,必须显式写出与 UT 的偏差,且 详细版式的所有内容都不得溢出图框。
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"},
}
for _, test := range cases {
options := SolarEclipseMapSVGOptions{
Width: 1000, Height: 1414, Location: test.location,
Projection: EclipseMapProjectionOrthographic,
}
rendered, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatalf("%s: missing map", test.location)
}
if !strings.Contains(rendered, test.want) {
t.Fatalf("%s: map does not state %q", test.location, test.want)
}
}
}