feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user