2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
316 lines
11 KiB
Go
316 lines
11 KiB
Go
package geodata
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) {
|
|
segments := PolylineSegments([]GeoPoint{
|
|
{Longitude: -180, Latitude: 10},
|
|
{Longitude: 180, Latitude: 20},
|
|
}, ClipView{Projection: ProjectionEquirectangular})
|
|
if len(segments) != 1 || len(segments[0]) != 2 {
|
|
t.Fatalf("exact-antimeridian line segments = %#v", segments)
|
|
}
|
|
if segments[0][0].Longitude != segments[0][1].Longitude {
|
|
t.Fatalf("exact-antimeridian line spans %.1f degrees",
|
|
math.Abs(segments[0][1].Longitude-segments[0][0].Longitude))
|
|
}
|
|
}
|
|
|
|
func TestPolylineSegmentsInterpolatesAntimeridianOnSphere(t *testing.T) {
|
|
segments := PolylineSegments([]GeoPoint{
|
|
{Longitude: 170, Latitude: 80},
|
|
{Longitude: -170, Latitude: 80},
|
|
}, ClipView{Projection: ProjectionEquirectangular})
|
|
if len(segments) != 2 {
|
|
t.Fatalf("high-latitude antimeridian line segments = %d, want 2", len(segments))
|
|
}
|
|
firstCrossing := segments[0][len(segments[0])-1]
|
|
secondCrossing := segments[1][0]
|
|
if firstCrossing.Longitude != 180 || secondCrossing.Longitude != -180 {
|
|
t.Fatalf("unexpected antimeridian crossings: %+v %+v", firstCrossing, secondCrossing)
|
|
}
|
|
if math.Abs(firstCrossing.Latitude-secondCrossing.Latitude) > 1e-10 {
|
|
t.Fatalf("split crossings disagree in latitude: %.12f != %.12f",
|
|
firstCrossing.Latitude, secondCrossing.Latitude)
|
|
}
|
|
// The shorter great-circle arc bends poleward. Linear longitude/latitude
|
|
// interpolation would incorrectly leave the crossing at exactly 80 degrees.
|
|
if firstCrossing.Latitude <= 80.1 || firstCrossing.Latitude >= 81 {
|
|
t.Fatalf("great-circle crossing latitude = %.9f, want (80.1, 81)", firstCrossing.Latitude)
|
|
}
|
|
}
|
|
|
|
func TestPolarPolylineClippingUsesSphericalEquatorIntersection(t *testing.T) {
|
|
segments := PolylineSegments([]GeoPoint{
|
|
{Longitude: 0, Latitude: -10},
|
|
{Longitude: 90, Latitude: 80},
|
|
}, ClipView{Projection: ProjectionNorthPolar})
|
|
if len(segments) != 1 || len(segments[0]) != 2 {
|
|
t.Fatalf("north-polar clipped line = %#v", segments)
|
|
}
|
|
crossing := segments[0][0]
|
|
if math.Abs(crossing.Latitude) > 1e-10 {
|
|
t.Fatalf("equator crossing latitude = %.12f", crossing.Latitude)
|
|
}
|
|
// The great circle crosses near 2 degrees, not at the 45 degree
|
|
// arithmetic interpolation used by planar clipping.
|
|
if crossing.Longitude < 1 || crossing.Longitude > 3 {
|
|
t.Fatalf("spherical equator crossing longitude = %.9f, want (1, 3)", crossing.Longitude)
|
|
}
|
|
}
|
|
|
|
func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
|
|
fragments := PolygonFragments([]GeoPoint{
|
|
{Longitude: -180, Latitude: 15},
|
|
{Longitude: 180, Latitude: 14},
|
|
{Longitude: 150, Latitude: 13},
|
|
{Longitude: 150, Latitude: -12},
|
|
{Longitude: 180, Latitude: -11},
|
|
{Longitude: -180, Latitude: -10},
|
|
}, ClipView{Projection: ProjectionEquirectangular})
|
|
if len(fragments) != 1 {
|
|
t.Fatalf("exact-antimeridian polygon produced %d fragments, want 1", len(fragments))
|
|
}
|
|
if math.Abs(signedPolygonArea(fragments[0])) < 1e-12 {
|
|
t.Fatal("exact-antimeridian polygon fragment has zero area")
|
|
}
|
|
}
|
|
|
|
func TestPolygonFragmentsClosesPoleEnclosingRingsAtEquirectangularMapEdge(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
boundaryLatitude float64
|
|
insideLatitude float64
|
|
outsideLatitude float64
|
|
}{
|
|
{name: "north", boundaryLatitude: 70, insideLatitude: 89, outsideLatitude: -89},
|
|
{name: "south", boundaryLatitude: -70, insideLatitude: -89, outsideLatitude: 89},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
polygon := []GeoPoint{
|
|
{Longitude: -135, Latitude: test.boundaryLatitude},
|
|
{Longitude: -45, Latitude: test.boundaryLatitude},
|
|
{Longitude: 45, Latitude: test.boundaryLatitude},
|
|
{Longitude: 135, Latitude: test.boundaryLatitude},
|
|
{Longitude: -135, Latitude: test.boundaryLatitude},
|
|
}
|
|
fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular})
|
|
if len(fragments) == 0 {
|
|
t.Fatal("pole-enclosing polygon produced no fragments")
|
|
}
|
|
for _, longitude := range []float64{-150, -90, 0, 90, 150} {
|
|
if !polygonFragmentsContain(fragments, longitude, test.insideLatitude) {
|
|
t.Fatalf("pole cap does not contain %.0f, %.0f", longitude, test.insideLatitude)
|
|
}
|
|
if polygonFragmentsContain(fragments, longitude, test.outsideLatitude) {
|
|
t.Fatalf("pole cap incorrectly contains %.0f, %.0f", longitude, test.outsideLatitude)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolygonFragmentsPoleClosurePreservesAntimeridianSeam(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
poleLatitude float64
|
|
boundary float64
|
|
}{
|
|
{name: "north", poleLatitude: 90, boundary: 50},
|
|
{name: "south", poleLatitude: -90, boundary: -50},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
polygon := []GeoPoint{
|
|
{Longitude: 150, Latitude: test.boundary},
|
|
{Longitude: 180, Latitude: 5},
|
|
{Longitude: 120, Latitude: -5},
|
|
{Longitude: 30, Latitude: 15},
|
|
{Longitude: -60, Latitude: 25},
|
|
{Longitude: -150, Latitude: 35},
|
|
}
|
|
if test.poleLatitude < 0 {
|
|
polygon = mirrorGeoPointLatitudes(polygon)
|
|
}
|
|
fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular})
|
|
if len(fragments) == 0 || len(fragments) > 2 {
|
|
t.Fatalf("pole polygon fragments=%d, want one or two map-edge fragments", len(fragments))
|
|
}
|
|
for _, longitude := range []float64{149, 151} {
|
|
latitude := 60.0
|
|
if test.poleLatitude < 0 {
|
|
latitude = -60
|
|
}
|
|
if !polygonFragmentsContain(fragments, longitude, latitude) {
|
|
t.Fatalf("pole closure lost %.0f, %.0f beside its internal seam", longitude, latitude)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolygonFragmentsPoleClosureAnchorsInteriorStartAtAntimeridian(t *testing.T) {
|
|
// This ring winds around the north pole but starts in the Bering Sea. The
|
|
// pole closure must use the map seam, not a meridian through that start
|
|
// vertex.
|
|
fragments := PolygonFragments([]GeoPoint{
|
|
{Longitude: -172, Latitude: 50},
|
|
{Longitude: -100, Latitude: 70},
|
|
{Longitude: 0, Latitude: 80},
|
|
{Longitude: 100, Latitude: 70},
|
|
{Longitude: 188, Latitude: 50},
|
|
}, ClipView{Projection: ProjectionEquirectangular})
|
|
if len(fragments) == 0 {
|
|
t.Fatal("pole-winding ring produced no fragments")
|
|
}
|
|
for _, fragment := range fragments {
|
|
for index, first := range fragment {
|
|
second := fragment[(index+1)%len(fragment)]
|
|
if math.Abs(first.Longitude-second.Longitude) <= 1e-9 &&
|
|
math.Abs(first.Latitude-second.Latitude) > 20 &&
|
|
math.Abs(first.Longitude) < 179.999 {
|
|
t.Fatalf("interior pole closure at %.3f: %+v -> %+v", first.Longitude, first, second)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPolygonFragmentsPoleClosureDoesNotDependOnVertexDensity(t *testing.T) {
|
|
base := []GeoPoint{
|
|
{Longitude: -135, Latitude: 70},
|
|
{Longitude: -45, Latitude: 70},
|
|
{Longitude: 45, Latitude: -60},
|
|
{Longitude: 135, Latitude: -60},
|
|
}
|
|
dense := []GeoPoint{
|
|
{Longitude: -135, Latitude: 70},
|
|
{Longitude: -45, Latitude: 70},
|
|
{Longitude: 45, Latitude: -60},
|
|
{Longitude: 55, Latitude: -60},
|
|
{Longitude: 65, Latitude: -60},
|
|
{Longitude: 75, Latitude: -60},
|
|
{Longitude: 85, Latitude: -60},
|
|
{Longitude: 95, Latitude: -60},
|
|
{Longitude: 105, Latitude: -60},
|
|
{Longitude: 115, Latitude: -60},
|
|
{Longitude: 125, Latitude: -60},
|
|
{Longitude: 135, Latitude: -60},
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
base []GeoPoint
|
|
dense []GeoPoint
|
|
}{
|
|
{name: "north", base: base, dense: dense},
|
|
{name: "south", base: mirrorGeoPointLatitudes(base), dense: mirrorGeoPointLatitudes(dense)},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
baseFragments := PolygonFragments(test.base, ClipView{Projection: ProjectionEquirectangular})
|
|
denseFragments := PolygonFragments(test.dense, ClipView{Projection: ProjectionEquirectangular})
|
|
for _, point := range []GeoPoint{
|
|
{Longitude: 0, Latitude: 89},
|
|
{Longitude: 0, Latitude: -89},
|
|
} {
|
|
baseContains := polygonFragmentsContain(baseFragments, point.Longitude, point.Latitude)
|
|
denseContains := polygonFragmentsContain(denseFragments, point.Longitude, point.Latitude)
|
|
if denseContains != baseContains {
|
|
t.Fatalf("densifying a boundary changed containment at %.0f, %.0f: base=%v dense=%v",
|
|
point.Longitude, point.Latitude, baseContains, denseContains)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPolygonFragmentsPolarIndentationPreservesInterior(t *testing.T) {
|
|
// A polar boundary crosses the map seam three times. Closing through the
|
|
// lowest crossing fills the excluded indentation on the east side.
|
|
polygon := []GeoPoint{
|
|
{Longitude: -172, Latitude: 50}, {Longitude: -179, Latitude: 54},
|
|
{Longitude: 175, Latitude: 60}, {Longitude: 173, Latitude: 65},
|
|
{Longitude: 175, Latitude: 70}, {Longitude: -178, Latitude: 75},
|
|
{Longitude: -140, Latitude: 79}, {Longitude: -70, Latitude: 68},
|
|
{Longitude: 0, Latitude: 15}, {Longitude: 90, Latitude: 0},
|
|
{Longitude: 140, Latitude: -23}, {Longitude: 175, Latitude: 30},
|
|
{Longitude: -179, Latitude: 40},
|
|
}
|
|
for _, sign := range []float64{1, -1} {
|
|
points := append([]GeoPoint(nil), polygon...)
|
|
for i := range points {
|
|
points[i].Latitude *= sign
|
|
}
|
|
for offset := range points {
|
|
rotated := append(append([]GeoPoint(nil), points[offset:]...), points[:offset]...)
|
|
for _, reverse := range []bool{false, true} {
|
|
if reverse {
|
|
for i, j := 0, len(rotated)-1; i < j; i, j = i+1, j-1 {
|
|
rotated[i], rotated[j] = rotated[j], rotated[i]
|
|
}
|
|
}
|
|
fragments := PolygonFragments(rotated, ClipView{Projection: ProjectionEquirectangular})
|
|
for _, point := range []GeoPoint{
|
|
{Longitude: 179, Latitude: sign * 65}, {Longitude: 175, Latitude: sign * 65},
|
|
{Longitude: 170, Latitude: sign * 65}, {Longitude: -179, Latitude: sign * 65},
|
|
{Longitude: 179, Latitude: sign * 85}, {Longitude: 0, Latitude: sign * 85},
|
|
} {
|
|
want := SphericalPolygonsContainPoints([][]GeoPoint{rotated}, []GeoPoint{point})[0]
|
|
if got := polygonFragmentsContain(fragments, point.Longitude, point.Latitude); got != want {
|
|
t.Fatalf("sign=%g offset=%d reversed=%v point=%+v: split=%v sphere=%v", sign, offset, reverse, point, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func mirrorGeoPointLatitudes(points []GeoPoint) []GeoPoint {
|
|
mirrored := make([]GeoPoint, len(points))
|
|
for index, point := range points {
|
|
mirrored[index] = GeoPoint{Longitude: point.Longitude, Latitude: -point.Latitude}
|
|
}
|
|
return mirrored
|
|
}
|
|
|
|
func polygonFragmentsContain(fragments [][]GeoPoint, longitude, latitude float64) bool {
|
|
for _, polygon := range fragments {
|
|
inside := false
|
|
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
|
|
a, b := polygon[previous], polygon[current]
|
|
if (a.Latitude > latitude) == (b.Latitude > latitude) {
|
|
continue
|
|
}
|
|
intersection := a.Longitude +
|
|
(latitude-a.Latitude)*(b.Longitude-a.Longitude)/(b.Latitude-a.Latitude)
|
|
if intersection > longitude {
|
|
inside = !inside
|
|
}
|
|
}
|
|
if inside {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestClosePoleEnclosingPolygonRequiresSingleWinding(t *testing.T) {
|
|
open := []GeoPoint{
|
|
{Longitude: 0, Latitude: 0},
|
|
{Longitude: 1, Latitude: 0},
|
|
{Longitude: 1, Latitude: 1},
|
|
}
|
|
if closed := closePoleEnclosingPolygon(open); len(closed) != len(open) {
|
|
t.Fatalf("a ring without pole winding gained %d closure points", len(closed)-len(open))
|
|
}
|
|
winding := []GeoPoint{
|
|
{Longitude: -135, Latitude: 70},
|
|
{Longitude: -45, Latitude: 70},
|
|
{Longitude: 45, Latitude: 70},
|
|
{Longitude: 135, Latitude: 70},
|
|
}
|
|
if closed := closePoleEnclosingPolygon(winding); len(closed) <= len(winding) {
|
|
t.Fatalf("a single-winding ring kept %d points, want a pole closure", len(closed))
|
|
}
|
|
}
|