9ee2163cc7
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
261 lines
9.0 KiB
Go
261 lines
9.0 KiB
Go
package svg
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"b612.me/astro/internal/svgmap"
|
|
)
|
|
|
|
func TestLunarEclipseMapSVGVisibilityRegions(t *testing.T) {
|
|
diagram, ok := LunarEclipseMapSVG(
|
|
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
|
LunarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC},
|
|
)
|
|
if !ok {
|
|
t.Fatal("expected lunar-eclipse visibility map")
|
|
}
|
|
for _, want := range []string{
|
|
"月全食全球可见图", "P1", "P4", "食甚", "全程可见", "带食月出", "带食月落", "不可见",
|
|
`class="entire-eclipse-region"`, "moonrise-region", "moonset-region",
|
|
`class="p1-horizon"`, `class="p4-horizon"`, `class="land"`, "不含行政边界",
|
|
} {
|
|
if !strings.Contains(diagram, want) {
|
|
t.Fatalf("lunar-eclipse map missing %q", want)
|
|
}
|
|
}
|
|
if err := validateEclipseMapXML(diagram); err != nil {
|
|
t.Fatalf("lunar-eclipse map is not valid XML: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
|
|
cst := time.FixedZone("CST", 8*60*60)
|
|
diagram, ok := LunarEclipseMapSVG(
|
|
time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
|
|
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst},
|
|
)
|
|
if !ok {
|
|
t.Fatal("expected lunar-eclipse visibility map")
|
|
}
|
|
|
|
for _, want := range []string{
|
|
`mask id="lunar-not-visible-mask"`,
|
|
`mask id="lunar-not-p4-mask"`,
|
|
`mask id="lunar-not-p1-mask"`,
|
|
`class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)"`,
|
|
`class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)"`,
|
|
`class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)"`,
|
|
} {
|
|
if !strings.Contains(diagram, want) {
|
|
t.Fatalf("lunar-eclipse map does not render exclusive visibility regions: missing %q", want)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) {
|
|
diagram, ok := LunarEclipseMapSVG(
|
|
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
|
LunarEclipseMapSVGOptions{Projection: EclipseMapProjectionSouthPolar},
|
|
)
|
|
if !ok {
|
|
t.Fatal("expected forced south-polar lunar-eclipse map")
|
|
}
|
|
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "南极方位等距投影"} {
|
|
if !strings.Contains(diagram, want) {
|
|
t.Fatalf("south-polar lunar-eclipse map missing %q", want)
|
|
}
|
|
}
|
|
if err := validateEclipseMapXML(diagram); err != nil {
|
|
t.Fatalf("south-polar lunar-eclipse map is not valid XML: %v", err)
|
|
}
|
|
}
|
|
|
|
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,
|
|
} {
|
|
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
|
|
}
|
|
if test.projection == svgmap.ProjectionSouthPolar && latitude >= 0 {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLunarEclipseMapSVGRejectsNoEventAndInvalidProjection(t *testing.T) {
|
|
if _, ok := LunarEclipseMapSVG(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), LunarEclipseMapSVGOptions{}); ok {
|
|
t.Fatal("unexpected lunar-eclipse map for a no-event date")
|
|
}
|
|
if _, ok := LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), LunarEclipseMapSVGOptions{Projection: "invalid"}); ok {
|
|
t.Fatal("invalid projection was accepted")
|
|
}
|
|
}
|
|
|
|
func validateEclipseMapXML(value string) error {
|
|
decoder := xml.NewDecoder(strings.NewReader(value))
|
|
for {
|
|
if _, err := decoder.Token(); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
a, b := polygon[current], polygon[previous]
|
|
crosses := (a[1] > y) != (b[1] > y)
|
|
if crosses && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] {
|
|
inside = !inside
|
|
}
|
|
}
|
|
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)
|
|
}
|