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

1317 lines
43 KiB
Go

package occultationgeo
import (
"fmt"
"math"
"sort"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
func occultationVisibleBoundaryLines(
fallbackPolygons [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
extraLines [][]geodata.GeoPoint,
) [][]geodata.GeoPoint {
return occultationVisibleBoundaryLinesFromBase(
occultationFallbackPolygonBoundaryLines(fallbackPolygons), curves, extraLines,
)
}
func occultationFallbackPolygonBoundaryLines(
fallbackPolygons [][]geodata.GeoPoint,
) [][]geodata.GeoPoint {
boundaryLines := make([][]geodata.GeoPoint, 0, len(fallbackPolygons))
for _, polygon := range fallbackPolygons {
if len(polygon) < 3 {
continue
}
line := append([]geodata.GeoPoint(nil), polygon...)
if !geodata.SameGeoPoint(line[0], line[len(line)-1]) {
line = append(line, line[0])
}
boundaryLines = append(boundaryLines, line)
}
return boundaryLines
}
func occultationContactContourBoundaryLines(
contours [][]basic.OccultationPathPoint,
) [][]geodata.GeoPoint {
boundaryLines := make([][]geodata.GeoPoint, 0, len(contours))
for _, contour := range contours {
for _, sampleRange := range ContinuousBoundaryRanges(contour) {
if sampleRange.End-sampleRange.Start < 2 {
continue
}
line := occultationPathGeoLine(contour[sampleRange.Start:sampleRange.End])
if len(line) >= 2 {
boundaryLines = append(boundaryLines, line)
}
}
}
return boundaryLines
}
func occultationStaticBandCurves(
curves []basic.OccultationRiseSetCurve,
) []basic.OccultationRiseSetCurve {
if len(curves) == 0 {
return nil
}
result := make([]basic.OccultationRiseSetCurve, 0, len(curves))
for _, curve := range curves {
if curve.Phase == basic.RiseSetPhaseGreatest {
continue
}
result = append(result, curve)
}
return result
}
func occultationVisibleBoundaryLinesFromBase(
baseLines [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
extraLines [][]geodata.GeoPoint,
) [][]geodata.GeoPoint {
boundaryLines := make([][]geodata.GeoPoint, 0, len(baseLines)+len(curves)*2+len(extraLines))
for _, source := range baseLines {
if len(source) < 2 {
continue
}
line := append([]geodata.GeoPoint(nil), source...)
boundaryLines = append(boundaryLines, line)
}
for _, curve := range curves {
boundaryLines = append(boundaryLines, occultationCurveBoundaryLines(curve)...)
}
boundaryLines = append(boundaryLines, extraLines...)
return boundaryLines
}
func occultationHorizonConnectorBoundaryLines(
connectors []HorizonConnector,
) [][]geodata.GeoPoint {
lines := make([][]geodata.GeoPoint, 0, len(connectors))
for _, connector := range connectors {
if len(connector.Points) < 2 {
continue
}
line := make([]geodata.GeoPoint, len(connector.Points))
for index, point := range connector.Points {
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
lines = append(lines, line)
}
return lines
}
type occultationPhaseBoundaryEdge struct {
points []geodata.GeoPoint
}
type occultationPhaseBoundaryNode struct {
point geodata.GeoPoint
edges []int
}
// occultationPhaseBoundaryPolygons constructs the visible outer band from
// start/end phase curves and horizon closures. Greatest is intentionally
// excluded: it is a diagnostic stage curve inside the visible region, not an
// exterior boundary. The returned rings are accepted only when the cycle
// covers the continuous contact envelope and its own edges remain close to
// that envelope.
func occultationPhaseBoundaryPolygons(
curves []basic.OccultationRiseSetCurve,
connectors []HorizonConnector,
fillPolygons [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, bool) {
if len(fillPolygons) == 0 {
return nil, false
}
edgesByDirection := make(map[basic.RiseSetDirection][]occultationPhaseBoundaryEdge)
for _, curve := range curves {
if curve.Phase == basic.RiseSetPhaseGreatest {
continue
}
if curve.Phase != basic.RiseSetPhaseStart &&
curve.Phase != basic.RiseSetPhaseEnd {
continue
}
for _, line := range occultationCurveBoundaryLines(curve) {
if len(line) >= 2 {
edgesByDirection[curve.Direction] = append(
edgesByDirection[curve.Direction],
occultationPhaseBoundaryEdge{points: line},
)
}
}
}
for _, connector := range connectors {
if len(connector.Points) < 2 {
continue
}
edgesByDirection[connector.Direction] = append(
edgesByDirection[connector.Direction],
occultationPhaseBoundaryEdge{
points: occultationPathGeoLine(connector.Points),
},
)
}
if len(edgesByDirection) == 0 {
return nil, false
}
// 每个通过门槛的环都是一条独立相位支路,并集结果与顺序无关;按分数只取"最优环"会
// 丢掉真实支路,因此这里保留全部环,只按方向键固定生成顺序以保证可复现。
directions := make([]basic.RiseSetDirection, 0, len(edgesByDirection))
for direction := range edgesByDirection {
directions = append(directions, direction)
}
sort.Slice(directions, func(first, second int) bool { return directions[first] < directions[second] })
rings := make([][]geodata.GeoPoint, 0, len(directions))
for _, direction := range directions {
for _, ring := range occultationPhaseBoundaryCyclesForEdges(edgesByDirection[direction]) {
if len(ring) < 4 {
continue
}
fillMiss := geodata.SphericalPolygonsPathMissDistanceKM(
[][]geodata.GeoPoint{ring}, fillPolygons, true,
)
if fillMiss > 150 {
continue
}
edgeMiss := geodata.SphericalPolygonsPathMissDistanceKM(
fillPolygons, [][]geodata.GeoPoint{ring}, true,
)
if edgeMiss > 200 {
continue
}
rings = append(rings, ring)
}
}
if len(rings) == 0 {
return nil, false
}
if len(rings) == 1 {
return rings, true
}
merged, err := geodata.UnionPolygons(rings)
if err != nil || len(merged) == 0 {
return nil, false
}
return merged, true
}
func occultationPhaseBoundaryCyclesForEdges(
edges []occultationPhaseBoundaryEdge,
) [][]geodata.GeoPoint {
if len(edges) < 2 {
return nil
}
nodes := make([]occultationPhaseBoundaryNode, 0, len(edges)*2)
edgeNodes := make([][2]int, len(edges))
validEdges := make([]bool, len(edges))
nodeFor := func(point geodata.GeoPoint) int {
for index := range nodes {
if geoDistanceKM(nodes[index].point, point) <= curveBoundaryJoinDistanceKM {
return index
}
}
nodes = append(nodes, occultationPhaseBoundaryNode{point: point})
return len(nodes) - 1
}
for edgeIndex, edge := range edges {
if len(edge.points) < 2 {
continue
}
start := nodeFor(edge.points[0])
end := nodeFor(edge.points[len(edge.points)-1])
if start == end {
continue
}
validEdges[edgeIndex] = true
edgeNodes[edgeIndex] = [2]int{start, end}
nodes[start].edges = append(nodes[start].edges, edgeIndex)
nodes[end].edges = append(nodes[end].edges, edgeIndex)
}
type cycleResult struct {
ring []geodata.GeoPoint
edgeIDs []int
signature string
}
results := make([]cycleResult, 0)
seen := make(map[string]struct{})
for startEdge, edge := range edges {
if len(edge.points) < 2 || !validEdges[startEdge] {
continue
}
for _, forward := range []bool{true, false} {
startNode := edgeNodes[startEdge][0]
currentNode := edgeNodes[startEdge][1]
points := append([]geodata.GeoPoint(nil), edge.points...)
if !forward {
startNode, currentNode = currentNode, startNode
reverseGeoPointRing(points)
}
used := map[int]bool{startEdge: true}
edgeIDs := []int{startEdge}
var walk func(int, []geodata.GeoPoint, map[int]bool, []int)
walk = func(node int, ring []geodata.GeoPoint, used map[int]bool, usedEdges []int) {
if node == startNode {
if len(usedEdges) < 2 || len(ring) < 3 {
return
}
closed := append([]geodata.GeoPoint(nil), ring...)
closed = append(closed, closed[0])
ids := append([]int(nil), usedEdges...)
sort.Ints(ids)
signature := fmt.Sprint(ids)
if _, exists := seen[signature]; exists {
return
}
seen[signature] = struct{}{}
results = append(results, cycleResult{
ring: closed, edgeIDs: ids, signature: signature,
})
return
}
if len(usedEdges) >= len(edges) {
return
}
for _, nextEdge := range nodes[node].edges {
if used[nextEdge] || !validEdges[nextEdge] || len(edges[nextEdge].points) < 2 {
continue
}
next := edges[nextEdge].points
nextNode := edgeNodes[nextEdge][1]
if edgeNodes[nextEdge][1] == node {
nextNode = edgeNodes[nextEdge][0]
next = append([]geodata.GeoPoint(nil), next...)
reverseGeoPointRing(next)
}
nextRing := append([]geodata.GeoPoint(nil), ring...)
if len(nextRing) > 0 && geoDistanceKM(nextRing[len(nextRing)-1], next[0]) <= curveBoundaryJoinDistanceKM {
next[0] = nextRing[len(nextRing)-1]
}
nextRing = append(nextRing, next[1:]...)
nextUsed := make(map[int]bool, len(used)+1)
for key, value := range used {
nextUsed[key] = value
}
nextUsed[nextEdge] = true
nextEdges := append(append([]int(nil), usedEdges...), nextEdge)
walk(nextNode, nextRing, nextUsed, nextEdges)
}
}
walk(currentNode, points, used, edgeIDs)
}
}
rings := make([][]geodata.GeoPoint, 0, len(results))
for _, result := range results {
rings = append(rings, result.ring)
}
return rings
}
// occultationCurveBoundaryAlternatives returns boundary line sets that replace
// one folded multi-branch curve with a single raw branch. The alternatives are
// intentionally bounded: they are only evaluated after the normal topology
// fails, and the shortest branch is tried first because it is the usual polar
// fold connector rather than the long interior branch.
func occultationCurveBoundaryAlternatives(
fallbackPolygons [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
extraLines [][]geodata.GeoPoint,
) [][][]geodata.GeoPoint {
return occultationCurveBoundaryAlternativesFromBase(
occultationFallbackPolygonBoundaryLines(fallbackPolygons), curves, extraLines,
)
}
func occultationCurveBoundaryAlternativesFromBase(
baseLines [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
extraLines [][]geodata.GeoPoint,
) [][][]geodata.GeoPoint {
base := occultationVisibleBoundaryLinesFromBase(baseLines, nil, extraLines)
curveLines := make([][][]geodata.GeoPoint, len(curves))
for index, curve := range curves {
curveLines[index] = occultationCurveBoundaryLines(curve)
}
var alternatives [][][]geodata.GeoPoint
for curveIndex, curve := range curves {
branches := make([][]geodata.GeoPoint, 0, len(curve.Segments))
for _, segment := range curve.Segments {
if len(segment) < 2 {
continue
}
branch := make([]geodata.GeoPoint, len(segment))
for pointIndex, point := range segment {
branch[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
branches = append(branches, branch)
}
if len(branches) < 2 {
continue
}
sort.SliceStable(branches, func(first, second int) bool {
return len(branches[first]) < len(branches[second])
})
for _, branch := range branches {
candidate := make([][]geodata.GeoPoint, 0, len(base)+len(curves)*2)
candidate = append(candidate, base...)
for otherIndex, lines := range curveLines {
if otherIndex == curveIndex {
candidate = append(candidate, branch)
continue
}
candidate = append(candidate, lines...)
}
alternatives = append(alternatives, candidate)
if len(alternatives) >= maximumOccultationBoundaryAlternatives {
return alternatives
}
}
}
return alternatives
}
const (
// CurveBoundaryJoinDistanceKM is deliberately much smaller than the
// polygonizer snap radius. It only joins endpoints that represent the same
// physical fold, rather than nearby polar branches that merely converge in
// longitude.
curveBoundaryJoinDistanceKM = 5.0
curveBoundaryJoinTime = 30 * time.Second
)
// occultationCurveBoundaryLines stitches same-curve branch segments that meet
// at a common physical endpoint. Public rise/set data keeps the original
// strictly time-ordered segments; the static fill topology needs a spatial
// edge, so a fold such as C->D and B->D becomes B->D->C.
func occultationCurveBoundaryLines(curve basic.OccultationRiseSetCurve) [][]geodata.GeoPoint {
segments := make([][]geodata.GeoPoint, 0, len(curve.Segments))
segmentTimes := make([][]time.Time, 0, len(curve.Segments))
for _, source := range curve.Segments {
if len(source) < 2 {
continue
}
line := occultationPathGeoLine(source)
times := make([]time.Time, len(source))
for index, point := range source {
times[index] = point.Time
}
segments = append(segments, line)
segmentTimes = append(segmentTimes, times)
}
lines := make([][]geodata.GeoPoint, 0, len(segments))
for len(segments) > 0 {
line := append([]geodata.GeoPoint(nil), segments[0]...)
lineTimes := append([]time.Time(nil), segmentTimes[0]...)
segments = segments[1:]
segmentTimes = segmentTimes[1:]
for {
joined := false
for index := range segments {
if !curveBoundaryEndpointsMatch(line, lineTimes, segments[index], segmentTimes[index]) {
continue
}
line, lineTimes = joinCurveBoundarySegments(line, lineTimes, segments[index], segmentTimes[index])
segments = append(segments[:index], segments[index+1:]...)
segmentTimes = append(segmentTimes[:index], segmentTimes[index+1:]...)
joined = true
break
}
if !joined {
break
}
}
if len(line) >= 2 {
lines = append(lines, line)
}
}
return lines
}
func occultationPathGeoLine(source []basic.OccultationPathPoint) []geodata.GeoPoint {
line := make([]geodata.GeoPoint, len(source))
for index, point := range source {
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
return line
}
// StitchedRiseSetCurveSegments 返回面向显示的升落曲线分段。
// StitchedRiseSetCurveSegments returns display-oriented rise/set curve
// segments. Solver output keeps branch segments time-ordered; map strokes need
// the spatial continuation at shared fold endpoints so a pair such as A->D and
// B->D renders as one continuous A->D->B polyline.
func StitchedRiseSetCurveSegments(curve basic.OccultationRiseSetCurve) [][]basic.OccultationPathPoint {
segments := make([][]basic.OccultationPathPoint, 0, len(curve.Segments))
for _, source := range curve.Segments {
if len(source) < 2 {
continue
}
segments = append(segments, append([]basic.OccultationPathPoint(nil), source...))
}
lines := make([][]basic.OccultationPathPoint, 0, len(segments))
for len(segments) > 0 {
line := append([]basic.OccultationPathPoint(nil), segments[0]...)
segments = segments[1:]
for {
joined := false
for index := range segments {
if !riseSetCurveEndpointsMatch(line, segments[index]) {
continue
}
line = joinRiseSetCurveSegments(line, segments[index])
segments = append(segments[:index], segments[index+1:]...)
joined = true
break
}
if !joined {
break
}
}
if len(line) >= 2 {
lines = append(lines, line)
}
}
return lines
}
func curveBoundaryEndpointsMatch(
first []geodata.GeoPoint,
firstTimes []time.Time,
second []geodata.GeoPoint,
secondTimes []time.Time,
) bool {
if len(first) < 2 || len(second) < 2 || len(firstTimes) != len(first) || len(secondTimes) != len(second) {
return false
}
return (curveBoundaryEndpointMatch(first[0], firstTimes[0], second[0], secondTimes[0]) ||
curveBoundaryEndpointMatch(first[0], firstTimes[0], second[len(second)-1], secondTimes[len(second)-1]) ||
curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[0], secondTimes[0]) ||
curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[len(second)-1], secondTimes[len(second)-1]))
}
func curveBoundaryEndpointMatch(first geodata.GeoPoint, firstTime time.Time, second geodata.GeoPoint, secondTime time.Time) bool {
return geoDistanceKM(first, second) <= curveBoundaryJoinDistanceKM &&
!firstTime.IsZero() && !secondTime.IsZero() && absDuration(firstTime.Sub(secondTime)) <= curveBoundaryJoinTime
}
func riseSetCurveEndpointsMatch(first, second []basic.OccultationPathPoint) bool {
if len(first) < 2 || len(second) < 2 {
return false
}
return riseSetCurveEndpointMatch(first[0], second[0]) ||
riseSetCurveEndpointMatch(first[0], second[len(second)-1]) ||
riseSetCurveEndpointMatch(first[len(first)-1], second[0]) ||
riseSetCurveEndpointMatch(first[len(first)-1], second[len(second)-1])
}
func riseSetCurveEndpointMatch(first, second basic.OccultationPathPoint) bool {
return DistanceKM(first, second) <= curveBoundaryJoinDistanceKM &&
!first.Time.IsZero() && !second.Time.IsZero() &&
absDuration(first.Time.Sub(second.Time)) <= curveBoundaryJoinTime
}
func joinRiseSetCurveSegments(
first, second []basic.OccultationPathPoint,
) []basic.OccultationPathPoint {
if riseSetCurveEndpointMatch(first[len(first)-1], second[0]) {
return append(first, second[1:]...)
}
if riseSetCurveEndpointMatch(first[len(first)-1], second[len(second)-1]) {
reverseOccultationPathPoints(second)
return append(first, second[1:]...)
}
if riseSetCurveEndpointMatch(first[0], second[len(second)-1]) {
return append(second[:len(second)-1], first...)
}
reverseOccultationPathPoints(second)
return append(second[:len(second)-1], first...)
}
func reverseOccultationPathPoints(points []basic.OccultationPathPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}
func joinCurveBoundarySegments(
first []geodata.GeoPoint,
firstTimes []time.Time,
second []geodata.GeoPoint,
secondTimes []time.Time,
) ([]geodata.GeoPoint, []time.Time) {
if curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[0], secondTimes[0]) {
return append(first, second[1:]...), append(firstTimes, secondTimes[1:]...)
}
if curveBoundaryEndpointMatch(first[len(first)-1], firstTimes[len(first)-1], second[len(second)-1], secondTimes[len(second)-1]) {
for left, right := 0, len(second)-1; left < right; left, right = left+1, right-1 {
second[left], second[right] = second[right], second[left]
secondTimes[left], secondTimes[right] = secondTimes[right], secondTimes[left]
}
return append(first, second[1:]...), append(firstTimes, secondTimes[1:]...)
}
if curveBoundaryEndpointMatch(first[0], firstTimes[0], second[len(second)-1], secondTimes[len(second)-1]) {
return append(second[:len(second)-1], first...), append(secondTimes[:len(secondTimes)-1], firstTimes...)
}
for left, right := 0, len(second)-1; left < right; left, right = left+1, right-1 {
second[left], second[right] = second[right], second[left]
secondTimes[left], secondTimes[right] = secondTimes[right], secondTimes[left]
}
return append(second[:len(second)-1], first...), append(secondTimes[:len(secondTimes)-1], firstTimes...)
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}
func densifyOccultationPolygons(
polygons [][]geodata.GeoPoint,
maximumEdgeKM float64,
) [][]geodata.GeoPoint {
if maximumEdgeKM <= 0 {
return polygons
}
result := make([][]geodata.GeoPoint, len(polygons))
for polygonIndex, polygon := range polygons {
if len(polygon) < 2 {
result[polygonIndex] = polygon
continue
}
ring := make([]geodata.GeoPoint, 0, len(polygon)*2)
for index, point := range polygon {
ring = append(ring, point)
if index+1 >= len(polygon) {
continue
}
next := polygon[index+1]
distance := occultationProjectedEdgeDistanceKM(point, next)
steps := int(math.Ceil(distance / maximumEdgeKM))
if steps < 2 {
continue
}
for step := 1; step < steps; step++ {
ring = append(ring, interpolateOccultationGeoPoint(point, next, float64(step)/float64(steps)))
}
}
// GeoJSON closes polygon rings by connecting the final point back to
// the first. Densify that implicit edge as well, otherwise a smooth
// fallback can still render one long closing chord.
if len(polygon) > 2 && !geodata.SameGeoPoint(polygon[0], polygon[len(polygon)-1]) {
first, last := polygon[0], polygon[len(polygon)-1]
distance := occultationProjectedEdgeDistanceKM(last, first)
steps := int(math.Ceil(distance / maximumEdgeKM))
if steps >= 2 {
for step := 1; step < steps; step++ {
ring = append(ring, interpolateOccultationGeoPoint(last, first, float64(step)/float64(steps)))
}
}
}
result[polygonIndex] = ring
}
return result
}
// occultationProjectedEdgeDistanceKM measures an edge in the Web Mercator
// chart used by the GeoJSON/OpenLayers consumer, while retaining the shortest
// wrapped longitude. At high latitude a small spherical edge expands strongly
// in this chart; using only the great-circle distance leaves visible polygon
// chords even though the source contact contour is densely sampled.
func occultationProjectedEdgeDistanceKM(first, second geodata.GeoPoint) float64 {
const maxLatitude = 85.05112878
clampLatitude := func(value float64) float64 {
return math.Max(-maxLatitude, math.Min(maxLatitude, value))
}
longitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Pi / 180
firstLatitude := clampLatitude(first.Latitude) * math.Pi / 180
secondLatitude := clampLatitude(second.Latitude) * math.Pi / 180
firstY := math.Log(math.Tan(math.Pi/4 + firstLatitude/2))
secondY := math.Log(math.Tan(math.Pi/4 + secondLatitude/2))
return EarthRadiusKM * math.Hypot(longitude, secondY-firstY)
}
func interpolateOccultationGeoPoint(
first, second geodata.GeoPoint,
fraction float64,
) geodata.GeoPoint {
firstLongitude, firstLatitude := first.Longitude*math.Pi/180, first.Latitude*math.Pi/180
secondLongitude, secondLatitude := second.Longitude*math.Pi/180, second.Latitude*math.Pi/180
firstCos := math.Cos(firstLatitude)
secondCos := math.Cos(secondLatitude)
firstVector := [3]float64{firstCos * math.Cos(firstLongitude), firstCos * math.Sin(firstLongitude), math.Sin(firstLatitude)}
secondVector := [3]float64{secondCos * math.Cos(secondLongitude), secondCos * math.Sin(secondLongitude), math.Sin(secondLatitude)}
dot := firstVector[0]*secondVector[0] + firstVector[1]*secondVector[1] + firstVector[2]*secondVector[2]
dot = math.Max(-1, math.Min(1, dot))
angle := math.Acos(dot)
var vector [3]float64
if angle < 1e-12 {
vector = [3]float64{
(1-fraction)*firstVector[0] + fraction*secondVector[0],
(1-fraction)*firstVector[1] + fraction*secondVector[1],
(1-fraction)*firstVector[2] + fraction*secondVector[2],
}
} else {
firstWeight := math.Sin((1-fraction)*angle) / math.Sin(angle)
secondWeight := math.Sin(fraction*angle) / math.Sin(angle)
vector = [3]float64{
firstWeight*firstVector[0] + secondWeight*secondVector[0],
firstWeight*firstVector[1] + secondWeight*secondVector[1],
firstWeight*firstVector[2] + secondWeight*secondVector[2],
}
}
length := math.Sqrt(vector[0]*vector[0] + vector[1]*vector[1] + vector[2]*vector[2])
return geodata.GeoPoint{
Longitude: math.Atan2(vector[1], vector[0]) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, vector[2]/length))) * 180 / math.Pi,
}
}
func occultationPolygonNeedsInteriorProbes(
polygon []basic.OccultationPathPoint,
) bool {
for _, point := range polygon {
if math.Abs(point.Latitude) >= 70 {
return true
}
}
return false
}
// clipOccultationPolygonToHorizon removes the below-horizon part of an
// instantaneous contact footprint. Footprint construction retains the full
// contact-cone arc so that its horizon closure can be swept continuously;
// that arc is not itself a visible area. Linear interpolation is sufficient
// at the sub-degree horizon crossing because the source samples are already
// spatially dense and the resulting linework is subsequently projected.
func clipOccultationPolygonToHorizon(
source []basic.OccultationPathPoint,
) []basic.OccultationPathPoint {
if len(source) < 3 {
return nil
}
points := source
if sameOccultationPoint(points[0], points[len(points)-1]) {
points = points[:len(points)-1]
}
if len(points) < 3 {
return nil
}
inside := func(point basic.OccultationPathPoint) bool {
return finiteGeo(point.MoonAltitude) && point.MoonAltitude >= -1e-9
}
result := make([]basic.OccultationPathPoint, 0, len(points)+2)
previous := points[len(points)-1]
previousInside := inside(previous)
for _, current := range points {
currentInside := inside(current)
if currentInside != previousInside {
result = append(result, interpolateOccultationHorizonPoint(previous, current))
}
if currentInside {
result = append(result, current)
}
previous, previousInside = current, currentInside
}
if len(result) < 3 {
return nil
}
return result
}
func interpolateOccultationHorizonPoint(
first, second basic.OccultationPathPoint,
) basic.OccultationPathPoint {
denominator := first.MoonAltitude - second.MoonAltitude
fraction := 0.5
if math.Abs(denominator) > 1e-12 {
fraction = first.MoonAltitude / denominator
}
fraction = math.Max(0, math.Min(1, fraction))
deltaLongitude := second.Longitude - first.Longitude
for deltaLongitude > 180 {
deltaLongitude -= 360
}
for deltaLongitude < -180 {
deltaLongitude += 360
}
point := first
point.Time = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
point.Longitude = normalizeGeoLongitude(first.Longitude + fraction*deltaLongitude)
point.Latitude = first.Latitude + fraction*(second.Latitude-first.Latitude)
point.MoonAltitude = 0
point.WidthKM = first.WidthKM + fraction*(second.WidthKM-first.WidthKM)
return point
}
func occultationVisibleFootprintProbes(
source []basic.OccultationPathPoint,
polygon []geodata.GeoPoint,
) []geodata.GeoPoint {
index := geodata.NewSphericalPolygonIndex([][]geodata.GeoPoint{polygon})
probes := occultationPolarFootprintProbes(source, polygon)
if len(source) < 3 || len(source) != len(polygon) {
return probes
}
x, y, z := 0.0, 0.0, 0.0
for _, point := range polygon {
longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180
cosLatitude := math.Cos(latitude)
x += cosLatitude * math.Cos(longitude)
y += cosLatitude * math.Sin(longitude)
z += math.Sin(latitude)
}
centerLength := math.Sqrt(x*x + y*y + z*z)
if centerLength <= 1e-12 {
return probes
}
center := [3]float64{x / centerLength, y / centerLength, z / centerLength}
centerProbe := geodata.GeoPoint{
Longitude: math.Atan2(center[1], center[0]) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, center[2]))) * 180 / math.Pi,
}
if index.ContainsPoints([]geodata.GeoPoint{centerProbe})[0] {
probes = append(probes, centerProbe)
}
const maximumVertices = 8
step := (len(polygon) + maximumVertices - 1) / maximumVertices
type candidateRange struct{ start, end int }
vertexCandidates := make([]geodata.GeoPoint, 0, 2*maximumVertices)
vertexRanges := make([]candidateRange, 0, maximumVertices)
for index := 0; index < len(polygon); index += step {
start := len(vertexCandidates)
point := polygon[index]
longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180
cosLatitude := math.Cos(latitude)
vertex := [3]float64{cosLatitude * math.Cos(longitude), cosLatitude * math.Sin(longitude), math.Sin(latitude)}
for _, inward := range []float64{0.0005, 0.005, 0.02, 0.05} {
candidateVector := [3]float64{
(1-inward)*vertex[0] + inward*center[0],
(1-inward)*vertex[1] + inward*center[1],
(1-inward)*vertex[2] + inward*center[2],
}
length := math.Sqrt(candidateVector[0]*candidateVector[0] + candidateVector[1]*candidateVector[1] + candidateVector[2]*candidateVector[2])
vertexCandidates = append(vertexCandidates, geodata.GeoPoint{
Longitude: math.Atan2(candidateVector[1], candidateVector[0]) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, candidateVector[2]/length))) * 180 / math.Pi,
})
}
vertexRanges = append(vertexRanges, candidateRange{start: start, end: len(vertexCandidates)})
}
vertexInside := index.ContainsPoints(vertexCandidates)
for _, value := range vertexRanges {
for index := value.start; index < value.end; index++ {
if vertexInside[index] {
probes = append(probes, vertexCandidates[index])
break
}
}
}
// A polar visible sliver can be much narrower than the vector from a
// vertex to the polygon centroid. Probe both sides of sampled edges as
// well, retaining only points that are actually inside the clipped source.
edgeCandidates := make([]geodata.GeoPoint, 0, 4*maximumVertices)
for index := 0; index < len(polygon); index += step {
first := polygon[index]
second := polygon[(index+1)%len(polygon)]
deltaLongitude := second.Longitude - first.Longitude
for deltaLongitude > 180 {
deltaLongitude -= 360
}
for deltaLongitude < -180 {
deltaLongitude += 360
}
deltaLatitude := second.Latitude - first.Latitude
length := math.Hypot(deltaLongitude, deltaLatitude)
if length <= 1e-12 {
continue
}
midpoint := geodata.GeoPoint{
Longitude: normalizeGeoLongitude(first.Longitude + deltaLongitude/2),
Latitude: first.Latitude + deltaLatitude/2,
}
for _, offset := range []float64{0.0002, 0.002, 0.01, 0.03} {
for _, side := range []float64{-1, 1} {
edgeCandidates = append(edgeCandidates, geodata.GeoPoint{
Longitude: normalizeGeoLongitude(midpoint.Longitude - side*deltaLatitude*offset/length),
Latitude: midpoint.Latitude + side*deltaLongitude*offset/length,
})
}
}
}
edgeInside := index.ContainsPoints(edgeCandidates)
for index, candidate := range edgeCandidates {
if edgeInside[index] {
probes = append(probes, candidate)
}
}
return probes
}
func limitOccultationCoveragePaths(
paths [][]geodata.GeoPoint,
maximum int,
) [][]geodata.GeoPoint {
if maximum < 1 || len(paths) <= maximum {
return paths
}
type indexedPoint struct {
index int
latitude float64
}
ordered := make([]indexedPoint, len(paths))
for index, path := range paths {
latitude := 0.0
if len(path) > 0 {
latitude = path[0].Latitude
}
ordered[index] = indexedPoint{index: index, latitude: latitude}
}
sort.SliceStable(ordered, func(first, second int) bool {
return ordered[first].latitude > ordered[second].latitude
})
keep := make([]bool, len(paths))
reserve := maximum / 8
if reserve < 1 {
reserve = 1
}
for index := 0; index < reserve && index < len(ordered); index++ {
keep[ordered[index].index] = true
}
for index := 0; index < reserve && index < len(ordered); index++ {
keep[ordered[len(ordered)-1-index].index] = true
}
remaining := maximum
for _, value := range keep {
if value {
remaining--
}
}
if remaining < 0 {
remaining = 0
}
stride := float64(len(paths)) / float64(remaining)
result := make([][]geodata.GeoPoint, 0, maximum)
for index, path := range paths {
if keep[index] {
result = append(result, path)
}
}
if remaining > 0 {
for cursor := 0.0; len(result) < maximum && int(cursor) < len(paths); cursor += stride {
index := int(cursor)
if keep[index] {
continue
}
keep[index] = true
result = append(result, paths[index])
}
}
return result
}
func sameOccultationPoint(first, second basic.OccultationPathPoint) bool {
return math.Abs(first.Latitude-second.Latitude) <= 1e-10 &&
math.Abs(normalizeGeoLongitude(first.Longitude-second.Longitude)) <= 1e-10
}
func normalizeGeoLongitude(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
func occultationPolarFootprintProbes(
source []basic.OccultationPathPoint,
polygon []geodata.GeoPoint,
) []geodata.GeoPoint {
if len(source) < 3 || len(source) != len(polygon) {
return nil
}
x, y, z := 0.0, 0.0, 0.0
polarIndices := make([]int, 0, len(source))
for index, point := range source {
longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180
cosLatitude := math.Cos(latitude)
x += cosLatitude * math.Cos(longitude)
y += cosLatitude * math.Sin(longitude)
z += math.Sin(latitude)
if math.Abs(point.Latitude) >= 70 {
polarIndices = append(polarIndices, index)
}
}
if len(polarIndices) == 0 {
return nil
}
centerLength := math.Sqrt(x*x + y*y + z*z)
if centerLength <= 1e-12 {
return nil
}
const maximumProbes = 16
step := (len(polarIndices) + maximumProbes - 1) / maximumProbes
candidates := make([]geodata.GeoPoint, 0, maximumProbes)
for position := 0; position < len(polarIndices); position += step {
point := source[polarIndices[position]]
longitude, latitude := point.Longitude*math.Pi/180, point.Latitude*math.Pi/180
cosLatitude := math.Cos(latitude)
const inward = 0.002
candidateVector := [3]float64{
(1-inward)*cosLatitude*math.Cos(longitude) + inward*x/centerLength,
(1-inward)*cosLatitude*math.Sin(longitude) + inward*y/centerLength,
(1-inward)*math.Sin(latitude) + inward*z/centerLength,
}
candidateLength := math.Sqrt(
candidateVector[0]*candidateVector[0] +
candidateVector[1]*candidateVector[1] +
candidateVector[2]*candidateVector[2],
)
candidates = append(candidates, geodata.GeoPoint{
Longitude: math.Atan2(candidateVector[1], candidateVector[0]) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, candidateVector[2]/candidateLength))) * 180 / math.Pi,
})
}
inside := geodata.SphericalPolygonsContainPoints([][]geodata.GeoPoint{polygon}, candidates)
probes := make([]geodata.GeoPoint, 0, len(candidates))
for index, candidate := range candidates {
if inside[index] {
probes = append(probes, candidate)
}
}
return probes
}
func removeOccultationHairpins(points []geodata.GeoPoint, maximumClosureKM, minimumDetourKM float64, maximumSpan int) []geodata.GeoPoint {
if len(points) < 5 || maximumSpan < 3 {
return points
}
result := append([]geodata.GeoPoint(nil), points...)
for pass := 0; pass < 8; pass++ {
changed := false
for start := 0; start+3 < len(result); start++ {
limit := start + maximumSpan
if limit >= len(result) {
limit = len(result) - 1
}
arcLength := 0.0
best := -1
for end := start + 1; end <= limit; end++ {
arcLength += geoDistanceKM(result[end-1], result[end])
if end < start+3 {
continue
}
closure := geoDistanceKM(result[start], result[end])
if closure <= maximumClosureKM && arcLength-closure >= minimumDetourKM {
best = end
}
}
if best > start+1 {
result = append(result[:start+1], result[best:]...)
changed = true
}
}
if !changed {
break
}
}
return result
}
// smoothOccultationHairpins replaces a numerical return with a short,
// densified great-circle chord. Deleting the return vertices outright can
// leave a long straight edge in a rendered fallback band, so the replacement
// preserves display sampling while removing only the detected detour.
func smoothOccultationHairpins(
points []geodata.GeoPoint,
maximumClosureKM, minimumDetourKM float64,
maximumSpan int,
maximumEdgeKM float64,
) []geodata.GeoPoint {
if len(points) < 5 || maximumSpan < 3 || maximumEdgeKM <= 0 {
return points
}
result := append([]geodata.GeoPoint(nil), points...)
for pass := 0; pass < 8; pass++ {
changed := false
for start := 0; start+3 < len(result); start++ {
limit := start + maximumSpan
if limit >= len(result) {
limit = len(result) - 1
}
arcLength := 0.0
best := -1
bestDetour := minimumDetourKM
for end := start + 1; end <= limit; end++ {
arcLength += geoDistanceKM(result[end-1], result[end])
if end < start+3 {
continue
}
closure := geoDistanceKM(result[start], result[end])
detour := arcLength - closure
if closure <= maximumClosureKM && detour >= bestDetour {
best = end
bestDetour = detour
}
}
if best <= start+1 {
continue
}
first, last := result[start], result[best]
steps := int(math.Ceil(geoDistanceKM(first, last) / maximumEdgeKM))
if steps < 1 {
steps = 1
}
replacement := make([]geodata.GeoPoint, 0, steps)
for step := 1; step < steps; step++ {
replacement = append(replacement,
interpolateOccultationGeoPoint(first, last, float64(step)/float64(steps)))
}
next := make([]geodata.GeoPoint, 0, len(result)-best+start+1+len(replacement))
next = append(next, result[:start+1]...)
next = append(next, replacement...)
next = append(next, result[best:]...)
result = next
changed = true
break
}
if !changed {
break
}
}
return result
}
// removeOccultationSharpCorners drops tiny numerical backtracks left at a
// shared polar fold. These are not physical phase vertices: the neighbouring
// points are only a few kilometres apart while the rendered edge reverses
// direction, which produces a visible corner in a filled map polygon.
func removeOccultationSharpCorners(
points []geodata.GeoPoint,
maximumChordKM, minimumAngleDegrees float64,
) []geodata.GeoPoint {
if len(points) < 4 || maximumChordKM <= 0 || minimumAngleDegrees <= 0 {
return points
}
result := append([]geodata.GeoPoint(nil), points...)
for pass := 0; pass < 128; pass++ {
changed := false
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if geoDistanceKM(first, last) > maximumChordKM {
continue
}
if occultationTurnAngleDegrees(first, middle, last) >= minimumAngleDegrees {
continue
}
result = append(result[:index], result[index+1:]...)
changed = true
break
}
if !changed {
break
}
}
return result
}
// removeOccultationPolarSharpCorners removes a short, high-latitude change of
// direction even when both coordinate axes remain monotonic. Such vertices
// are not reversals, but are the polygonizer's seam between two sampled polar
// branches and render as a visible notch in Web Mercator.
func removeOccultationPolarSharpCorners(
points []geodata.GeoPoint,
maximumChordKM, minimumAngleDegrees, minimumLatitudeDegrees float64,
) []geodata.GeoPoint {
if len(points) < 4 || maximumChordKM <= 0 || minimumAngleDegrees <= 0 {
return points
}
result := append([]geodata.GeoPoint(nil), points...)
for pass := 0; pass < 128; pass++ {
changed := false
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if math.Abs(middle.Latitude) < minimumLatitudeDegrees ||
geoDistanceKM(first, last) > maximumChordKM ||
occultationTurnAngleDegrees(first, middle, last) >= minimumAngleDegrees {
continue
}
result = append(result[:index], result[index+1:]...)
changed = true
break
}
if !changed {
break
}
}
return result
}
// smoothOccultationPolarCorners replaces a short polar backtrack whose direct
// endpoint chord is too long for deletion. Interpolating that chord keeps the
// rendered spacing bounded while removing the sampled branch's angular seam.
func smoothOccultationPolarCorners(points []geodata.GeoPoint) []geodata.GeoPoint {
if len(points) < 4 {
return points
}
result := append([]geodata.GeoPoint(nil), points...)
for pass := 0; pass < 128; pass++ {
changed := false
for index := 1; index+1 < len(result); index++ {
first, middle, last := result[index-1], result[index], result[index+1]
if math.Abs(middle.Latitude) < 70 ||
occultationProjectedEdgeDistanceKM(first, last) > 140 ||
occultationTurnAngleDegrees(first, middle, last) >= 165 {
continue
}
longitudeReversal := math.Remainder(middle.Longitude-first.Longitude, 360)*
math.Remainder(last.Longitude-middle.Longitude, 360) < 0
latitudeReversal := (middle.Latitude-first.Latitude)*(last.Latitude-middle.Latitude) < 0
if !longitudeReversal && !latitudeReversal {
continue
}
steps := int(math.Ceil(occultationProjectedEdgeDistanceKM(first, last) / 40))
if steps < 2 {
steps = 2
}
replacement := make([]geodata.GeoPoint, 0, steps-1)
for step := 1; step < steps; step++ {
replacement = append(replacement,
interpolateOccultationGeoPoint(first, last, float64(step)/float64(steps)))
}
next := make([]geodata.GeoPoint, 0, len(result)-1+len(replacement))
next = append(next, result[:index]...)
next = append(next, replacement...)
next = append(next, result[index+1:]...)
result = next
changed = true
break
}
if !changed {
break
}
}
return result
}
// smoothOccultationPolarWobbles removes a short latitude oscillation on an
// otherwise monotone polar edge. Polygonizer seams can alternate north/south
// by a few kilometres while spanning a longer longitude interval, which is
// too wide for the point-deletion cleaners above. The replacement is bounded
// to a four-point window, a 35 km deviation, and a 400 km endpoint span; real
// branch folds with a longitude reversal or larger curvature remain intact.
func smoothOccultationPolarWobbles(points []geodata.GeoPoint) []geodata.GeoPoint {
const (
minimumPolarLatitudeDegrees = 75.0
maximumDeviationKM = 35.0
maximumEndpointSpanKM = 400.0
windowPoints = 3
)
if len(points) < windowPoints+1 {
return points
}
result := append([]geodata.GeoPoint(nil), points...)
closed := len(result) > 1 && geodata.SameGeoPoint(result[0], result[len(result)-1])
limit := len(result)
if closed {
limit--
}
for pass := 0; pass < 8; pass++ {
changed := false
for start := 0; start+windowPoints < limit; start++ {
end := start + windowPoints
first, last := result[start], result[end]
if math.Abs(first.Latitude) < minimumPolarLatitudeDegrees ||
math.Abs(last.Latitude) < minimumPolarLatitudeDegrees ||
occultationProjectedEdgeDistanceKM(first, last) > maximumEndpointSpanKM {
continue
}
longitudeDirection := 0.0
latitudeReversal := false
previousLatitudeDelta := 0.0
valid := true
for index := start + 1; index <= end; index++ {
point := result[index]
if math.Abs(point.Latitude) < minimumPolarLatitudeDegrees {
valid = false
break
}
longitudeDelta := math.Remainder(point.Longitude-result[index-1].Longitude, 360)
if math.Abs(longitudeDelta) <= 1e-7 {
valid = false
break
}
if longitudeDirection == 0 {
longitudeDirection = math.Copysign(1, longitudeDelta)
} else if longitudeDelta*longitudeDirection <= 0 {
valid = false
break
}
latitudeDelta := point.Latitude - result[index-1].Latitude
if previousLatitudeDelta != 0 && latitudeDelta*previousLatitudeDelta < 0 {
latitudeReversal = true
}
if latitudeDelta != 0 {
previousLatitudeDelta = latitudeDelta
}
}
if !valid || !latitudeReversal {
continue
}
for index := start + 1; index < end; index++ {
fraction := float64(index-start) / float64(windowPoints)
baseline := interpolateOccultationGeoPoint(first, last, fraction)
if geoDistanceKM(result[index], baseline) > maximumDeviationKM {
valid = false
break
}
}
if !valid {
continue
}
for index := start + 1; index < end; index++ {
fraction := float64(index-start) / float64(windowPoints)
result[index] = interpolateOccultationGeoPoint(first, last, fraction)
}
changed = true
start = end - 1
}
if !changed {
break
}
}
if closed && len(result) > 1 {
result[len(result)-1] = result[0]
}
return result
}
func occultationTurnAngleDegrees(
first, middle, last geodata.GeoPoint,
) float64 {
latitude := middle.Latitude * math.Pi / 180
scale := math.Cos(latitude)
firstX := math.Remainder(first.Longitude-middle.Longitude, 360) * scale
firstY := first.Latitude - middle.Latitude
lastX := math.Remainder(last.Longitude-middle.Longitude, 360) * scale
lastY := last.Latitude - middle.Latitude
firstLength := math.Hypot(firstX, firstY)
lastLength := math.Hypot(lastX, lastY)
if firstLength <= 1e-12 || lastLength <= 1e-12 {
return 180
}
cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength)
cosine = math.Max(-1, math.Min(1, cosine))
return math.Acos(cosine) * 180 / math.Pi
}