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

178 lines
6.9 KiB
Go

package geojson
import (
"math"
"sort"
"b612.me/astro/internal/geodata"
)
// GeoJSON joins vertices with straight longitude/latitude segments. Bound
// their deviation from the spherical edges before antimeridian clipping,
// especially for a short arc that passes close to either pole.
func sampleSphericalMapRing(points []geodata.GeoPoint) []geodata.GeoPoint {
return sampleSphericalMapRingWithin(points, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees)
}
// Exported map chords are bounded so that a straight lon/lat segment still
// follows the spherical edge. Filled footprint polygons tolerate a much coarser
// bound than the strokes that carry the path limits: they are drawn as
// translucent fills, and their rings dominate the payload (a solar eclipse
// exports a hundred penumbral footprints).
const (
sphericalMapChordLimitKM = 100.0
sphericalMapChordErrorDegrees = 0.002
sphericalFillChordLimitKM = 400.0
sphericalFillChordErrorDegrees = 0.02
)
// sampleSphericalMapRingWithin is sampleSphericalMapRing with explicit chord
// limits, used for fill-only geometry.
func sampleSphericalMapRingWithin(
points []geodata.GeoPoint,
chordLimitKM, chordErrorDegrees float64,
) []geodata.GeoPoint {
if len(points) < 3 {
return points
}
if len(points) == 3 {
return sampleSphericalMapTriangle(points)
}
result := make([]geodata.GeoPoint, 0, len(points))
for index, point := range points {
result = appendSphericalMapArcWithin(
result, point, points[(index+1)%len(points)], 0, chordLimitKM, chordErrorDegrees,
)
}
return result
}
func sphericalMapChordError(first, middle, last geodata.GeoPoint) float64 {
longitude := first.Longitude + math.Remainder(last.Longitude-first.Longitude, 360)/2
return math.Hypot(math.Remainder(middle.Longitude-longitude, 360), middle.Latitude-(first.Latitude+last.Latitude)/2)
}
func appendSphericalMapArc(points []geodata.GeoPoint, first, last geodata.GeoPoint, depth int) []geodata.GeoPoint {
return appendSphericalMapArcWithin(
points, first, last, depth, sphericalMapChordLimitKM, sphericalMapChordErrorDegrees,
)
}
func appendSphericalMapArcWithin(
points []geodata.GeoPoint,
first, last geodata.GeoPoint,
depth int,
chordLimitKM, chordErrorDegrees float64,
) []geodata.GeoPoint {
middle := geodata.InterpolateGreatCircle(first, last, 0.5)
// Keep exported map chords bounded even when a great-circle arc is nearly
// linear in lon/lat (notably the long horizon closure edges of shallow
// polar eclipses). The adaptive angular-error test alone cannot see that
// case and leaves visually abrupt 250+ km segments.
arcDistanceKM := solarCentralBandGeoPointDistanceKM(first, last)
if (sphericalMapChordError(first, middle, last) <= chordErrorDegrees && arcDistanceKM <= chordLimitKM) || depth >= 20 {
return append(points, first)
}
points = appendSphericalMapArcWithin(points, first, middle, depth+1, chordLimitKM, chordErrorDegrees)
return appendSphericalMapArcWithin(points, middle, last, depth+1, chordLimitKM, chordErrorDegrees)
}
func sampleSphericalMapPath(source []pathSample) []pathSample {
if len(source) < 2 {
return source
}
result := make([]pathSample, 0, len(source))
var refine func(pathSample, pathSample, int)
refine = func(first, last pathSample, depth int) {
a := geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude}
b := geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}
middle := geodata.InterpolateGreatCircle(a, b, 0.5)
at := first.Time.Add(last.Time.Sub(first.Time) / 2)
if sphericalMapChordError(a, middle, b) <= 0.002 || depth >= 20 || at.Equal(first.Time) || at.Equal(last.Time) {
result = append(result, first)
return
}
point := pathSample{Time: at, Longitude: middle.Longitude, Latitude: middle.Latitude}
refine(first, point, depth+1)
refine(point, last, depth+1)
}
for i := 1; i < len(source); i++ {
refine(source[i-1], source[i], 0)
}
return append(result, source[len(source)-1])
}
// Match the subdivisions on both long sides of a thin spherical triangle.
// Independent chord approximations can cross even with a small absolute error.
func sampleSphericalMapTriangle(points []geodata.GeoPoint) []geodata.GeoPoint {
apex, shortest := 0, math.Inf(1)
for i := range points {
if length := solarCentralBandGeoPointDistanceKM(points[(i+1)%3], points[(i+2)%3]); length < shortest {
apex, shortest = i, length
}
}
a, b, c := points[apex], points[(apex+1)%3], points[(apex+2)%3]
var left, right []geodata.GeoPoint
var refine func(geodata.GeoPoint, geodata.GeoPoint, geodata.GeoPoint, geodata.GeoPoint, int)
refine = func(a, b, c, d geodata.GeoPoint, depth int) {
m, n := geodata.InterpolateGreatCircle(a, b, 0.5), geodata.InterpolateGreatCircle(c, d, 0.5)
if depth >= 20 || math.Max(sphericalMapChordError(a, m, b), sphericalMapChordError(c, n, d)) <= 0.002 {
left, right = append(left, a), append(right, c)
return
}
refine(a, m, c, n, depth+1)
refine(m, b, n, d, depth+1)
}
refine(a, b, a, c, 0)
left, right = append(left, b), append(right, c)
left, right = alignSphericalMapTriangleSides(left, right)
left = left[:len(left)-1]
left = appendSphericalMapArc(left, b, c, 0)
left = append(left, c)
for i := len(right) - 2; i > 0; i-- {
left = append(left, right[i])
}
return left
}
func alignSphericalMapTriangleSides(left, right []geodata.GeoPoint) ([]geodata.GeoPoint, []geodata.GeoPoint) {
var longitudes []float64
for _, side := range [][]geodata.GeoPoint{left, right} {
for i := range side {
if i > 0 {
side[i].Longitude = side[i-1].Longitude + math.Remainder(side[i].Longitude-side[i-1].Longitude, 360)
}
longitudes = append(longitudes, side[i].Longitude)
}
}
sort.Float64s(longitudes)
align := func(side []geodata.GeoPoint) []geodata.GeoPoint {
result := []geodata.GeoPoint{side[0]}
for i := 1; i < len(side); i++ {
a, b := side[i-1], side[i]
lo, hi := math.Min(a.Longitude, b.Longitude), math.Max(a.Longitude, b.Longitude)
first, last := sort.SearchFloat64s(longitudes, lo), sort.SearchFloat64s(longitudes, hi)
for j := first; j < last; j++ {
index := j
if a.Longitude > b.Longitude {
index = first + last - 1 - j
}
lon := longitudes[index]
if lon <= lo+1e-12 || lon >= hi-1e-12 || index > 0 && lon == longitudes[index-1] {
continue
}
// The great-circle plane intersects each intermediate meridian once.
lonA, latA, lonB, latB := a.Longitude*math.Pi/180, a.Latitude*math.Pi/180, b.Longitude*math.Pi/180, b.Latitude*math.Pi/180
x, y, z := math.Cos(latA)*math.Cos(lonA), math.Cos(latA)*math.Sin(lonA), math.Sin(latA)
u, v, w := math.Cos(latB)*math.Cos(lonB), math.Cos(latB)*math.Sin(lonB), math.Sin(latB)
nx, ny, nz := y*w-z*v, z*u-x*w, x*v-y*u
lat := math.Atan(-(nx*math.Cos(lon*math.Pi/180)+ny*math.Sin(lon*math.Pi/180))/nz) * 180 / math.Pi
result = append(result, geodata.GeoPoint{Longitude: lon, Latitude: lat})
}
result = append(result, b)
}
return result
}
return align(left), align(right)
}