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

514 lines
18 KiB
Go

package geodata
import "math"
// SphericalCircle 返回球面小圆上的等间隔采样点 / SphericalCircle returns evenly spaced points on a small circle on the
// 球面小圆;方位角从地理北方顺时针采样 / sphere. Bearings are sampled clockwise from geographic north.
func SphericalCircle(center GeoPoint, radiusDegrees float64, points int) []GeoPoint {
// r=0 时采样点重合、r=180 时只剩对跖点副本,都不构成环。
if points < 3 || !(radiusDegrees > 0 && radiusDegrees < 180) {
return nil
}
latitude := center.Latitude * math.Pi / 180
longitude := center.Longitude * math.Pi / 180
radius := radiusDegrees * math.Pi / 180
result := make([]GeoPoint, points)
for index := range result {
bearing := 2 * math.Pi * float64(index) / float64(points)
lat := math.Asin(math.Sin(latitude)*math.Cos(radius) +
math.Cos(latitude)*math.Sin(radius)*math.Cos(bearing))
lon := longitude + math.Atan2(
math.Sin(bearing)*math.Sin(radius)*math.Cos(latitude),
math.Cos(radius)-math.Sin(latitude)*math.Sin(lat),
)
result[index] = GeoPoint{
Longitude: normalizeLongitude(lon * 180 / math.Pi),
Latitude: lat * 180 / math.Pi,
}
}
return result
}
type geoVector3 struct {
x, y, z float64
}
func geoPointVector(point GeoPoint) geoVector3 {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
return geoVector3{
x: cosLatitude * math.Cos(longitude),
y: cosLatitude * math.Sin(longitude),
z: math.Sin(latitude),
}
}
func geoVectorPoint(vector geoVector3) GeoPoint {
length := math.Sqrt(vector.x*vector.x + vector.y*vector.y + vector.z*vector.z)
if length == 0 || math.IsNaN(length) || math.IsInf(length, 0) {
return GeoPoint{Longitude: math.NaN(), Latitude: math.NaN()}
}
vector.x /= length
vector.y /= length
vector.z /= length
return GeoPoint{
Longitude: normalizeLongitude(math.Atan2(vector.y, vector.x) * 180 / math.Pi),
Latitude: math.Asin(math.Max(-1, math.Min(1, vector.z))) * 180 / math.Pi,
}
}
func geoVectorDot(first, second geoVector3) float64 {
return first.x*second.x + first.y*second.y + first.z*second.z
}
func geoVectorCross(first, second geoVector3) geoVector3 {
return geoVector3{
x: first.y*second.z - first.z*second.y,
y: first.z*second.x - first.x*second.z,
z: first.x*second.y - first.y*second.x,
}
}
func geoVectorScale(vector geoVector3, scale float64) geoVector3 {
return geoVector3{x: vector.x * scale, y: vector.y * scale, z: vector.z * scale}
}
func geoVectorAdd(first, second geoVector3) geoVector3 {
return geoVector3{x: first.x + second.x, y: first.y + second.y, z: first.z + second.z}
}
func geoVectorNormalize(vector geoVector3) (geoVector3, bool) {
length := math.Sqrt(geoVectorDot(vector, vector))
if length <= 1e-15 || math.IsNaN(length) || math.IsInf(length, 0) {
return geoVector3{}, false
}
return geoVectorScale(vector, 1/length), true
}
// InterpolateGreatCircle 在较短大圆弧上按分数插值,分数超出 [0,1] 时沿弧延长 / interpolates the shorter great-circle arc, extending it beyond an endpoint.
func InterpolateGreatCircle(first, second GeoPoint, fraction float64) GeoPoint {
return sphericalInterpolate(first, second, fraction)
}
func sphericalInterpolate(first, second GeoPoint, fraction float64) GeoPoint {
a := geoPointVector(first)
b := geoPointVector(second)
dot := math.Max(-1, math.Min(1, geoVectorDot(a, b)))
if dot > 1-1e-14 {
return geoVectorPoint(geoVectorAdd(geoVectorScale(a, 1-fraction), geoVectorScale(b, fraction)))
}
if dot < -1+1e-14 {
// Antipodal endpoints have no unique great circle. The path samplers
// never intentionally create one, but retain a finite fallback for
// malformed caller input.
return GeoPoint{
Longitude: normalizeLongitude(first.Longitude + fraction*normalizeLongitude(second.Longitude-first.Longitude)),
Latitude: first.Latitude + fraction*(second.Latitude-first.Latitude),
}
}
angle := math.Acos(dot)
sine := math.Sin(angle)
value := geoVectorAdd(
geoVectorScale(a, math.Sin((1-fraction)*angle)/sine),
geoVectorScale(b, math.Sin(fraction*angle)/sine),
)
return geoVectorPoint(value)
}
func sphericalPointOnArc(point, first, second GeoPoint) bool {
firstVector := geoPointVector(first)
secondVector := geoPointVector(second)
pointVector := geoPointVector(point)
arc := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, secondVector))))
if arc <= 1e-14 {
return math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, pointVector)))) <= 1e-9
}
firstDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, pointVector))))
secondDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, secondVector))))
return math.Abs(firstDistance+secondDistance-arc) <= 1e-9
}
// sphericalPolygonContainsOrTouches tests a simple ring using its minor great
// circle edges. It is intentionally internal: map encoders still own the
// projection-specific winding rules, while topology code needs a seam-free
// containment predicate for pole and antimeridian decisions.
func sphericalPolygonContainsOrTouches(polygon []GeoPoint, point GeoPoint) bool {
if len(polygon) < 3 {
return false
}
if SameGeoPoint(polygon[0], polygon[len(polygon)-1]) {
polygon = polygon[:len(polygon)-1]
}
target := geoPointVector(point)
angleSum := 0.0
area := 0.0
origin := geoPointVector(polygon[0])
for index, currentPoint := range polygon {
previousPoint := polygon[(index+len(polygon)-1)%len(polygon)]
if sphericalPointOnArc(point, previousPoint, currentPoint) {
return true
}
previous := geoPointVector(previousPoint)
current := geoPointVector(currentPoint)
area += 2 * math.Atan2(geoVectorDot(origin, geoVectorCross(previous, current)),
1+geoVectorDot(origin, previous)+geoVectorDot(previous, current)+geoVectorDot(current, origin))
previousTangent := geoVectorAdd(previous, geoVectorScale(target, -geoVectorDot(previous, target)))
currentTangent := geoVectorAdd(current, geoVectorScale(target, -geoVectorDot(current, target)))
previousTangent, previousOK := geoVectorNormalize(previousTangent)
currentTangent, currentOK := geoVectorNormalize(currentTangent)
if !previousOK || !currentOK {
// A vertex and its antipode both have no tangent. Only the vertex
// belongs to the ring; acos roundoff can miss it in the arc check.
return (!previousOK && geoVectorDot(previous, target) > 0) ||
(!currentOK && geoVectorDot(current, target) > 0)
}
cross := geoVectorCross(previousTangent, currentTangent)
angleSum += math.Atan2(geoVectorDot(target, cross), geoVectorDot(previousTangent, currentTangent))
}
// Tangent winding has opposite signs inside the polygon and its antipodal
// image. Match the signed minor area instead of accepting both images.
return math.Abs(angleSum) > math.Pi && angleSum*math.Remainder(area, 4*math.Pi) > 0
}
// SphericalPolygonsContainPaths 判断每个路径顶点和加密边中点是否都位于球面多边形内。
// SphericalPolygonsContainPaths reports whether every path vertex and every
// minor-great-circle edge midpoint lies in or on at least one polygon. When
// closePaths is true, the last vertex of each path is also joined to its first.
func SphericalPolygonsContainPaths(polygons, paths [][]GeoPoint, closePaths bool) bool {
return SphericalPolygonsContainPathsWithinKM(polygons, paths, closePaths, 0)
}
// SphericalPolygonIndex 可复用的球面多边形包含索引 / a reusable containment index for one polygon set.
type SphericalPolygonIndex struct {
containment sphericalPolygonContainment
}
// NewSphericalPolygonIndex 为多边形集合构建包含索引 / builds a containment index for the polygon set.
func NewSphericalPolygonIndex(polygons [][]GeoPoint) *SphericalPolygonIndex {
return &SphericalPolygonIndex{containment: newSphericalPolygonContainment(polygons)}
}
// ContainsPoints 返回各点是否位于多边形内,结果与 points 对齐 / reports per-point containment aligned with points.
func (index *SphericalPolygonIndex) ContainsPoints(points []GeoPoint) []bool {
result := make([]bool, len(points))
if index == nil {
return result
}
for pointIndex, point := range points {
result[pointIndex] = index.containment.contains(point)
}
return result
}
// SphericalPolygonsContainPoints 用共享索引批量测试各个独立点 / tests independent points against one shared spherical polygon index.
func SphericalPolygonsContainPoints(polygons [][]GeoPoint, points []GeoPoint) []bool {
return NewSphericalPolygonIndex(polygons).ContainsPoints(points)
}
// SphericalPolygonsContainPathsWithinKM 是 SphericalPolygonsContainPaths 的容差感知形式。
// SphericalPolygonsContainPathsWithinKM is the tolerance-aware form of
// SphericalPolygonsContainPaths. It stops at the first probe farther than the
// requested distance from every polygon.
func SphericalPolygonsContainPathsWithinKM(
polygons, paths [][]GeoPoint,
closePaths bool,
toleranceKM float64,
) bool {
containment := newSphericalPolygonContainment(polygons)
return visitSphericalPathProbes(paths, closePaths, func(point GeoPoint) bool {
return containment.missDistanceKM(point) <= toleranceKM
})
}
// SphericalPolygonsPathMissDistanceKM 返回路径样本到多边形内部或边界的最大偏离距离。
// SphericalPolygonsPathMissDistanceKM returns the greatest distance from a
// path probe outside all polygons to the nearest polygon edge. Vertices and
// minor-great-circle edge midpoints are probed; a fully contained path returns
// zero.
func SphericalPolygonsPathMissDistanceKM(polygons, paths [][]GeoPoint, closePaths bool) float64 {
containment := newSphericalPolygonContainment(polygons)
maximumMiss := 0.0
visitSphericalPathProbes(paths, closePaths, func(point GeoPoint) bool {
maximumMiss = math.Max(maximumMiss, containment.missDistanceKM(point))
return true
})
return maximumMiss
}
func visitSphericalPathProbes(
paths [][]GeoPoint,
closePaths bool,
visit func(GeoPoint) bool,
) bool {
for _, path := range paths {
path = openGeoRing(path)
for _, point := range path {
if !visit(point) {
return false
}
}
edgeCount := len(path) - 1
if closePaths && len(path) > 1 {
edgeCount = len(path)
}
for index := 0; index < edgeCount; index++ {
if !visit(sphericalInterpolate(path[index], path[(index+1)%len(path)], 0.5)) {
return false
}
}
}
return true
}
type sphericalPolygonContainment struct {
polygons [][]GeoPoint
projected []polygonUnionRing
center geoVector3
xAxis geoVector3
yAxis geoVector3
}
func newSphericalPolygonContainment(polygons [][]GeoPoint) sphericalPolygonContainment {
result := sphericalPolygonContainment{polygons: polygons}
center := geoVector3{}
for _, polygon := range polygons {
for _, point := range openGeoRing(polygon) {
center = geoVectorAdd(center, geoPointVector(point))
}
}
var ok bool
result.center, ok = geoVectorNormalize(center)
if !ok {
return result
}
reference := geoVector3{z: 1}
if math.Abs(geoVectorDot(reference, result.center)) > 0.9 {
reference = geoVector3{x: 1}
}
result.xAxis, ok = geoVectorNormalize(geoVectorCross(reference, result.center))
if !ok {
return result
}
result.yAxis, ok = geoVectorNormalize(geoVectorCross(result.center, result.xAxis))
if !ok {
return result
}
result.projected = make([]polygonUnionRing, len(polygons))
for polygonIndex, polygon := range polygons {
polygon = openGeoRing(polygon)
points := make([]polygonUnionPoint, len(polygon))
for pointIndex, point := range polygon {
projected, projectedOK := result.project(point)
if !projectedOK {
result.projected = nil
return result
}
points[pointIndex] = projected
}
result.projected[polygonIndex] = polygonUnionRingForPoints(points)
}
return result
}
func (containment sphericalPolygonContainment) project(point GeoPoint) (polygonUnionPoint, bool) {
vector := geoPointVector(point)
denominator := geoVectorDot(vector, containment.center)
if denominator <= 1e-12 {
return polygonUnionPoint{}, false
}
return polygonUnionPoint{
x: geoVectorDot(vector, containment.xAxis) / denominator,
y: geoVectorDot(vector, containment.yAxis) / denominator,
}, true
}
func (containment sphericalPolygonContainment) contains(point GeoPoint) bool {
if len(containment.projected) > 0 {
if projected, ok := containment.project(point); ok {
for _, polygon := range containment.projected {
if polygonUnionRingContainsPoint(projected, polygon) {
return true
}
}
return false
}
// Every ring is inside this gnomonic hemisphere. A point beyond its
// horizon cannot be inside and must not use antipodal tangent winding.
return false
}
for _, polygon := range containment.polygons {
if sphericalPolygonContainsOrTouches(polygon, point) {
return true
}
}
return false
}
func (containment sphericalPolygonContainment) missDistanceKM(point GeoPoint) float64 {
if containment.contains(point) {
return 0
}
return containment.sphericalMissDistanceKM(point)
}
func (containment sphericalPolygonContainment) sphericalMissDistanceKM(point GeoPoint) float64 {
nearest := math.Inf(1)
for _, polygon := range containment.polygons {
polygon = openGeoRing(polygon)
for index, start := range polygon {
end := polygon[(index+1)%len(polygon)]
nearest = math.Min(nearest, sphericalPointArcDistanceKM(point, start, end))
}
}
return nearest
}
func sphericalPointArcDistanceKM(point, start, end GeoPoint) float64 {
pointVector := geoPointVector(point)
startVector := geoPointVector(start)
endVector := geoPointVector(end)
normal, ok := geoVectorNormalize(geoVectorCross(startVector, endVector))
if ok {
projection := geoVectorAdd(pointVector, geoVectorScale(normal, -geoVectorDot(pointVector, normal)))
if projected, projectedOK := geoVectorNormalize(projection); projectedOK {
for _, candidate := range []geoVector3{projected, geoVectorScale(projected, -1)} {
candidatePoint := geoVectorPoint(candidate)
if sphericalPointOnArc(candidatePoint, start, end) {
angle := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, candidate))))
return angle * 6378.1366
}
}
}
}
return math.Min(geoPointDistanceKM(point, start), geoPointDistanceKM(point, end))
}
// JoinPolylineSegments 按最近端点连接无序边界线段 / JoinPolylineSegments joins unordered boundary segments by their nearest
// 端点连接;输入线段不会被修改 / endpoints. The input segments are not modified.
func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint {
filtered := make([][]GeoPoint, 0, len(segments))
for _, segment := range segments {
if len(segment) == 0 {
continue
}
filtered = append(filtered, append([]GeoPoint(nil), segment...))
}
if len(filtered) == 0 {
return nil
}
result := append([]GeoPoint(nil), filtered[0]...)
used := make([]bool, len(filtered))
used[0] = true
for joined := 1; joined < len(filtered); joined++ {
bestIndex := -1
bestReverse := false
bestPrepend := false
bestDistance := math.Inf(1)
start := result[0]
end := result[len(result)-1]
for index, segment := range filtered {
if used[index] {
continue
}
if distance := angularDistanceDegrees(end, segment[0]); distance < bestDistance {
bestIndex, bestReverse, bestPrepend, bestDistance = index, false, false, distance
}
if distance := angularDistanceDegrees(end, segment[len(segment)-1]); distance < bestDistance {
bestIndex, bestReverse, bestPrepend, bestDistance = index, true, false, distance
}
if distance := angularDistanceDegrees(start, segment[len(segment)-1]); distance < bestDistance {
bestIndex, bestReverse, bestPrepend, bestDistance = index, false, true, distance
}
if distance := angularDistanceDegrees(start, segment[0]); distance < bestDistance {
bestIndex, bestReverse, bestPrepend, bestDistance = index, true, true, distance
}
}
if bestIndex < 0 {
break
}
segment := filtered[bestIndex]
if bestReverse {
reverseGeoPoints(segment)
}
if bestPrepend {
result = appendJoinedGeoPoints(segment, result)
} else {
result = appendJoinedGeoPoints(result, segment)
}
used[bestIndex] = true
}
return result
}
// appendJoinedGeoPoints 拼接两段并丢掉衔接处重合的顶点。
func appendJoinedGeoPoints(first, second []GeoPoint) []GeoPoint {
result := append([]GeoPoint(nil), first...)
start := 0
for start < len(second) && len(result) > 0 && SameGeoPoint(result[len(result)-1], second[start]) {
start++
}
return append(result, second[start:]...)
}
// ShortestCircleArc 返回两点之间较短的采样圆弧 / ShortestCircleArc returns the shorter sampled arc from one point to another.
func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint {
if len(circle) == 0 {
return nil
}
fromIndex := nearestGeoPointIndex(circle, from)
toIndex := nearestGeoPointIndex(circle, to)
forwardSteps := (toIndex - fromIndex + len(circle)) % len(circle)
backwardSteps := (fromIndex - toIndex + len(circle)) % len(circle)
direction := 1
steps := forwardSteps
if backwardSteps < forwardSteps {
direction = -1
steps = backwardSteps
}
result := make([]GeoPoint, 0, steps+2)
result = append(result, from)
for step := 1; step < steps; step++ {
index := (fromIndex + direction*step) % len(circle)
if index < 0 {
index += len(circle)
}
result = append(result, circle[index])
}
return append(result, to)
}
// SameGeoPoint 判断两个经纬度点是否在拓扑所需精度内相等 / SameGeoPoint reports whether two longitude/latitude points are equal within
// 地图拓扑辅助函数所需的精度内相等 / the precision needed by the map topology helpers.
func SameGeoPoint(a, b GeoPoint) bool {
return math.Abs(normalizeLongitude(a.Longitude-b.Longitude)) < 1e-9 &&
math.Abs(a.Latitude-b.Latitude) < 1e-9
}
func nearestGeoPointIndex(points []GeoPoint, target GeoPoint) int {
bestIndex := 0
bestDistance := math.Inf(1)
for index, point := range points {
if distance := angularDistanceDegrees(point, target); distance < bestDistance {
bestIndex, bestDistance = index, distance
}
}
return bestIndex
}
func angularDistanceDegrees(a, b GeoPoint) float64 {
lat1 := a.Latitude * math.Pi / 180
lat2 := b.Latitude * math.Pi / 180
dLongitude := normalizeLongitude(b.Longitude-a.Longitude) * math.Pi / 180
cosine := math.Sin(lat1)*math.Sin(lat2) +
math.Cos(lat1)*math.Cos(lat2)*math.Cos(dLongitude)
return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi
}
func reverseGeoPoints(points []GeoPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}