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