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

3845 lines
138 KiB
Go

package geojson
import (
"fmt"
"math"
"sort"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/lunarhorizon"
"b612.me/astro/internal/solarclosure"
)
func validateSolarEclipseInput(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) error {
info := partial.Eclipse
if info.GreatestEclipse.IsZero() {
return fmt.Errorf("geojson: solar eclipse greatest time is required")
}
if !info.HasPartial {
return fmt.Errorf("geojson: solar eclipse must contain a partial phase")
}
if info.Type == eclipsecore.SolarEclipsePartial && len(partial.CentralBandFootprints) > 0 {
return fmt.Errorf("geojson: partial solar eclipse cannot contain central band footprints")
}
if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() {
return fmt.Errorf("geojson: solar eclipse partial contact times are required")
}
if !info.PartialBeginOnEarth.Before(info.GreatestEclipse) ||
!info.GreatestEclipse.Before(info.PartialEndOnEarth) {
return fmt.Errorf("geojson: solar eclipse times must be ordered partial begin, greatest, partial end")
}
if !finiteGeoJSON(info.Magnitude) || info.Magnitude <= 0 {
return fmt.Errorf("geojson: solar eclipse magnitude must be positive and finite")
}
if err := validateSolarPathPoint("solar greatest", eclipsecore.SolarEclipsePathPoint{
Time: info.GreatestEclipse, Longitude: info.GreatestLongitude, Latitude: info.GreatestLatitude,
}); err != nil {
return err
}
if err := validateSolarFootprints(
"partial", partial.Footprints, info.PartialBeginOnEarth, info.PartialEndOnEarth,
); err != nil {
return err
}
for _, contact := range []struct {
name string
point eclipsecore.SolarEclipsePathPoint
}{
{"P1", partial.P1}, {"P2", partial.P2}, {"P3", partial.P3}, {"P4", partial.P4},
{"U1", partial.U1}, {"U2", partial.U2}, {"U3", partial.U3}, {"U4", partial.U4},
} {
if contact.point.Time.IsZero() {
continue
}
if err := validateSolarPathPoint("solar "+contact.name, contact.point); err != nil {
return err
}
if !solarEclipseTimeInsideInterval(
contact.point.Time, info.PartialBeginOnEarth, info.PartialEndOnEarth,
) {
return fmt.Errorf("geojson: solar %s time is outside the partial interval", contact.name)
}
}
if err := validateSolarContactSequence(
"penumbral", partial.P1, partial.P2, partial.P3, partial.P4,
); err != nil {
return err
}
if err := validateSolarContactSequence(
"central-shadow", partial.U1, partial.U2, partial.U3, partial.U4,
); err != nil {
return err
}
centralShadowStart, centralShadowEnd := partial.U1.Time, partial.U4.Time
if len(partial.CentralShadowFootprints) > 0 || len(partial.CentralBandFootprints) > 0 {
if centralShadowStart.IsZero() || centralShadowEnd.IsZero() || !centralShadowStart.Before(centralShadowEnd) {
return fmt.Errorf("geojson: solar central-shadow footprints require ordered U1 and U4 contacts")
}
}
if err := validateSolarFootprints(
"central-shadow", partial.CentralShadowFootprints, centralShadowStart, centralShadowEnd,
); err != nil {
return err
}
if err := validateSolarFootprints(
"central-band", partial.CentralBandFootprints, centralShadowStart, centralShadowEnd,
); err != nil {
return err
}
if len(partial.CentralBandHorizonClosures) != 0 && len(partial.CentralBandHorizonClosures) != 2 {
return fmt.Errorf("geojson: solar central-band horizon closures require start and end arcs")
}
for index, closure := range partial.CentralBandHorizonClosures {
if len(closure) < 2 {
return fmt.Errorf("geojson: solar central-band horizon closure %d requires at least two points", index)
}
for pointIndex, point := range closure {
if err := validateSolarPathPoint(
fmt.Sprintf("solar central-band horizon closure %d point %d", index, pointIndex), point,
); err != nil {
return err
}
}
for _, point := range closure {
if !solarEclipseTimeInsideInterval(point.Time, centralShadowStart, centralShadowEnd) {
return fmt.Errorf("geojson: solar central-band horizon closure %d is outside U1-U4", index)
}
}
}
for segmentIndex, segment := range partial.PartialBandContours {
if err := validateSolarMagnitudeContourSeries(
fmt.Sprintf("solar partial-band contour %d", segmentIndex), segment, true,
); err != nil {
return err
}
}
for index, contour := range partial.MagnitudeContours {
if !finiteGeoJSON(contour.Magnitude) || contour.Magnitude <= 0 || contour.Magnitude > info.Magnitude+1e-9 {
return fmt.Errorf("geojson: solar magnitude contour %d must be positive and no greater than the eclipse magnitude", index)
}
if len(contour.Segments) > 0 {
for segmentIndex, segment := range contour.Segments {
if err := validateSolarMagnitudeContourSeries(
fmt.Sprintf("solar magnitude contour %d segment %d", index, segmentIndex), segment, true,
); err != nil {
return err
}
}
continue
}
if err := validateSolarPathSeries("solar northern magnitude contour", contour.NorthernLimit, true); err != nil {
return err
}
if err := validateSolarPathSeries("solar southern magnitude contour", contour.SouthernLimit, true); err != nil {
return err
}
}
if err := validateSolarGreatestTimeContours(partial.GreatestTimeContours); err != nil {
return err
}
if err := validateSolarRiseSetCurves(
partial.RiseSetCurves, info.PartialBeginOnEarth, info.PartialEndOnEarth,
); err != nil {
return err
}
if central == nil {
return nil
}
if !central.Eclipse.GreatestEclipse.Equal(info.GreatestEclipse) ||
central.Eclipse.Type != info.Type || central.Eclipse.Model != info.Model {
return fmt.Errorf("geojson: partial footprints and central path describe different eclipses")
}
if central.Eclipse.CentralBeginOnEarth.IsZero() || central.Eclipse.CentralEndOnEarth.IsZero() ||
!central.Eclipse.CentralBeginOnEarth.Before(central.Eclipse.GreatestEclipse) ||
!central.Eclipse.GreatestEclipse.Before(central.Eclipse.CentralEndOnEarth) {
return fmt.Errorf("geojson: solar central path contact times are invalid")
}
if err := validateSolarPathPoint("solar central greatest", central.Greatest); err != nil {
return err
}
if !central.Greatest.Time.Equal(central.Eclipse.GreatestEclipse) {
return fmt.Errorf("geojson: solar central greatest time does not match eclipse greatest")
}
if err := validateSolarPathSeries("solar center line", central.CenterLine, true); err != nil {
return err
}
if central.Greatest.Time.Before(central.CenterLine[0].Time) ||
central.Greatest.Time.After(central.CenterLine[len(central.CenterLine)-1].Time) {
return fmt.Errorf("geojson: solar greatest time is outside the center-line interval")
}
if central.CenterLine[0].Time.Before(central.Eclipse.CentralBeginOnEarth) ||
central.CenterLine[len(central.CenterLine)-1].Time.After(central.Eclipse.CentralEndOnEarth) {
return fmt.Errorf("geojson: solar center line is outside the central interval")
}
if len(central.NorthernLimit) != len(central.SouthernLimit) {
return fmt.Errorf("geojson: solar central limits must have the same sample count")
}
if len(central.NorthernLimit) > 0 {
if err := validateSolarPathSeries("solar northern limit", central.NorthernLimit, true); err != nil {
return err
}
if err := validateSolarPathSeries("solar southern limit", central.SouthernLimit, true); err != nil {
return err
}
for index := range central.NorthernLimit {
if !central.NorthernLimit[index].Time.Equal(central.SouthernLimit[index].Time) {
return fmt.Errorf("geojson: solar central limit sample %d times must match", index)
}
}
}
return nil
}
func validateSolarContactSequence(
name string,
contacts ...eclipsecore.SolarEclipsePathPoint,
) error {
previous := time.Time{}
for index, contact := range contacts {
if contact.Time.IsZero() {
continue
}
if !previous.IsZero() && !previous.Before(contact.Time) {
return fmt.Errorf("geojson: solar %s contacts must be strictly ordered at %d", name, index)
}
previous = contact.Time
}
return nil
}
func validateSolarFootprints(
name string,
footprints []eclipsecore.SolarEclipsePartialFootprint,
start, end time.Time,
) error {
previous := time.Time{}
for footprintIndex, footprint := range footprints {
if footprint.Time.IsZero() {
return fmt.Errorf("geojson: solar %s footprint %d time is required", name, footprintIndex)
}
if !previous.IsZero() && !footprint.Time.After(previous) {
return fmt.Errorf("geojson: solar %s footprint times must be strictly increasing", name)
}
if footprint.Time.Before(start) || footprint.Time.After(end) {
return fmt.Errorf("geojson: solar %s footprint %d time is outside its event interval", name, footprintIndex)
}
if len(footprint.Boundaries) == 0 {
return fmt.Errorf("geojson: solar %s footprint %d has no boundary", name, footprintIndex)
}
for segmentIndex, segment := range footprint.Boundaries {
if len(segment) == 0 {
return fmt.Errorf("geojson: solar %s footprint %d boundary %d is empty", name, footprintIndex, segmentIndex)
}
for pointIndex, point := range segment {
if err := validateSolarPathPoint(fmt.Sprintf(
"solar %s footprint %d boundary %d point %d", name, footprintIndex, segmentIndex, pointIndex,
), point); err != nil {
return err
}
if !point.Time.Equal(footprint.Time) {
return fmt.Errorf("geojson: solar %s footprint point time must match its footprint", name)
}
}
}
previous = footprint.Time
}
return nil
}
func validateSolarRiseSetCurves(
curves []eclipsecore.SolarEclipseRiseSetCurve,
start, end time.Time,
) error {
seen := make(map[[2]string]bool, len(curves))
for curveIndex, curve := range curves {
if curve.Phase != eclipsecore.RiseSetPhaseStart &&
curve.Phase != eclipsecore.RiseSetPhaseGreatest && curve.Phase != eclipsecore.RiseSetPhaseEnd {
return fmt.Errorf("geojson: solar rise/set curve %d has unsupported phase %q", curveIndex, curve.Phase)
}
if curve.Direction != eclipsecore.RiseSetDirectionRise && curve.Direction != eclipsecore.RiseSetDirectionSet {
return fmt.Errorf("geojson: solar rise/set curve %d has unsupported direction %q", curveIndex, curve.Direction)
}
key := [2]string{string(curve.Phase), string(curve.Direction)}
if seen[key] {
return fmt.Errorf("geojson: solar rise/set curve %d duplicates phase %q and direction %q", curveIndex, curve.Phase, curve.Direction)
}
seen[key] = true
if len(curve.Segments) == 0 {
return fmt.Errorf("geojson: solar rise/set curve %d has no segments", curveIndex)
}
for segmentIndex, segment := range curve.Segments {
if len(segment) < 2 {
return fmt.Errorf("geojson: solar rise/set curve %d segment %d requires at least two points", curveIndex, segmentIndex)
}
previous := time.Time{}
for pointIndex, point := range segment {
if err := validateSolarPathPoint(fmt.Sprintf(
"solar rise/set curve %d segment %d point %d", curveIndex, segmentIndex, pointIndex,
), point); err != nil {
return err
}
if !solarEclipseTimeInsideInterval(point.Time, start, end) {
return fmt.Errorf("geojson: solar rise/set curve point is outside the partial interval")
}
if !previous.IsZero() && !point.Time.After(previous) {
return fmt.Errorf("geojson: solar rise/set curve segment times must be strictly increasing")
}
previous = point.Time
}
}
}
return nil
}
func solarEclipseTimeInsideInterval(value, start, end time.Time) bool {
return !value.Before(start.Add(-solarEclipseValidationTimeTolerance)) &&
!value.After(end.Add(solarEclipseValidationTimeTolerance))
}
func validateSolarMagnitudeContourSeries(
name string,
points []eclipsecore.SolarEclipsePathPoint,
required bool,
) error {
if required && len(points) < 2 {
return fmt.Errorf("geojson: %s requires at least two points", name)
}
for index, point := range points {
if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
return err
}
}
return nil
}
func validateSolarGreatestTimeContours(
contours []eclipsecore.SolarEclipseGreatestTimeContour,
) error {
for index, contour := range contours {
if !finiteGeoJSON(contour.JDE) || contour.JDE == 0 {
return fmt.Errorf("geojson: solar greatest-time contour %d JDE must be finite and non-zero", index)
}
if contour.Time.IsZero() {
return fmt.Errorf("geojson: solar greatest-time contour %d time is required", index)
}
if len(contour.Segments) == 0 {
return fmt.Errorf("geojson: solar greatest-time contour %d has no branches", index)
}
for segmentIndex, segment := range contour.Segments {
if err := validateSolarMagnitudeContourSeries(
fmt.Sprintf("solar greatest-time contour %d branch %d", index, segmentIndex), segment, true,
); err != nil {
return err
}
}
}
return nil
}
func validateSolarPathSeries(name string, points []eclipsecore.SolarEclipsePathPoint, required bool) error {
if required && len(points) < 2 {
return fmt.Errorf("geojson: %s requires at least two points", name)
}
previous := time.Time{}
for index, point := range points {
if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
return err
}
if !previous.IsZero() && !point.Time.After(previous) {
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
}
previous = point.Time
}
return nil
}
func validateSolarPathPoint(name string, point eclipsecore.SolarEclipsePathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("geojson: %s time is required", name)
}
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return fmt.Errorf("geojson: %s: %w", name, err)
}
if !finiteGeoJSON(point.SunAltitude) || point.SunAltitude < -90 || point.SunAltitude > 90 {
return fmt.Errorf("geojson: %s sun altitude must be finite and within [-90, 90]", name)
}
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
}
return nil
}
func validateLunarEclipseInfo(info eclipsecore.LunarEclipseInfo) error {
if !info.HasPenumbral || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
return fmt.Errorf("geojson: lunar eclipse penumbral contact times are required")
}
if info.Maximum.IsZero() {
return fmt.Errorf("geojson: lunar eclipse greatest time is required")
}
if info.Type != eclipsecore.LunarEclipsePenumbral && info.Type != eclipsecore.LunarEclipsePartial &&
info.Type != eclipsecore.LunarEclipseTotal {
return fmt.Errorf("geojson: lunar eclipse type is invalid")
}
switch info.Type {
case eclipsecore.LunarEclipsePenumbral:
if info.HasPartial || info.HasTotal {
return fmt.Errorf("geojson: penumbral eclipse cannot contain partial or total phases")
}
case eclipsecore.LunarEclipsePartial:
if !info.HasPartial || info.HasTotal {
return fmt.Errorf("geojson: partial eclipse must contain only a partial phase")
}
case eclipsecore.LunarEclipseTotal:
if !info.HasPartial || !info.HasTotal {
return fmt.Errorf("geojson: total eclipse must contain partial and total phases")
}
}
if !info.HasPartial && (!info.PartialStart.IsZero() || !info.PartialEnd.IsZero()) {
return fmt.Errorf("geojson: partial contact times require a partial phase")
}
if !info.HasTotal && (!info.TotalStart.IsZero() || !info.TotalEnd.IsZero()) {
return fmt.Errorf("geojson: total contact times require a total phase")
}
ordered := []time.Time{info.PenumbralStart}
if info.HasPartial {
if info.PartialStart.IsZero() || info.PartialEnd.IsZero() {
return fmt.Errorf("geojson: lunar eclipse partial contact times are required")
}
ordered = append(ordered, info.PartialStart)
}
if info.HasTotal {
if info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
return fmt.Errorf("geojson: lunar eclipse total contact times are required")
}
ordered = append(ordered, info.TotalStart)
}
ordered = append(ordered, info.Maximum)
if info.HasTotal {
ordered = append(ordered, info.TotalEnd)
}
if info.HasPartial {
ordered = append(ordered, info.PartialEnd)
}
ordered = append(ordered, info.PenumbralEnd)
for index := 1; index < len(ordered); index++ {
if !ordered[index-1].Before(ordered[index]) {
return fmt.Errorf("geojson: lunar eclipse contact times are not strictly ordered")
}
}
return nil
}
const (
solarEclipseEvent = "solar-eclipse"
lunarEclipseEvent = "lunar-eclipse"
solarEclipseValidationTimeTolerance = 3 * time.Minute
solarEclipseCentralBandMinimumBoundaryPoints = 90
defaultLunarBoundaryPoints = 360
minimumLunarBoundaryPoints = 12
maximumLunarBoundaryPoints = 1440
)
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
func MarshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) ([]byte, error) {
return marshalSolarEclipse(partial, central, nil)
}
// MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC。
// MarshalSolarEclipseWithTimeMarkers encodes a solar eclipse and adds Point Features at regular intervals along the central line. Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalSolarEclipseWithTimeMarkers(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
options TimeMarkerOptions,
) ([]byte, error) {
return marshalSolarEclipse(partial, central, &options)
}
func marshalSolarEclipse(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
markerOptions *TimeMarkerOptions,
) ([]byte, error) {
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if len(partial.Footprints) == 0 {
return nil, fmt.Errorf("geojson: solar eclipse has no partial footprints")
}
if err := validateSolarEclipseInput(partial, central); err != nil {
return nil, err
}
properties := map[string]interface{}{
"eclipse_type": string(partial.Eclipse.Type),
"model": string(partial.Eclipse.Model),
}
features := make([]feature, 0, len(partial.Footprints)+9)
for _, footprint := range partial.Footprints {
polygon, err := solarPartialFootprintPolygon(footprint, true)
if err != nil {
return nil, err
}
curve, err := solarShadowFootprintCurveFromSegments(footprint.Boundaries)
if err != nil {
return nil, err
}
if solarShadowRegionDegenerate(curve, polygon) {
// 与单时刻导出同口径:退化区域整条缺省,不退化成点或零面积环。
continue
}
footprintProperties := cloneProperties(properties)
footprintProperties["time"] = formatTime(footprint.Time)
footprintProperties["source_boundary_closed"] = footprint.Closed
footprintProperties["interp_signature"] = solarShadowFootprintSignature(
footprint.Boundaries, footprint.Closed, eclipsecore.SolarEclipseShadowPenumbra,
)
if !footprint.Closed {
footprintProperties["geometry_role"] = "horizon-closed-region"
footprintProperties["closure"] = solarHorizonClosureProperties(
footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
)
}
if len(polygon) == 1 {
value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude)
if pointErr != nil {
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), pointErr)
}
features = append(features, newFeature(
solarEclipseEvent, "partial-footprint", value, footprintProperties,
))
continue
}
value, err := multiPolygonFillGeometry([][]geodata.GeoPoint{polygon})
if err != nil {
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), err)
}
features = append(features, newFeature(
solarEclipseEvent, "partial-footprint", value, footprintProperties,
))
}
if value, source, ok, err := solarPartialBandGeometry(partial); err != nil {
return nil, fmt.Errorf("geojson: solar partial band: %w", err)
} else if ok {
bandProperties := cloneProperties(properties)
bandProperties["source"] = source
features = append(features, newFeature(
solarEclipseEvent, "partial-band", value, bandProperties,
))
}
var err error
features, err = appendSolarRiseSetCurveFeatures(features, partial.RiseSetCurves, properties)
if err != nil {
return nil, err
}
features, err = appendSolarFootprintFeatures(
features, "central-shadow-footprint", partial.CentralShadowFootprints, properties,
)
if err != nil {
return nil, err
}
bandFootprints := solarCentralBandFootprints(partial)
// Keep the densest shadow footprints selected by solarCentralBandFootprints.
// The lightweight companion is sufficient for ordinary closed envelopes, but
// polar two-limit fallback needs the exact U1/U4 endpoint sweep.
if central == nil && len(bandFootprints) > 0 {
band, bandSource, bandErr := solarCentralBandEnvelopeGeometry(partial.CentralBandSegments)
if bandErr != nil {
band, bandErr = solarCentralShadowSweepGeometry(bandFootprints)
bandSource = "central-shadow-sweep"
if bandErr != nil && len(partial.CentralBandFootprints) > 0 &&
!sameSolarFootprintSlice(bandFootprints, partial.CentralBandFootprints) {
band, bandErr = solarCentralShadowSweepGeometry(partial.CentralBandFootprints)
}
}
if bandErr != nil {
return nil, fmt.Errorf("geojson: solar central band: %w", bandErr)
}
bandProperties := cloneProperties(properties)
bandProperties["centrality"] = string(partial.Eclipse.Centrality)
bandProperties["source"] = bandSource
features = append(features, newFeature(
solarEclipseEvent, "central-band", band, bandProperties,
))
}
for _, contour := range partial.MagnitudeContours {
if len(contour.Segments) > 0 {
contourProperties := cloneProperties(properties)
contourProperties["magnitude"] = contour.Magnitude
features, err = appendSolarSegmentedPathLine(
features, "magnitude-line", contour.Segments, contourProperties, false,
)
if err != nil {
return nil, err
}
continue
}
for _, side := range []struct {
name string
points []eclipsecore.SolarEclipsePathPoint
}{
{name: "north", points: contour.NorthernLimit},
{name: "south", points: contour.SouthernLimit},
} {
contourProperties := cloneProperties(properties)
contourProperties["magnitude"] = contour.Magnitude
contourProperties["side"] = side.name
features, err = appendSolarPathLine(
features, "magnitude-line", side.points, contourProperties,
)
if err != nil {
return nil, err
}
}
}
for _, contour := range partial.GreatestTimeContours {
for _, segment := range contour.Segments {
contourProperties := cloneProperties(properties)
contourProperties["time"] = formatTime(contour.Time)
contourProperties["jde"] = contour.JDE
// 支路各点同为该时刻,逐点时间不是递增序列。
features, err = appendSolarSegmentedPathLine(
features, "greatest-time-line", [][]eclipsecore.SolarEclipsePathPoint{segment},
contourProperties, false,
)
if err != nil {
return nil, err
}
}
}
if central != nil {
// bandFootprints is the presentation subset the ribbon and the closed
// envelopes are validated against; one-limit events trim the U1/U4 tails
// out of it. sweepFootprints keeps the complete umbral sweep, because a
// grazing one-limit path really does extend over that whole interval
// (NASA's path table lists its limits from U1 to U4), so a band built or
// validated only against the trimmed subset silently loses the flared
// ends of the real annular/total region.
bandFootprints = solarCentralBandFootprintsForPath(partial, central)
sweepFootprints := solarCentralBandFootprints(partial)
// The exported limit lines are trimmed to the center-line interval for
// ordinary maps, but the static band must be built from the complete
// U1/U4 paired limits: for a shallow two-limit event the axis interval is
// a fraction of the umbral window, and a band built from the trimmed
// limits drops hundreds of kilometres of real annular area.
presentationNorthernLimit := central.NorthernLimit
presentationSouthernLimit := central.SouthernLimit
if partial.Eclipse.Centrality == eclipsecore.SolarEclipseCentralTwoLimits {
if north, south, ok := solarCentralTwoLimitPresentationLimits(
central.NorthernLimit, central.SouthernLimit, central.CenterLine,
); ok {
presentationNorthernLimit, presentationSouthernLimit = north, south
}
}
bandNorthernLimit := central.NorthernLimit
bandSouthernLimit := central.SouthernLimit
// A grazing band is not bounded by the instantaneous cross-section
// limits: those stop describing the region and can sit hundreds of
// kilometres inside it (1136-06-01: 456 km for the northern limit).
// Whenever the analytic limits no longer follow the band boundary, the
// exported lines are taken from the band ring itself, so the dashed
// limits and the filled band describe the same region.
var derivedNorthernLimit, derivedSouthernLimit []eclipsecore.SolarEclipsePathPoint
if len(central.NorthernLimit) > 0 {
var value geometry
var source string
var usedMagnitudeOne bool
centralEnvelope := partial.CentralBandSegments
if len(central.CentralBandSegments) > 0 {
centralEnvelope = central.CentralBandSegments
}
useCriticalEnvelope := len(centralEnvelope) > 0
// Check the shadow axis, not the instantaneous cross-section limits:
// near the horizon those samples can have their local greatest below
// the horizon and need not belong to the visible central band.
coveragePath := *central
coveragePath.NorthernLimit = presentationNorthernLimit
coveragePath.SouthernLimit = presentationSouthernLimit
if useCriticalEnvelope && !solarCentralBandEnvelopeCoversPath(
centralEnvelope, &coveragePath,
) {
useCriticalEnvelope = false
}
if useCriticalEnvelope && !solarCentralBandEnvelopeCoversFootprints(
centralEnvelope, bandFootprints,
) {
useCriticalEnvelope = false
}
if useCriticalEnvelope {
value, source, err = solarCentralBandEnvelopeGeometry(centralEnvelope)
if partial.Eclipse.Type == eclipsecore.SolarEclipseTotal {
source = "magnitude-one-envelope"
}
} else {
value, source, usedMagnitudeOne, err = solarCentralMagnitudeOneBandGeometry(
partial.Eclipse.Type,
partial.MagnitudeContours,
central.CenterLine,
partial.CentralBandHorizonClosures,
)
}
if !useCriticalEnvelope && !usedMagnitudeOne {
value, source, err = solarCentralBandGeometry(
bandNorthernLimit,
bandSouthernLimit,
central.CenterLine,
partial.Eclipse.Type,
partial.Eclipse.Centrality,
sweepFootprints,
partial.CentralBandHorizonClosures,
)
}
if err != nil && len(partial.CentralBandFootprints) > 0 &&
!sameSolarFootprintSlice(sweepFootprints, partial.CentralBandFootprints) {
// A caller may request dense central-shadow samples. Near
// grazing contacts, the planar sweep can become numerically
// open; the always-available end-cap samples provide a stable
// equivalent band without rejecting the whole export.
value, source, err = solarCentralBandGeometry(
bandNorthernLimit,
bandSouthernLimit,
central.CenterLine,
partial.Eclipse.Type,
partial.Eclipse.Centrality,
partial.CentralBandFootprints,
partial.CentralBandHorizonClosures,
)
}
if err != nil {
return nil, fmt.Errorf("geojson: solar central band: %w", err)
}
// Only a band rebuilt from sampled footprints carries the sampling
// ripple the snap removes; an analytic envelope is already the exact
// boundary and must keep its own end caps.
if partial.CentralBandSampled || central.CentralBandSampled {
value = snapSolarBandGeometryToHorizonCurves(value, partial.RiseSetCurves)
}
bandProperties := cloneProperties(properties)
bandProperties["source"] = source
features = append(features, newFeature(
solarEclipseEvent, "central-band", value, bandProperties,
))
} else if len(sweepFootprints) > 0 {
// A one-limit event may publish no paired limits at all. Fall back to
// the complete umbral sweep and make sure the exported band still
// contains its own center line.
polygons, sweepErr := solarCentralShadowSweepPolygons(sweepFootprints)
if sweepErr != nil {
return nil, fmt.Errorf("geojson: solar central band: %w", sweepErr)
}
value, geometryErr := multiPolygonGeometry(
solarCentralBandWithCenterlineCorridor(polygons, central.CenterLine),
)
if geometryErr != nil {
return nil, fmt.Errorf("geojson: solar central band: %w", geometryErr)
}
bandProperties := cloneProperties(properties)
bandProperties["source"] = "central-shadow-sweep"
features = append(features, newFeature(
solarEclipseEvent, "central-band", value, bandProperties,
))
}
// Derive the exported limits from whichever band was built above: a
// grazing band is not bounded by the instantaneous cross-section limits,
// which stop describing the region and can sit hundreds of kilometres
// inside it (1136-06-01: 456 km for the northern limit). A band split at
// the antimeridian is rejoined first; when its fragments do not pair up,
// each ring is cut into runs that stay on one side of the center line.
if bandGeometry, ok := solarEclipseBandGeometry(features); ok && len(central.CenterLine) >= 2 {
geometryRings := solarBandGeometryRings(bandGeometry)
stitched := stitchSolarBandRings(geometryRings)
// 单环先接缝再切侧;两条分支共用同一逐点投影侧判据,标签不会互相矛盾。
if len(stitched) == 1 {
geometryRings = stitched
}
north, south, derived := solarCentralBandLimitSidesFromRings(geometryRings, central.CenterLine)
if derived && (solarCentralLimitSeparationKM(
presentationNorthernLimit, north,
) > solarCentralBandLimitSidesSplitKM || solarCentralLimitSeparationKM(
presentationSouthernLimit, south,
) > solarCentralBandLimitSidesSplitKM) {
derivedNorthernLimit, derivedSouthernLimit = north, south
}
}
if len(derivedNorthernLimit) > 0 {
presentationNorthernLimit, presentationSouthernLimit = derivedNorthernLimit, derivedSouthernLimit
}
features, err = appendSolarPathLine(features, "center-line", central.CenterLine, properties)
if err != nil {
return nil, err
}
if len(presentationNorthernLimit) > 0 {
features, err = appendSolarPathLine(features, "north-limit", presentationNorthernLimit, properties)
if err != nil {
return nil, err
}
features, err = appendSolarPathLine(features, "south-limit", presentationSouthernLimit, properties)
if err != nil {
return nil, err
}
}
if markerOptions != nil {
features, err = appendTimeMarkerFeatures(
features,
solarEclipseEvent,
"center-line",
solarPathSamples(central.CenterLine),
*markerOptions,
)
if err != nil {
return nil, err
}
}
}
greatest := pathSample{
Time: partial.Eclipse.GreatestEclipse,
Longitude: partial.Eclipse.GreatestLongitude,
Latitude: partial.Eclipse.GreatestLatitude,
}
greatestProperties := solarEclipseMetadata(partial.Eclipse)
if central != nil {
greatest = solarPathSample(central.Greatest)
greatestProperties["width_km"] = central.Greatest.WidthKM
greatestProperties["sun_altitude_deg"] = central.Greatest.SunAltitude
if central.MaxCentralDuration > 0 {
// The longest central phase anywhere on the track, which for a
// shallow event exceeds the value at greatest eclipse.
greatestProperties["max_central_duration_seconds"] = central.MaxCentralDuration.Seconds()
greatestProperties["max_central_duration"] = central.MaxCentralDuration.String()
greatestProperties["max_central_duration_longitude"] = central.MaxCentralDurationLongitude
greatestProperties["max_central_duration_latitude"] = central.MaxCentralDurationLatitude
}
}
features, err = appendPointFeature(
features, solarEclipseEvent, "greatest", greatest, greatestProperties,
)
if err != nil {
return nil, err
}
return marshalFeatureCollection(features)
}
// solarCentralBandSeamEpsilonKM is the seam tolerance used when rejoining the
// fragments the antimeridian split left in one band boundary.
const solarCentralBandSeamEpsilonKM = 0.5
// solarCentralBandRunConnectKM is the gap below which two boundary runs are
// treated as consecutive pieces of one limit line.
const solarCentralBandRunConnectKM = 25.0
// stitchSolarBandRings rejoins the fragments an antimeridian split produced, so
// the northern and southern sides can be derived from a single loop. Each
// fragment carries meridian edges at the seam; dropping them leaves open chains
// whose endpoints are rejoined at matching latitudes.
func stitchSolarBandRings(
rings [][]eclipsecore.SolarEclipsePathPoint,
) [][]eclipsecore.SolarEclipsePathPoint {
if len(rings) < 2 {
return rings
}
type bandChain struct {
points []eclipsecore.SolarEclipsePathPoint
}
var chains []bandChain
for _, ring := range rings {
points := openSolarPathRing(ring)
count := len(points)
if count < 3 || !solarBandRingTouchesSeam(points) {
chains = append(chains, bandChain{points: points})
continue
}
seam := make([]bool, count)
start := -1
for index := 0; index < count; index++ {
first, second := points[index], points[(index+1)%count]
seam[index] = solarBandSeamLongitude(first.Longitude) &&
solarBandSeamLongitude(second.Longitude) &&
math.Abs(first.Latitude-second.Latitude) > 1e-9
if seam[index] && start < 0 {
start = (index + 1) % count
}
}
if start < 0 {
chains = append(chains, bandChain{points: points})
continue
}
current := make([]eclipsecore.SolarEclipsePathPoint, 0, count)
for step := 0; step < count; step++ {
index := (start + step) % count
current = append(current, points[index])
if seam[index] {
chains = append(chains, bandChain{points: current})
current = make([]eclipsecore.SolarEclipsePathPoint, 0, count)
}
}
if len(current) > 0 {
chains = append(chains, bandChain{points: current})
}
}
used := make([]bool, len(chains))
merged := make([][]eclipsecore.SolarEclipsePathPoint, 0, len(chains))
for index := range chains {
if used[index] {
continue
}
used[index] = true
current := chains[index].points
for {
joined := false
for next := range chains {
if used[next] {
continue
}
if value, ok := joinSolarBandChains(current, chains[next].points); ok {
current = value
used[next] = true
joined = true
break
}
}
if !joined {
break
}
}
if len(current) >= 3 {
merged = append(merged, current)
}
}
// Close every rejoined loop so downstream code sees whole rings again.
for index, ring := range merged {
if len(ring) > 1 && !solarBandPointsCoincide(ring[0], ring[len(ring)-1]) {
merged[index] = append(ring, ring[0])
}
}
return merged
}
// solarBandRingTouchesSeam reports whether any vertex sits on the antimeridian.
func solarBandRingTouchesSeam(points []eclipsecore.SolarEclipsePathPoint) bool {
for _, point := range points {
if solarBandSeamLongitude(point.Longitude) {
return true
}
}
return false
}
// solarBandSeamLongitude reports whether one longitude lies on the export seam.
func solarBandSeamLongitude(longitude float64) bool {
return math.Abs(math.Abs(longitude)-180) <= 1e-6
}
// solarBandPointsCoincide compares two path points, wrapping longitudes.
func solarBandPointsCoincide(first, second eclipsecore.SolarEclipsePathPoint) bool {
if math.Abs(first.Latitude-second.Latitude) > 1e-9 {
return false
}
delta := math.Abs(math.Remainder(first.Longitude-second.Longitude, 360))
return delta <= 1e-9 || math.Abs(delta-360) <= 1e-9
}
// joinSolarBandChains appends one open chain to another when their seam
// endpoints describe the same latitude on opposite sides of the antimeridian.
func joinSolarBandChains(
first, second []eclipsecore.SolarEclipsePathPoint,
) ([]eclipsecore.SolarEclipsePathPoint, bool) {
if len(first) == 0 || len(second) == 0 {
return nil, false
}
reversed := make([]eclipsecore.SolarEclipsePathPoint, len(second))
for index := range second {
reversed[index] = second[len(second)-1-index]
}
switch {
case solarBandSeamMatch(first[len(first)-1], second[0]):
return append(append([]eclipsecore.SolarEclipsePathPoint{}, first...), second[1:]...), true
case solarBandSeamMatch(first[len(first)-1], second[len(second)-1]):
return append(append([]eclipsecore.SolarEclipsePathPoint{}, first...), reversed[1:]...), true
case solarBandSeamMatch(first[0], second[len(second)-1]):
return append(append([]eclipsecore.SolarEclipsePathPoint{}, second...), first[1:]...), true
case solarBandSeamMatch(first[0], second[0]):
return append(append([]eclipsecore.SolarEclipsePathPoint{}, reversed...), first[1:]...), true
}
return nil, false
}
// solarBandSeamMatch reports whether two chain ends meet across the seam.
func solarBandSeamMatch(first, second eclipsecore.SolarEclipsePathPoint) bool {
if math.Abs(first.Latitude-second.Latitude) > 1e-6 {
return false
}
delta := math.Abs(math.Abs(first.Longitude) - math.Abs(second.Longitude))
if delta > 1e-6 {
return false
}
// Opposite sides of the seam, or the very same meridian point.
return math.Signbit(first.Longitude) != math.Signbit(second.Longitude) ||
math.Abs(first.Longitude-second.Longitude) <= 1e-6
}
// solarCentralBandLimitSidesSplitKM is how far the analytic limits may sit from
// the band boundary before the export replaces them with the band's own sides.
// Ordinary events agree to a few kilometres; a grazing band is hundreds of
// kilometres away, because there the instantaneous cross-section limits stop
// describing the boundary of the region at all.
const solarCentralBandLimitSidesSplitKM = 25.0
// solarBandSideRun is one boundary stretch that stays on a single side of the
// center line, with the projected position and time of each of its vertices.
type solarBandSideRun struct {
points []eclipsecore.SolarEclipsePathPoint
times []time.Time
progress []float64
north bool
}
// solarCentralBandLimitSidesFromRings 由食带边界派生南北限:逐点投影定侧,同侧最长连通段按路径序拼接。
func solarCentralBandLimitSidesFromRings(
rings [][]eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) {
if len(centerLine) < 2 {
return nil, nil, false
}
runs, ok := solarBandSideRuns(rings, centerLine)
if !ok || len(runs) == 0 {
return nil, nil, false
}
northern := solarBandSidePoints(runs, true)
southern := solarBandSidePoints(runs, false)
if len(northern) < 3 || len(southern) < 3 {
return nil, nil, false
}
return northern, southern, true
}
// solarBandSideRuns cuts every ring into runs that keep one side of the center
// line. A run ends where the boundary crosses the center line, jumps across the
// seam, or stalls against the projection.
func solarBandSideRuns(
rings [][]eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) ([]solarBandSideRun, bool) {
var runs []solarBandSideRun
for _, ring := range rings {
points := openSolarPathRing(ring)
if len(points) < 3 {
continue
}
progress := make([]float64, len(points))
times := make([]time.Time, len(points))
north := make([]bool, len(points))
for index, point := range points {
value, stamp, isNorth, ok := solarBandProjectOnCenterLine(point, centerLine)
if !ok {
return nil, false
}
progress[index] = value
times[index] = stamp
north[index] = isNorth
}
current := solarBandSideRun{}
flush := func() {
if len(current.points) >= 3 {
runs = append(runs, current)
}
current = solarBandSideRun{}
}
for index := range points {
next := (index + 1) % len(points)
current.points = append(current.points, points[index])
current.times = append(current.times, times[index])
current.progress = append(current.progress, progress[index])
current.north = north[index]
seamJump := math.Abs(math.Remainder(points[next].Longitude-points[index].Longitude, 360)) > 180
stalled := math.Abs(progress[next]-progress[index]) > 3
if north[index] != north[next] || seamJump || stalled {
flush()
}
}
flush()
}
return runs, true
}
// solarBandSidePoints concatenates the runs of one side in path order and
// spreads their times evenly, because the export requires strictly increasing
// times. Runs that do not touch each other belong to different boundary
// fragments (the union leaves small islands behind); concatenating them would
// draw a limit line straight across the map, so only the longest connected
// group is kept.
func solarBandSidePoints(runs []solarBandSideRun, north bool) []eclipsecore.SolarEclipsePathPoint {
chosen := make([]solarBandSideRun, 0, len(runs))
for _, run := range runs {
if run.north == north {
chosen = append(chosen, run)
}
}
if len(chosen) == 0 {
return nil
}
sort.SliceStable(chosen, func(first, second int) bool {
return meanProgress(chosen[first].progress) < meanProgress(chosen[second].progress)
})
var groups [][]solarBandSideRun
for _, run := range chosen {
if len(groups) > 0 {
last := groups[len(groups)-1]
if solarBandRunsConnect(last[len(last)-1].points, run.points) {
groups[len(groups)-1] = append(last, run)
continue
}
}
groups = append(groups, []solarBandSideRun{run})
}
countPoints := func(group []solarBandSideRun) int {
total := 0
for _, run := range group {
total += len(run.points)
}
return total
}
best := groups[0]
for _, group := range groups[1:] {
if countPoints(group) > countPoints(best) {
best = group
}
}
side := make([]eclipsecore.SolarEclipsePathPoint, 0, countPoints(best))
for _, run := range best {
for index, point := range run.points {
if index < len(run.times) {
point.Time = run.times[index]
}
side = append(side, point)
}
}
if len(side) < 3 {
return nil
}
if !side[len(side)-1].Time.After(side[0].Time) {
for left, right := 0, len(side)-1; left < right; left, right = left+1, right-1 {
side[left], side[right] = side[right], side[left]
}
}
return enforceSolarBandSideTimes(side)
}
// enforceSolarBandSideTimes 保留逐点投影时间,只把投影时间回退的顶点抬到前一点之后。
func enforceSolarBandSideTimes(side []eclipsecore.SolarEclipsePathPoint) []eclipsecore.SolarEclipsePathPoint {
if len(side) < 2 || !side[len(side)-1].Time.After(side[0].Time) {
return nil
}
for index := 1; index < len(side); index++ {
if !side[index].Time.After(side[index-1].Time) {
side[index].Time = side[index-1].Time.Add(time.Millisecond)
}
}
return side
}
// solarBandRunsConnect reports whether two runs share an endpoint, wrapping
// longitudes so a seam crossing still counts as connected.
func solarBandRunsConnect(first, second []eclipsecore.SolarEclipsePathPoint) bool {
if len(first) == 0 || len(second) == 0 {
return false
}
scale := math.Cos(first[len(first)-1].Latitude * math.Pi / 180)
deltaLongitude := math.Remainder(first[len(first)-1].Longitude-second[0].Longitude, 360) * scale
deltaLatitude := first[len(first)-1].Latitude - second[0].Latitude
return 111.32*math.Hypot(deltaLongitude, deltaLatitude) <= solarCentralBandRunConnectKM
}
// meanProgress averages the projected positions of one run.
func meanProgress(values []float64) float64 {
if len(values) == 0 {
return 0
}
total := 0.0
for _, value := range values {
total += value
}
return total / float64(len(values))
}
// solarBandProjectOnCenterLine projects one band point onto the center line and
// reports its position along the path, the matching time, and whether it falls
// north of the center line at that position.
func solarBandProjectOnCenterLine(
point eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) (float64, time.Time, bool, bool) {
bestDistance := math.Inf(1)
bestProgress := 0.0
bestTime := centerLine[0].Time
bestLatitude := centerLine[0].Latitude
scale := math.Cos(point.Latitude * math.Pi / 180)
for position := 0; position+1 < len(centerLine); position++ {
first, second := centerLine[position], centerLine[position+1]
ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale
ay := first.Latitude - point.Latitude
bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale
by := second.Latitude - point.Latitude
dx, dy := bx-ax, by-ay
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
}
distance := math.Hypot(ax+fraction*dx, ay+fraction*dy)
if distance >= bestDistance {
continue
}
bestDistance = distance
bestProgress = float64(position) + fraction
bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
bestLatitude = first.Latitude + fraction*(second.Latitude-first.Latitude)
}
if math.IsInf(bestDistance, 1) {
return 0, time.Time{}, false, false
}
return bestProgress, bestTime, point.Latitude >= bestLatitude, true
}
// solarCentralLimitSeparationKM returns the greatest distance from one exported
// limit curve to the matching side of the band.
func solarCentralLimitSeparationKM(
line []eclipsecore.SolarEclipsePathPoint,
side []eclipsecore.SolarEclipsePathPoint,
) float64 {
if len(line) < 2 || len(side) < 2 {
return math.Inf(1)
}
maximum := 0.0
for _, point := range line {
best := math.Inf(1)
for index := 0; index+1 < len(side); index++ {
best = math.Min(best, solarCentralBandPointSegmentKM(point, side[index], side[index+1]))
}
maximum = math.Max(maximum, best)
}
return maximum
}
// solarCentralBandPointSegmentKM is the distance from a point to one great-circle
// segment, evaluated on a local equirectangular chart.
func solarCentralBandPointSegmentKM(
point, first, second eclipsecore.SolarEclipsePathPoint,
) float64 {
scale := math.Cos(point.Latitude * math.Pi / 180)
// 经度差必须先归约到 ±180°:跨换日线的限线用裸差值会得到数万公里的假距离
// (同一文件其它点-段投影都先做 math.Remainder)。
// Longitude differences must be wrapped to ±180°: a limit line crossing the
// antimeridian otherwise measures tens of thousands of kilometres away, while every
// other point-to-segment projection in this file wraps first.
ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale
ay := first.Latitude - point.Latitude
bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale
by := second.Latitude - point.Latitude
dx, dy := bx-ax, by-ay
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
}
return 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy)
}
// solarCentralBandSnapToleranceKM is how close an exported band vertex must be
// to a greatest-at-horizon curve before it is moved onto it. A grazing band is
// rebuilt from sampled footprints, so its horizon-bounded edge carries a few
// kilometres of sampling ripple; the curve itself is the exact boundary there,
// and the map draws both, so the ripple reads as two lines weaving instead of
// one boundary.
const solarCentralBandSnapToleranceKM = 25.0
// snapSolarBandGeometryToHorizonCurves replaces the band boundary runs that
// already follow a greatest-at-horizon curve with that curve's own vertices, so
// the filled band and the exported visibility line share one boundary. Runs are
// only replaced while their projection onto the curve stays monotone, which
// keeps the substitution from folding the ring; every other edge (the
// shadow-bounded parts) is left untouched.
func snapSolarBandGeometryToHorizonCurves(
value geometry,
curves []eclipsecore.SolarEclipseRiseSetCurve,
) geometry {
polygons, ok := value.Coordinates.([][][][]float64)
if !ok || len(polygons) == 0 {
return value
}
paths := make([][]eclipsecore.SolarEclipsePathPoint, 0, 2)
for _, curve := range curves {
if curve.Phase != eclipsecore.RiseSetPhaseGreatest {
continue
}
for _, segment := range curve.Segments {
if len(segment) >= 2 {
paths = append(paths, segment)
}
}
}
if len(paths) == 0 {
return value
}
snapped := make([][][][]float64, len(polygons))
for polygonIndex, polygon := range polygons {
snapped[polygonIndex] = make([][][]float64, len(polygon))
for ringIndex, ring := range polygon {
snapped[polygonIndex][ringIndex] = snapSolarBandRingToHorizonPaths(ring, paths)
}
}
return geometry{Type: value.Type, Coordinates: snapped}
}
// solarBandProjection is the closest point of one greatest-at-horizon path to a
// band vertex, with the parameter that locates it along that path.
type solarBandProjection struct {
pathIndex int
parameter float64
longitude float64
latitude float64
distance float64
}
func solarBandProjectionAt(
longitude, latitude float64,
paths [][]eclipsecore.SolarEclipsePathPoint,
) (solarBandProjection, bool) {
best := solarBandProjection{distance: solarCentralBandSnapToleranceKM}
found := false
scale := math.Cos(latitude * math.Pi / 180)
for pathIndex, path := range paths {
for index := 0; index+1 < len(path); index++ {
first, second := path[index], path[index+1]
ax := math.Remainder(first.Longitude-longitude, 360) * scale
ay := first.Latitude - latitude
bx := math.Remainder(second.Longitude-longitude, 360) * scale
by := second.Latitude - latitude
dx, dy := bx-ax, by-ay
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
}
distance := 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy)
if distance >= best.distance {
continue
}
candidate := longitude + (ax+fraction*dx)/scale
if candidate < -180 || candidate > 180 {
// A projection that leaves the export window would have to be
// wrapped, which moves the vertex across the seam. Keep the
// sampled position instead of rewriting the fragment topology.
continue
}
best = solarBandProjection{
pathIndex: pathIndex,
parameter: float64(index) + fraction,
longitude: candidate,
latitude: latitude + (ay + fraction*dy),
distance: distance,
}
found = true
}
}
return best, found
}
// snapSolarBandRingToHorizonPaths substitutes the monotone runs of one ring.
func snapSolarBandRingToHorizonPaths(
ring [][]float64,
paths [][]eclipsecore.SolarEclipsePathPoint,
) [][]float64 {
if len(ring) < 4 {
return ring
}
type projected struct {
point []float64
projection solarBandProjection
matched bool
}
points := make([]projected, len(ring))
for index, point := range ring {
points[index] = projected{point: point}
if len(point) < 2 {
continue
}
if projection, ok := solarBandProjectionAt(point[0], point[1], paths); ok {
points[index] = projected{
point: []float64{projection.longitude, projection.latitude},
projection: projection, matched: true,
}
}
}
result := make([][]float64, 0, len(ring))
for index := 0; index < len(points); {
if !points[index].matched {
result = append(result, points[index].point)
index++
continue
}
end := index
for end+1 < len(points) && points[end+1].matched &&
points[end+1].projection.pathIndex == points[index].projection.pathIndex &&
points[end+1].projection.parameter > points[end].projection.parameter {
end++
}
if end == index {
result = append(result, points[index].point)
index++
continue
}
path := paths[points[index].projection.pathIndex]
startParameter := points[index].projection.parameter
endParameter := points[end].projection.parameter
result = append(result, []float64{points[index].projection.longitude, points[index].projection.latitude})
for position := int(math.Ceil(startParameter)); position < len(path); position++ {
if float64(position) <= startParameter {
continue
}
if float64(position) >= endParameter {
break
}
result = append(result, []float64{path[position].Longitude, path[position].Latitude})
}
result = append(result, []float64{points[end].projection.longitude, points[end].projection.latitude})
index = end + 1
}
if len(result) > 1 {
result[len(result)-1] = result[0]
}
if len(result) < 4 {
return ring
}
return result
}
// solarEclipseBandGeometry returns the geometry of the exported central band.
func solarEclipseBandGeometry(features []feature) (geometry, bool) {
for index := len(features) - 1; index >= 0; index-- {
if features[index].Properties["role"] != "central-band" {
continue
}
return features[index].Geometry, true
}
return geometry{}, false
}
// solarBandGeometryRings returns the outer rings of one exported band geometry
// as path points without times; the limit split re-times them from the center
// line, so any band construction can be split the same way.
func solarBandGeometryRings(value geometry) [][]eclipsecore.SolarEclipsePathPoint {
polygons, ok := value.Coordinates.([][][][]float64)
if !ok {
return nil
}
rings := make([][]eclipsecore.SolarEclipsePathPoint, 0, len(polygons))
for _, polygon := range polygons {
if len(polygon) == 0 {
continue
}
ring := make([]eclipsecore.SolarEclipsePathPoint, 0, len(polygon[0]))
for _, position := range polygon[0] {
if len(position) < 2 {
continue
}
ring = append(ring, eclipsecore.SolarEclipsePathPoint{
Longitude: position[0], Latitude: position[1],
})
}
if len(ring) >= 3 {
rings = append(rings, ring)
}
}
return rings
}
func solarCentralBandEnvelopeCoversFootprints(
segments [][]eclipsecore.SolarEclipsePathPoint,
footprints []eclipsecore.SolarEclipsePartialFootprint,
) bool {
if len(segments) == 0 || len(footprints) == 0 {
return true
}
polygons := make([][]geodata.GeoPoint, 0, len(segments))
for _, segment := range segments {
polygon := make([]geodata.GeoPoint, len(segment))
for index, point := range segment {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
// Endpoint footprints are sampled independently from the analytic
// envelope; small numerical gaps are expected. Only a macroscopic miss
// indicates that the envelope selected the wrong polar branch.
points := make([]geodata.GeoPoint, 0, len(footprints))
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
if len(boundary) < 2 {
continue
}
converted, ok := solarCentralBandCoveragePoints(boundary)
if !ok {
return false
}
points = append(points, converted...)
}
}
if len(points) == 0 {
return true
}
return solarCentralBandPointsCover(polygons, points, solarCentralBandCoverageToleranceKM)
}
func solarCentralBandEnvelopeCoversPath(
segments [][]eclipsecore.SolarEclipsePathPoint,
central *eclipsecore.SolarEclipsePath,
) bool {
if len(segments) == 0 || central == nil {
return false
}
// Away from the poles the gnomonic containment check is well conditioned;
// retain the critical envelope there to avoid changing ordinary output.
// Validate the same spherical path containment at every latitude. A
// latitude-based bypass hid ordinary grazing endpoint errors in addition
// to the polar cases it was originally meant to protect.
polygons := make([][]geodata.GeoPoint, 0, len(segments))
for _, segment := range segments {
if len(openSolarPathRing(segment)) < 3 {
return false
}
polygon := make([]geodata.GeoPoint, len(segment))
for index, point := range segment {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
if len(central.CenterLine) < 2 {
return false
}
path := make([]geodata.GeoPoint, len(central.CenterLine))
for index, point := range central.CenterLine {
path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
const maximumMissKM = 6.0
miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, [][]geodata.GeoPoint{path}, false)
if miss > maximumMissKM {
// A narrow band can have a few-kilometre spherical edge sag at a
// closure. Larger misses still select the physical fallback geometry.
if miss > maximumMissKM {
return false
}
}
// The raw spherical ring can still lose a seam when converted to
// RFC-7946 fragments at the antimeridian. Validate the same fragments
// used by multiPolygonGeometry before accepting this envelope.
fragments := make([][]geodata.GeoPoint, 0, len(polygons))
for _, polygon := range polygons {
fragments = append(fragments,
geodata.PolygonFragments(sampleSphericalMapRing(polygon), geodata.ClipView{Projection: geodata.ProjectionEquirectangular})...,
)
}
if len(fragments) == 0 || geodata.SphericalPolygonsPathMissDistanceKM(fragments, [][]geodata.GeoPoint{path}, false) > maximumMissKM {
return false
}
// The projected fragments above are the same RFC-7946 pieces used for
// export and already contain the path-containment check. A separate
// latitude-only seam heuristic rejects valid thin polar rings when the
// axis and boundary cross the antimeridian at different local curvatures.
return true
}
func solarCentralBandAntimeridianSeamMatchesPath(
polygons [][]geodata.GeoPoint, path []geodata.GeoPoint,
) bool {
const maximumSeamLatitudeGap = 5.0
for index := 1; index < len(path); index++ {
first, second := path[index-1], path[index]
if math.Abs(first.Longitude-second.Longitude) <= 180 {
continue
}
secondLongitude := second.Longitude
if secondLongitude < first.Longitude {
secondLongitude += 360
}
firstLongitude := first.Longitude
if firstLongitude < second.Longitude {
firstLongitude += 360
}
fraction := (180 - firstLongitude) / (secondLongitude - firstLongitude)
if fraction < 0 || fraction > 1 {
fraction = (-180 - firstLongitude) / (secondLongitude - firstLongitude)
}
seamLatitude := first.Latitude + fraction*(second.Latitude-first.Latitude)
bestGap := math.Inf(1)
for _, polygon := range polygons {
for pointIndex := 1; pointIndex < len(polygon); pointIndex++ {
firstPoint, secondPoint := polygon[pointIndex-1], polygon[pointIndex]
if math.Abs(firstPoint.Longitude-secondPoint.Longitude) > 180 {
secondPointLongitude := secondPoint.Longitude
if secondPointLongitude < firstPoint.Longitude {
secondPointLongitude += 360
}
firstPointLongitude := firstPoint.Longitude
if firstPointLongitude < secondPoint.Longitude {
firstPointLongitude += 360
}
fraction := (180 - firstPointLongitude) / (secondPointLongitude - firstPointLongitude)
if fraction >= 0 && fraction <= 1 {
candidate := firstPoint.Latitude + fraction*(secondPoint.Latitude-firstPoint.Latitude)
bestGap = math.Min(bestGap, math.Abs(candidate-seamLatitude))
}
}
}
}
if bestGap > maximumSeamLatitudeGap {
return false
}
}
return true
}
func solarCentralBandEnvelopeGeometry(
segments [][]eclipsecore.SolarEclipsePathPoint,
) (geometry, string, error) {
if len(segments) == 0 {
return geometry{}, "", fmt.Errorf("central-band envelope is unavailable")
}
polygons := make([][]geodata.GeoPoint, 0, len(segments))
for segmentIndex, segment := range segments {
if len(openSolarPathRing(segment)) < 3 {
return geometry{}, "", fmt.Errorf("central-band envelope segment %d has fewer than three points", segmentIndex)
}
polygon := make([]geodata.GeoPoint, len(segment))
for pointIndex, point := range segment {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return geometry{}, "", fmt.Errorf("central-band envelope segment %d point %d: %w", segmentIndex, pointIndex, err)
}
polygon[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, polygon)
}
merged := polygons
if len(polygons) > 1 {
var err error
merged, err = geodata.UnionPolygons(polygons)
if err != nil {
// Hybrid envelopes can contain annular/total/annular components
// that meet only at a zero-width transition. Their individual
// spherical rings are valid, while forcing a planar union creates
// an open seam at the transition. Preserve those physical components
// as a MultiPolygon instead of rejecting the whole eclipse.
merged = polygons
value, geometryErr := multiPolygonGeometry(merged)
if geometryErr != nil {
return geometry{}, "", fmt.Errorf("central-band envelope union: %w", err)
}
return value, "besselian-critical-envelope-components", nil
}
}
value, err := multiPolygonGeometry(merged)
if err != nil {
return geometry{}, "", err
}
return value, "besselian-critical-envelope", nil
}
func openSolarPathRing(points []eclipsecore.SolarEclipsePathPoint) []eclipsecore.SolarEclipsePathPoint {
if len(points) > 1 && points[0].Longitude == points[len(points)-1].Longitude &&
points[0].Latitude == points[len(points)-1].Latitude {
return points[:len(points)-1]
}
return points
}
func sameSolarFootprintSlice(
first, second []eclipsecore.SolarEclipsePartialFootprint,
) bool {
if len(first) != len(second) {
return false
}
if len(first) == 0 {
return true
}
return &first[0] == &second[0]
}
func solarCentralBandFootprints(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
) []eclipsecore.SolarEclipsePartialFootprint {
if len(partial.CentralShadowFootprints) > 0 &&
(len(partial.CentralBandFootprints) == 0 ||
partial.CentralShadowStep > 0 && partial.CentralShadowStep <= partial.CentralBandStep &&
partial.BoundaryPoints >= solarEclipseCentralBandMinimumBoundaryPoints) {
return partial.CentralShadowFootprints
}
return partial.CentralBandFootprints
}
// solarCentralBandFootprintsForPath 将开放端部足迹限制在中心轴位于地平线以上的时段。
// One-limit polar eclipses have U1/U4 contacts before/after that interval;
// sweeping those open footprints into the static band creates artificial flared ends.
func solarCentralBandFootprintsForPath(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central *eclipsecore.SolarEclipsePath,
) []eclipsecore.SolarEclipsePartialFootprint {
footprints := solarCentralBandFootprints(partial)
if central == nil || partial.Eclipse.Centrality != eclipsecore.SolarEclipseCentralOneLimit ||
len(central.CenterLine) < 2 {
return footprints
}
start := central.CenterLine[0].Time
end := central.CenterLine[len(central.CenterLine)-1].Time
if start.IsZero() || !start.Before(end) {
return footprints
}
filtered := make([]eclipsecore.SolarEclipsePartialFootprint, 0, len(footprints))
for _, footprint := range footprints {
if footprint.Time.IsZero() || footprint.Time.Before(start) || footprint.Time.After(end) {
continue
}
filtered = append(filtered, footprint)
}
if len(filtered) >= 2 {
return filtered
}
return footprints
}
// MarshalLunarEclipse 将月食 P1/P4 站心月心可见区和几何地平线编码为 GeoJSON,不含折射。
// MarshalLunarEclipse encodes the P1/P4 topocentric Moon-center visibility regions and geometric horizons, without refraction.
// boundaryPoints 小于等于零时使用 360;其他值限制在 [12, 1440]。
// boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440].
func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) {
return marshalLunarEclipse(info, boundaryPoints, nil)
}
// MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素。
// MarshalLunarEclipseWithTimeMarkers encodes a lunar eclipse and adds Point Features along the sublunar track from penumbral start through end.
// 已有要素保持不变;标记标签使用 options.Location,时间值保持 UTC。
// Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalLunarEclipseWithTimeMarkers(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
options TimeMarkerOptions,
) ([]byte, error) {
return marshalLunarEclipse(info, boundaryPoints, &options)
}
func marshalLunarEclipse(
info eclipsecore.LunarEclipseInfo,
boundaryPoints int,
markerOptions *TimeMarkerOptions,
) ([]byte, error) {
if markerOptions != nil {
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
return nil, err
}
}
if err := validateLunarEclipseInfo(info); err != nil {
return nil, err
}
boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints)
properties := map[string]interface{}{
"eclipse_type": string(info.Type),
"boundary_points": boundaryPoints,
}
features := make([]feature, 0, 5)
contacts := []struct {
role string
horizonRole string
time time.Time
}{
{role: "visible-at-p1", horizonRole: "p1-horizon", time: info.PenumbralStart},
{role: "visible-at-p4", horizonRole: "p4-horizon", time: info.PenumbralEnd},
}
for _, contact := range contacts {
points := basic.MoonHorizon(basic.Date2JDE(contact.time.UTC()), boundaryPoints)
horizon := make([]geodata.GeoPoint, len(points))
for index, point := range points {
horizon[index] = geodata.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
horizon = lunarhorizon.Refine(horizon, contact.time)
value, err := multiPolygonGeometry([][]geodata.GeoPoint{horizon})
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
}
// 日界线剪裁会在相邻世界各输出一次零宽薄片:顶点全落在同一条子午线上、平面面积只剩
// 浮点噪声(实测 1.8e-12 deg²,刚好越过共享剪裁器 1e-12 的零面积阈值)。只在本月食
// 可见区导出里丢弃它——共享剪裁器的输出被掩星拓扑依赖,不能在那里过滤。
// The antimeridian split can emit one zero-width sliver per adjacent world: every vertex
// on one meridian and a planar area of pure floating-point noise (measured 1.8e-12 deg^2,
// just past the shared splitter's 1e-12 zero-area floor). Drop it here, in the lunar
// visibility export only; the shared splitter's output feeds occultation topology and must
// not be filtered.
value = dropDegenerateMultiPolygonRings(value)
contactProperties := cloneProperties(properties)
contactProperties["time"] = formatTime(contact.time)
features = append(features, newFeature(
lunarEclipseEvent, contact.role, value, contactProperties,
))
horizonValue, err := geoMultiLineGeometry(horizon, true)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err)
}
features = append(features, newFeature(
lunarEclipseEvent,
contact.horizonRole,
horizonValue,
map[string]interface{}{
"eclipse_type": string(info.Type),
"time": formatTime(contact.time),
},
))
}
maximum := lunarSubpoint(info.Maximum)
features, err := appendPointFeature(
features,
lunarEclipseEvent,
"greatest",
pathSample{Time: info.Maximum, Longitude: maximum.Longitude, Latitude: maximum.Latitude},
lunarEclipseMetadata(info),
)
if err != nil {
return nil, err
}
if markerOptions != nil {
markers, markerErr := lunarEclipseTimeMarkerSamples(info, *markerOptions)
if markerErr != nil {
return nil, markerErr
}
features, err = appendTimeMarkerPointFeatures(
features,
lunarEclipseEvent,
"sublunar-track",
markers,
markerOptions.Location,
)
if err != nil {
return nil, err
}
}
return marshalFeatureCollection(features)
}
// horizonExact 为 true 时把开放边界补到地平圈擦地点(导出用);为 false 时沿用旧封口,
// 因为掩带的面选择依赖这些填充提示,端点外扩会改变极区边缘的面归属。
func solarPartialFootprintPolygon(
footprint eclipsecore.SolarEclipsePartialFootprint,
horizonExact bool,
) ([]geodata.GeoPoint, error) {
if footprint.Time.IsZero() {
return nil, fmt.Errorf("geojson: solar partial footprint time is required")
}
input, err := solarClosureFootprint(footprint)
if err != nil {
return nil, err
}
polygon, _, ok := solarclosure.Ring(input, horizonExact)
if !ok {
return nil, fmt.Errorf("geojson: solar partial footprint boundary is incomplete")
}
return polygon, nil
}
// solarPartialBandGeometry builds the authoritative static visibility region
// from the continuous zero-magnitude envelope and the horizon endpoint tracks.
// Instantaneous footprints remain available for selecting the current shadow,
// but are not part of this time-independent outline.
func solarPartialBandGeometry(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
) (geometry, string, bool, error) {
if len(partial.PartialBandContours) == 0 || len(partial.RiseSetCurves) == 0 {
return solarPartialBandOverlayGeometry(partial.Footprints, partial.RiseSetCurves)
}
contours := make([][]geodata.GeoPoint, 0, len(partial.PartialBandContours))
for _, contour := range partial.PartialBandContours {
contours = append(contours, solarCentralBandGeoPoints(contour))
}
riseSetLines := make([][]geodata.GeoPoint, 0, len(partial.RiseSetCurves)*2)
for _, curve := range partial.RiseSetCurves {
for _, segment := range curve.Segments {
riseSetLines = append(riseSetLines, solarCentralBandGeoPoints(segment))
}
}
footprints := make([]solarclosure.Footprint, 0, len(partial.Footprints))
for _, footprint := range partial.Footprints {
input, err := solarClosureFootprint(footprint)
if err != nil {
return geometry{}, "", false, err
}
footprints = append(footprints, input)
}
// 面选择沿用采样端点的近似补口:端点外扩会改变极区边缘的面归属。
polygons, ok := solarclosure.BandPolygons(
contours, riseSetLines, footprints, false, solarclosure.SnapDistanceKM,
)
if !ok {
return solarPartialBandOverlayGeometry(partial.Footprints, partial.RiseSetCurves)
}
source := "zero-magnitude-envelope+horizon-boundary"
phaseLines := solarPartialBandPhaseLines(partial.RiseSetCurves)
// The linework polygonizer selects faces using sampled instantaneous
// footprints. In an extremely shallow non-central eclipse a phase branch
// can lie in a neighbouring face that no sampled footprint reaches, even
// though it belongs to the same visible envelope. Repair only that proven
// containment miss; ordinary events keep the exact polygonizer result.
if geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false) > 2 {
if bridged, bridgedOK := solarPartialBandBridgePhaseLines(polygons, phaseLines); bridgedOK {
polygons = bridged
source += "+phase-bridge"
}
}
for polygonIndex, polygon := range polygons {
for pointIndex, point := range polygon {
polygons[polygonIndex][pointIndex].Longitude = normalizeLongitude(point.Longitude)
}
}
value, err := multiPolygonGeometry(polygons)
if err != nil {
return geometry{}, "", false, err
}
return value, source, true, nil
}
// solarPartialBandOverlayGeometry fills the time-sampling gaps along the
// sunrise/sunset side when the physical line network cannot form a complete
// visibility boundary. The original footprint sequence remains part of the
// static fill for this explicitly non-authoritative fallback.
func solarPartialBandOverlayGeometry(
footprints []eclipsecore.SolarEclipsePartialFootprint,
curves []eclipsecore.SolarEclipseRiseSetCurve,
) (geometry, string, bool, error) {
openCount := 0
for _, footprint := range footprints {
if !footprint.Closed && len(footprint.Boundaries) > 0 {
openCount++
}
}
if openCount < 2 {
return geometry{}, "", false, nil
}
samples, err := solarCentralShadowSweepSamples(footprints)
if err != nil {
return geometry{}, "", false, err
}
polygons, err := geodata.OpenBoundaryEndpointOutlines(samples)
if err != nil {
return geometry{}, "", false, nil
}
source := "open-boundary-endpoint-outlines"
if bridged, ok := solarPartialBandBridgePhaseLines(polygons, solarPartialBandPhaseLines(curves)); ok {
polygons = bridged
source += "+horizon-boundary"
}
for polygonIndex, polygon := range polygons {
for pointIndex, point := range polygon {
polygons[polygonIndex][pointIndex].Longitude = normalizeLongitude(point.Longitude)
}
}
value, err := multiPolygonGeometry(polygons)
if err != nil {
return geometry{}, "", false, err
}
return value, source, true, nil
}
func solarPartialBandPhaseLines(
curves []eclipsecore.SolarEclipseRiseSetCurve,
) [][]geodata.GeoPoint {
lines := make([][]geodata.GeoPoint, 0, len(curves)*2)
for _, curve := range curves {
for _, segment := range curve.Segments {
if len(segment) < 2 {
continue
}
line := make([]geodata.GeoPoint, len(segment))
for index, point := range segment {
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
lines = append(lines, line)
}
}
return lines
}
// solarPartialBandBridgePhaseLines closes the open-footprint fallback with
// the supplied rise/set tracks. Extremely shallow non-central eclipses may
// have no continuous zero-magnitude contour, while their phase tracks still
// extend beyond the few sampled open footprints. Connect each track to the
// nearest base-ring vertices, then union the local bridge faces. The union
// removes the artificial endpoint crossing and leaves every source phase line
// inside the returned visible envelope.
func solarPartialBandBridgePhaseLines(
base, curves [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, bool) {
if len(base) == 0 || len(curves) == 0 {
return base, false
}
inputs := append([][]geodata.GeoPoint(nil), base...)
bridged := false
for _, line := range curves {
if len(line) < 2 {
continue
}
bestRing := -1
bestDistance := math.Inf(1)
for ringIndex, ring := range base {
open := openRing(ring)
if len(open) < 3 {
continue
}
distance := solarPartialBandGeoPointDistanceToRing(line[0], open) +
solarPartialBandGeoPointDistanceToRing(line[len(line)-1], open)
if distance < bestDistance {
bestRing, bestDistance = ringIndex, distance
}
}
if bestRing < 0 || bestDistance > 4000 {
return base, false
}
bridge := solarPartialBandBridgeLineToRing(line, openRing(base[bestRing]))
if len(bridge) < 4 {
continue
}
inputs = append(inputs, bridge)
bridged = true
}
if !bridged {
return base, false
}
merged, err := geodata.UnionPolygons(inputs)
if err != nil || len(merged) == 0 ||
!geodata.SphericalPolygonsContainPathsWithinKM(merged, curves, false, 2) {
return base, false
}
return merged, true
}
func solarPartialBandGeoPointDistanceToRing(
point geodata.GeoPoint,
ring []geodata.GeoPoint,
) float64 {
minimum := math.Inf(1)
for index := range ring {
minimum = math.Min(minimum, solarCentralBandGeoPointDistanceKM(point, ring[index]))
}
return minimum
}
func solarPartialBandBridgeLineToRing(
line, ring []geodata.GeoPoint,
) []geodata.GeoPoint {
if len(line) < 2 || len(ring) < 3 {
return nil
}
open := openRing(ring)
if len(open) < 3 {
return nil
}
nearest := func(point geodata.GeoPoint) (int, float64) {
bestIndex := 0
bestDistance := solarCentralBandGeoPointDistanceKM(point, open[0])
for index := 1; index < len(open); index++ {
if distance := solarCentralBandGeoPointDistanceKM(point, open[index]); distance < bestDistance {
bestIndex, bestDistance = index, distance
}
}
return bestIndex, bestDistance
}
startIndex, _ := nearest(line[0])
endIndex, _ := nearest(line[len(line)-1])
path := func(step int) []geodata.GeoPoint {
result := []geodata.GeoPoint{open[endIndex]}
index := endIndex
for index != startIndex {
index = (index + step + len(open)) % len(open)
result = append(result, open[index])
}
return result
}
forward, reverse := path(1), path(-1)
pathLength := func(points []geodata.GeoPoint) float64 {
length := 0.0
for index := 1; index < len(points); index++ {
length += solarCentralBandGeoPointDistanceKM(points[index-1], points[index])
}
return length
}
boundary := forward
if pathLength(reverse) < pathLength(forward) {
boundary = reverse
}
result := append([]geodata.GeoPoint(nil), line...)
result = append(result, boundary...)
return result
}
func appendSolarFootprintFeatures(
features []feature,
role string,
footprints []eclipsecore.SolarEclipsePartialFootprint,
properties map[string]interface{},
) ([]feature, error) {
for _, footprint := range footprints {
if role == solarCentralShadowFootprintRole && !footprint.Closed {
appended, err := appendSolarHorizonClosedShadowFootprint(features, footprint, properties)
if err != nil {
return nil, err
}
features = appended
continue
}
polygon, err := solarPartialFootprintPolygon(footprint, false)
if err != nil {
return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), err)
}
curve, err := solarShadowFootprintCurveFromSegments(footprint.Boundaries)
if err != nil {
return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), err)
}
if solarShadowRegionDegenerate(curve, polygon) {
// 与单时刻导出同口径:退化区域整条缺省,不退化成点或零面积环。
continue
}
footprintProperties := cloneProperties(properties)
footprintProperties["time"] = formatTime(footprint.Time)
footprintProperties["source_boundary_closed"] = footprint.Closed
footprintProperties["interp_signature"] = solarShadowFootprintSignature(
footprint.Boundaries, footprint.Closed, eclipsecore.SolarEclipseShadowUmbra,
)
if len(polygon) == 1 {
value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude)
if pointErr != nil {
return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), pointErr)
}
features = append(features, newFeature(solarEclipseEvent, role, value, footprintProperties))
continue
}
value, geometryErr := multiPolygonGeometry([][]geodata.GeoPoint{polygon})
if geometryErr != nil {
return nil, fmt.Errorf("geojson: solar %s at %s: %w", role, formatTime(footprint.Time), geometryErr)
}
features = append(features, newFeature(solarEclipseEvent, role, value, footprintProperties))
}
return features, nil
}
// solarCentralShadowSweepGeometry closes the open antumbral arcs as one swept
// region. A non-central eclipse has no axis/earth intersection, so the normal
// paired central limits cannot describe this half-band. The first and last
// shadow arcs form the end caps; their two endpoint tracks form the sides.
func solarCentralShadowSweepGeometry(
footprints []eclipsecore.SolarEclipsePartialFootprint,
) (geometry, error) {
polygons, err := solarCentralShadowSweepPolygons(footprints)
if err != nil {
return geometry{}, err
}
return multiPolygonGeometry(polygons)
}
func solarCentralShadowSweepPolygons(
footprints []eclipsecore.SolarEclipsePartialFootprint,
) ([][]geodata.GeoPoint, error) {
samples, err := solarCentralShadowSweepSamples(footprints)
if err != nil {
return nil, err
}
polygons, err := geodata.OpenBoundarySweep(samples)
if err != nil {
return nil, fmt.Errorf("central-shadow footprints: %w", err)
}
return usableSolarCentralShadowSweepPolygons(polygons)
}
func solarCentralMonotoneEndSweepPolygons(
footprints []eclipsecore.SolarEclipsePartialFootprint,
) ([][]geodata.GeoPoint, error) {
samples, err := solarCentralShadowSweepSamples(footprints)
if err != nil {
return nil, err
}
polygons, err := geodata.MonotoneOpenBoundarySweep(samples)
if err != nil {
polygons, err = geodata.OpenBoundarySweep(
geodata.DecimateOpenBoundarySweepSamples(samples, 24, 40),
)
if err != nil {
return nil, fmt.Errorf("central-shadow endpoint footprints: %w", err)
}
}
return usableSolarCentralShadowSweepPolygons(polygons)
}
func solarCentralShadowSweepSamples(
footprints []eclipsecore.SolarEclipsePartialFootprint,
) ([]geodata.OpenBoundarySweepSample, error) {
samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints))
for _, footprint := range footprints {
boundary := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
for _, source := range footprint.Boundaries {
segment := make([]geodata.GeoPoint, len(source))
for index, point := range source {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, err
}
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
boundary = append(boundary, segment)
}
samples = append(samples, geodata.OpenBoundarySweepSample{
Boundaries: boundary,
Closed: footprint.Closed,
})
}
return samples, nil
}
func usableSolarCentralShadowSweepPolygons(
polygons [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, error) {
usable := make([][]geodata.GeoPoint, 0, len(polygons))
for _, polygon := range polygons {
if len(openRing(polygon)) >= 3 {
usable = append(usable, polygon)
}
}
if len(usable) == 0 {
return nil, fmt.Errorf("central-shadow footprints contain no usable swept region")
}
if len(usable) == 1 {
return usable, nil
}
return geodata.UnionPolygons(usable)
}
// solarCentralBandCoverageToleranceKM is the macro-leak threshold used to
// reject a central-band candidate that leaves real umbral area uncovered.
// Ordinary events stay within roughly 40 km at the 90th percentile, while a
// grazing one-limit ribbon or a failed two-limit ribbon misses by hundreds of
// kilometres.
const solarCentralBandCoverageToleranceKM = 100.0
// solarCentralBandCoveragePoints 把路径点转成球面点,坐标非法时返回 false。
func solarCentralBandCoveragePoints(
points []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, bool) {
result := make([]geodata.GeoPoint, len(points))
for index, point := range points {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil, false
}
result[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
return result, true
}
// solarCentralBandVertexGridDegrees 是顶点网格的边长:一格的纬度跨度已超过任何容差。
const solarCentralBandVertexGridDegrees = 1.0
// solarCentralBandVertexGrid 按固定网格索引环顶点,用于快速确认探针落在环附近。
type solarCentralBandVertexGrid map[int][]geodata.GeoPoint
func solarCentralBandVertexGridKey(latitude, longitude float64) int {
latitudeCell := int(math.Floor(latitude/solarCentralBandVertexGridDegrees)) + 90
longitudeCell := int(math.Floor(normalizeLongitude(longitude)/solarCentralBandVertexGridDegrees)) + 180
return latitudeCell*360 + longitudeCell
}
func newSolarCentralBandVertexGrid(polygons [][]geodata.GeoPoint) solarCentralBandVertexGrid {
grid := make(solarCentralBandVertexGrid)
for _, polygon := range polygons {
for _, point := range openRing(polygon) {
key := solarCentralBandVertexGridKey(point.Latitude, point.Longitude)
grid[key] = append(grid[key], point)
}
}
return grid
}
// vertexWithinKM 报告网格邻域内是否存在容差范围内的环顶点;网格给不出结论不代表真的超限。
func (grid solarCentralBandVertexGrid) vertexWithinKM(
point geodata.GeoPoint,
toleranceKM float64,
) bool {
deltaLatitude := toleranceKM/111.0 + solarCentralBandVertexGridDegrees
scale := math.Abs(math.Cos(point.Latitude * math.Pi / 180))
if scale < 1e-6 {
scale = 1e-6
}
deltaLongitude := deltaLatitude/scale + solarCentralBandVertexGridDegrees
minimumLatitudeCell := int(math.Floor((point.Latitude-deltaLatitude)/solarCentralBandVertexGridDegrees)) + 90
maximumLatitudeCell := int(math.Floor((point.Latitude+deltaLatitude)/solarCentralBandVertexGridDegrees)) + 90
minimumLongitudeCell := int(math.Floor((point.Longitude-deltaLongitude)/solarCentralBandVertexGridDegrees)) + 180
maximumLongitudeCell := int(math.Floor((point.Longitude+deltaLongitude)/solarCentralBandVertexGridDegrees)) + 180
for latitudeCell := minimumLatitudeCell; latitudeCell <= maximumLatitudeCell; latitudeCell++ {
for longitudeCell := minimumLongitudeCell; longitudeCell <= maximumLongitudeCell; longitudeCell++ {
for _, vertex := range grid[latitudeCell*360+longitudeCell] {
if solarCentralBandGeoPointDistanceKM(point, vertex) <= toleranceKM {
return true
}
}
}
}
return false
}
// solarCentralBandPointsCover 报告每个点是否都在容差内落在多边形里:先做一次球面包含判定,
// 环外的点先用顶点网格确认附近有环顶点,只有网格给不出结论时才做精确的球面偏离计算。
func solarCentralBandPointsCover(
polygons [][]geodata.GeoPoint,
points []geodata.GeoPoint,
toleranceKM float64,
) bool {
if len(polygons) == 0 {
return false
}
if len(points) == 0 {
return true
}
grid := newSolarCentralBandVertexGrid(polygons)
index := geodata.NewSphericalPolygonIndex(polygons)
for position, inside := range index.ContainsPoints(points) {
if inside {
continue
}
// 环顶点到多边形的距离不小于到环顶点的距离,邻域内有顶点即已满足容差。
if grid.vertexWithinKM(points[position], toleranceKM) {
continue
}
if geodata.SphericalPolygonsPathMissDistanceKM(
polygons, [][]geodata.GeoPoint{{points[position]}}, false,
) > toleranceKM {
return false
}
}
return true
}
// solarCentralBandRingsCover reports whether the candidate rings contain the
// complete center line and the umbral sweep. The center line and the swept
// footprints are the ground truth the static band must describe, so a candidate
// that leaves either outside is not acceptable while a better alternative
// remains; every vertex is probed because this decision selects the exported
// candidate and a subsample can step over a real gap.
func solarCentralBandRingsCover(
rings [][]geodata.GeoPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
footprints []eclipsecore.SolarEclipsePartialFootprint,
) bool {
if len(rings) == 0 {
return false
}
points := make([]geodata.GeoPoint, 0, len(centerLine))
if len(centerLine) >= 2 {
converted, ok := solarCentralBandCoveragePoints(centerLine)
if !ok {
return false
}
points = append(points, converted...)
}
for _, footprint := range footprints {
for _, boundary := range footprint.Boundaries {
if len(boundary) < 2 {
continue
}
converted, ok := solarCentralBandCoveragePoints(boundary)
if !ok {
return false
}
points = append(points, converted...)
}
}
if len(points) == 0 {
return true
}
return solarCentralBandPointsCover(rings, points, solarCentralBandCoverageToleranceKM)
}
// The center line is the spine of the band: a candidate that clips it is not a
// valid envelope even when its outer edge follows the umbral sweep, and the
// union of a paired ribbon with end sweeps can re-orient a polar ring just
// enough to push a few line vertices outside. The corridor below widens such a
// candidate locally instead of rejecting the whole band and falling back to a
// ribbon that loses hundreds of kilometres of real umbral area.
const (
solarCentralBandCenterlineToleranceKM = 2.0
// 探针偏离超过该上限就不补走廊:半径会把食带撑成一个覆盖半个地球的圆盘。
solarCentralBandCenterlineCorridorMaxKM = 200.0
)
// solarCentralBandCenterlineProbes 把采样中心线展开成顶点与边中点,作为包含判据的探针集合。
func solarCentralBandCenterlineProbes(
centerLine []eclipsecore.SolarEclipsePathPoint,
) []geodata.GeoPoint {
probes := make([]geodata.GeoPoint, 0, 2*len(centerLine))
for index, point := range centerLine {
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
return nil
}
probes = append(probes, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
if index+1 >= len(centerLine) {
continue
}
next := centerLine[index+1]
if err := validateCoordinate(next.Longitude, next.Latitude); err != nil {
return nil
}
probes = append(probes, geodata.GeoPoint{
Longitude: normalizeLongitude(
point.Longitude + math.Remainder(next.Longitude-point.Longitude, 360)/2,
),
Latitude: (point.Latitude + next.Latitude) / 2,
})
}
return probes
}
// solarCentralBandCenterlineMissesKM 逐个探针量到多边形的偏离,落在多边形内的探针为 0。
func solarCentralBandCenterlineMissesKM(
polygons [][]geodata.GeoPoint,
probes []geodata.GeoPoint,
) []float64 {
misses := make([]float64, len(probes))
if len(polygons) == 0 || len(probes) == 0 {
return misses
}
index := geodata.NewSphericalPolygonIndex(polygons)
contained := index.ContainsPoints(probes)
for position, inside := range contained {
if inside {
continue
}
misses[position] = geodata.SphericalPolygonsPathMissDistanceKM(
polygons, [][]geodata.GeoPoint{{probes[position]}}, false,
)
}
return misses
}
// solarCentralBandWithCenterlineCorridor 保证每个中心线探针都在容差内落在返回的多边形里:
// 只给越界的探针补一个半径等于它自身偏离加容差的圆盘,偏离超过上限时原样返回。
func solarCentralBandWithCenterlineCorridor(
polygons [][]geodata.GeoPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) [][]geodata.GeoPoint {
if len(polygons) == 0 || len(centerLine) < 2 {
return polygons
}
probes := solarCentralBandCenterlineProbes(centerLine)
if len(probes) == 0 {
return polygons
}
misses := solarCentralBandCenterlineMissesKM(polygons, probes)
inputs := append([][]geodata.GeoPoint{}, polygons...)
patched := 0
for position, probe := range probes {
miss := misses[position]
if miss <= solarCentralBandCenterlineToleranceKM {
continue
}
if miss > solarCentralBandCenterlineCorridorMaxKM {
return polygons
}
circle := geodata.SphericalCircle(
probe, (miss+solarCentralBandCenterlineToleranceKM)/111.32, 12,
)
if len(circle) < 3 {
continue
}
inputs = append(inputs, append(circle, circle[0]))
patched++
}
if patched == 0 {
return polygons
}
merged, err := geodata.UnionPolygons(inputs)
if err != nil || len(merged) == 0 {
return polygons
}
return merged
}
// solarCentralMagnitudeOneBandGeometry builds the static totality envelope
// from the local-maximum magnitude-one contour. The old central limits are
// instantaneous cross-sections perpendicular to the moving shadow; near a
// low-altitude path those cross-sections can be narrower than the spatial
// envelope swept by the shadow. A magnitude-one contour is already that
// envelope. This fallback supports caller-supplied results without the core
// CentralBandSegments; normal calculations provide the closed band directly.
func solarCentralMagnitudeOneBandGeometry(
eclipseType eclipsecore.SolarEclipseType,
contours []eclipsecore.SolarEclipseMagnitudeContour,
centerLine []eclipsecore.SolarEclipsePathPoint,
horizonClosures [][]eclipsecore.SolarEclipsePathPoint,
) (geometry, string, bool, error) {
// Only a total eclipse has a local magnitude-one contour: an annular eclipse
// stays below one everywhere (the ring is the whole point), so its band is
// bounded by the antumbral limits instead.
if eclipseType != eclipsecore.SolarEclipseTotal || len(centerLine) < 2 {
return geometry{}, "", false, nil
}
var segments [][]eclipsecore.SolarEclipsePathPoint
for _, contour := range contours {
if math.Abs(contour.Magnitude-1) > 1e-12 || len(contour.Segments) != 2 {
continue
}
for _, segment := range contour.Segments {
if len(segment) >= 2 {
segments = append(segments, segment)
}
}
if len(segments) == 2 {
break
}
segments = nil
}
if len(segments) != 2 {
return geometry{}, "", false, nil
}
first := append([]eclipsecore.SolarEclipsePathPoint(nil), segments[0]...)
second := append([]eclipsecore.SolarEclipsePathPoint(nil), segments[1]...)
ring, closedAtHorizon := solarCentralMagnitudeOneHorizonRing(first, second, horizonClosures)
if !closedAtHorizon {
forwardGap := solarCentralBandPathDistanceKM(first[len(first)-1], second[0])
reverseGap := solarCentralBandPathDistanceKM(first[len(first)-1], second[len(second)-1])
if reverseGap < forwardGap {
for left, right := 0, len(second)-1; left < right; left, right = left+1, right-1 {
second[left], second[right] = second[right], second[left]
}
forwardGap = reverseGap
}
closingGap := solarCentralBandPathDistanceKM(second[len(second)-1], first[0])
if forwardGap > 2000 || closingGap > 2000 {
return geometry{}, "", false, nil
}
ring = make([]geodata.GeoPoint, 0, len(first)+len(second))
for _, point := range first {
ring = append(ring, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
for _, point := range second {
ring = append(ring, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
}
ring = solarCentralBandRefineRingSpacing(ring, 200)
centerPath := make([]geodata.GeoPoint, len(centerLine))
for index, point := range centerLine {
centerPath[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
if !geodata.SphericalPolygonsContainPathsWithinKM(
[][]geodata.GeoPoint{ring}, [][]geodata.GeoPoint{centerPath}, false, 5,
) {
return geometry{}, "", false, nil
}
value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring})
if err != nil {
return geometry{}, "", false, err
}
return value, "magnitude-one-envelope", true, nil
}
// solarCentralMagnitudeOneHorizonRing replaces both straight endpoint chords
// with the exact greatest-at-horizon arcs shared by the public rise/set lines.
func solarCentralMagnitudeOneHorizonRing(
first, second []eclipsecore.SolarEclipsePathPoint,
closures [][]eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, bool) {
if len(first) < 2 || len(second) < 2 || len(closures) != 2 ||
len(closures[0]) < 2 || len(closures[1]) < 2 {
return nil, false
}
type candidate struct {
first []eclipsecore.SolarEclipsePathPoint
second []eclipsecore.SolarEclipsePathPoint
score float64
}
best := candidate{score: math.Inf(1)}
for _, reverseFirst := range []bool{false, true} {
for _, reverseSecond := range []bool{false, true} {
firstCandidate := solarCentralBandOrientedPath(first, reverseFirst)
secondCandidate := solarCentralBandOrientedPath(second, reverseSecond)
score := solarCentralMagnitudeOneClosurePairDistance(
closures[0], firstCandidate[0], secondCandidate[0],
) + solarCentralMagnitudeOneClosurePairDistance(
closures[1], firstCandidate[len(firstCandidate)-1], secondCandidate[len(secondCandidate)-1],
)
if score < best.score {
best = candidate{first: firstCandidate, second: secondCandidate, score: score}
}
}
}
startClosure, startOK := solarCentralMagnitudeOneOrientedClosure(
closures[0], best.second[0], best.first[0],
)
endClosure, endOK := solarCentralMagnitudeOneOrientedClosure(
closures[1], best.first[len(best.first)-1], best.second[len(best.second)-1],
)
if !startOK || !endOK {
return nil, false
}
best.first[0] = startClosure[len(startClosure)-1]
best.first[len(best.first)-1] = endClosure[0]
best.second[0] = startClosure[0]
best.second[len(best.second)-1] = endClosure[len(endClosure)-1]
points := make([]eclipsecore.SolarEclipsePathPoint, 0,
len(best.first)+len(best.second)+len(startClosure)+len(endClosure),
)
points = append(points, best.first...)
points = append(points, endClosure[1:]...)
for index := len(best.second) - 2; index >= 0; index-- {
points = append(points, best.second[index])
}
points = append(points, startClosure[1:]...)
return solarCentralBandGeoPoints(points), true
}
func solarCentralBandOrientedPath(
points []eclipsecore.SolarEclipsePathPoint,
reverse bool,
) []eclipsecore.SolarEclipsePathPoint {
result := append([]eclipsecore.SolarEclipsePathPoint(nil), points...)
if reverse {
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
result[left], result[right] = result[right], result[left]
}
}
return result
}
func solarCentralMagnitudeOneClosurePairDistance(
closure []eclipsecore.SolarEclipsePathPoint,
first, second eclipsecore.SolarEclipsePathPoint,
) float64 {
direct := solarCentralBandPathDistanceKM(closure[0], first) +
solarCentralBandPathDistanceKM(closure[len(closure)-1], second)
reverse := solarCentralBandPathDistanceKM(closure[len(closure)-1], first) +
solarCentralBandPathDistanceKM(closure[0], second)
return math.Min(direct, reverse)
}
func solarCentralMagnitudeOneOrientedClosure(
source []eclipsecore.SolarEclipsePathPoint,
start, end eclipsecore.SolarEclipsePathPoint,
) ([]eclipsecore.SolarEclipsePathPoint, bool) {
const maximumRootDistanceKM = 1.0
closure := solarCentralBandOrientedPath(source, false)
direct := solarCentralBandPathDistanceKM(start, closure[0]) +
solarCentralBandPathDistanceKM(end, closure[len(closure)-1])
reverse := solarCentralBandPathDistanceKM(start, closure[len(closure)-1]) +
solarCentralBandPathDistanceKM(end, closure[0])
if reverse < direct {
closure = solarCentralBandOrientedPath(closure, true)
}
if solarCentralBandPathDistanceKM(start, closure[0]) > maximumRootDistanceKM ||
solarCentralBandPathDistanceKM(end, closure[len(closure)-1]) > maximumRootDistanceKM {
return nil, false
}
return closure, true
}
func solarCentralBandPathDistanceKM(
first, second eclipsecore.SolarEclipsePathPoint,
) float64 {
return solarCentralBandGeoPointDistanceKM(
geodata.GeoPoint{Longitude: first.Longitude, Latitude: first.Latitude},
geodata.GeoPoint{Longitude: second.Longitude, Latitude: second.Latitude},
)
}
func solarCentralBandGeometry(
northern, southern []eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
eclipseType eclipsecore.SolarEclipseType,
centrality eclipsecore.SolarEclipseCentrality,
footprints []eclipsecore.SolarEclipsePartialFootprint,
horizonClosures [][]eclipsecore.SolarEclipsePathPoint,
) (geometry, string, error) {
if eclipseType != eclipsecore.SolarEclipseHybrid {
// A one-limit central event traces its paired limits only over the
// shadow-axis interval, which for a grazing event (|gamma| ~ 0.98-0.997)
// is a fraction of the U1..U4 umbral window. Reuse the two-limit
// candidate chain, whose end-sweep and complete-contact alternatives are
// coverage-validated, before falling back to the continuous ribbon.
if centrality == eclipsecore.SolarEclipseCentralOneLimit {
// The chain's last-resort candidates are returned without coverage
// validation, so re-check here: a validated alternative is only worth
// taking when it actually covers the umbral sweep, otherwise the
// continuous ribbon below stays the better rendering.
if polygons, source, ok := solarCentralTwoLimitBandPolygons(
northern, southern, centerLine, footprints, horizonClosures,
); ok && solarCentralBandRingsCover(polygons, centerLine, footprints) {
value, geometryErr := multiPolygonGeometry(
solarCentralBandWithCenterlineCorridor(polygons, centerLine),
)
if geometryErr != nil {
return geometry{}, "", geometryErr
}
return value, source, nil
}
band, err := pairedLimitPolygon(northern, southern)
if err != nil {
return geometry{}, "", err
}
inputs := append([][]geodata.GeoPoint{band}, solarCentralBandEndpointCaps(northern, southern, centerLine)...)
if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil {
inputs = merged
}
value, geometryErr := multiPolygonGeometry(inputs)
if geometryErr != nil {
return geometry{}, "", geometryErr
}
return value, "paired-limits-one-limit", nil
}
if centrality == eclipsecore.SolarEclipseCentralTwoLimits {
polygons, source, ok := solarCentralTwoLimitBandPolygons(
northern, southern, centerLine, footprints, horizonClosures,
)
if ok {
value, geometryErr := multiPolygonGeometry(
solarCentralBandWithCenterlineCorridor(polygons, centerLine),
)
if geometryErr != nil {
return geometry{}, "", geometryErr
}
return value, source, nil
}
}
band, err := pairedLimitPolygon(northern, southern)
if err != nil {
return geometry{}, "", err
}
endpointCaps := solarCentralBandEndpointCaps(northern, southern, centerLine)
inputs := append([][]geodata.GeoPoint{band}, endpointCaps...)
if len(footprints) > 0 {
if sweep, sweepErr := solarCentralShadowSweepPolygons(footprints); sweepErr == nil {
inputs = append(inputs, sweep...)
}
}
if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil {
if value, geometryErr := multiPolygonGeometry(
solarCentralBandWithCenterlineCorridor(merged, centerLine),
); geometryErr == nil {
return value, "paired-limits+central-shadow-sweep-union", nil
}
}
value, geometryErr := multiPolygonGeometry(
solarCentralBandWithCenterlineCorridor(inputs, centerLine),
)
if geometryErr != nil {
return geometry{}, "", geometryErr
}
return value, "paired-limits-fallback", nil
}
bandPolygons, source := solarCentralPathBandPolygons(northern, southern)
if len(bandPolygons) == 0 {
band, err := pairedLimitPolygon(northern, southern)
if err != nil {
return geometry{}, "", err
}
bandPolygons = [][]geodata.GeoPoint{band}
source = "paired-limits-fallback"
}
endpointCaps := solarCentralBandEndpointCaps(northern, southern, centerLine)
if len(horizonClosures) == 2 {
start, startOK := solarCentralBandHorizonTail(northern[0], southern[0], horizonClosures[0])
end, endOK := solarCentralBandHorizonTail(northern[len(northern)-1], southern[len(southern)-1], horizonClosures[1])
if startOK && endOK {
endpointCaps = [][]geodata.GeoPoint{start, end}
source += "+horizon-closures"
}
}
// Keep the hybrid transition sweep, but close its ends at the solved
// horizon limits rather than collapsing a finite-width shadow to the axis.
polygons := append(append([][]geodata.GeoPoint(nil), bandPolygons...), endpointCaps...)
if len(endpointCaps) > 0 {
if merged, mergeErr := geodata.UnionPolygons(polygons); mergeErr == nil {
polygons = merged
}
}
paired, geometryErr := multiPolygonGeometry(polygons)
if geometryErr != nil {
return geometry{}, "", geometryErr
}
return paired, source, nil
}
func solarCentralTwoLimitBandPolygons(
northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint,
footprints []eclipsecore.SolarEclipsePartialFootprint,
horizonClosures [][]eclipsecore.SolarEclipsePathPoint,
) ([][]geodata.GeoPoint, string, bool) {
// The map linework is deliberately trimmed to the center-line interval,
// while this geometry must retain the complete U1/U4 contact interval.
// MarshalSolarEclipse owns that presentation trim and always supplies the
// complete paired limits here.
north, south, ok := solarCentralTwoLimitPairedSamples(northern, southern)
if !ok {
return nil, "", false
}
north, south = solarCentralTwoLimitEnvelopeSamples(north, south)
middle, err := pairedLimitPolygon(north, south)
if err != nil {
return nil, "", false
}
if len(horizonClosures) == 2 {
startTail, startOK := solarCentralBandHorizonTail(north[0], south[0], horizonClosures[0])
endTail, endOK := solarCentralBandHorizonTail(
north[len(north)-1], south[len(south)-1], horizonClosures[1],
)
if startOK && endOK {
inputs := [][]geodata.GeoPoint{middle, startTail, endTail}
if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil && len(merged) == 1 &&
solarCentralBandRingsCover(merged, centerLine, footprints) {
return merged, "paired-limits+horizon-closures", true
}
}
}
// Prefer the paired-limit ribbon that is explicitly validated against the
// complete center line. End-sweep overlays can select a neighboring polar
// face and create an artificial narrow neck even when the ribbon itself is
// continuous.
// The ribbon is traced over the center-line interval only. Accept it when
// it really covers the complete umbral sweep; otherwise keep looking, since
// grazing events lose hundreds of kilometres of genuine umbral area here.
if merged, ok := solarCentralTwoLimitRibbonUnionPolygons(
north, south, centerLine, nil,
); ok && solarCentralBandRingsCover(merged, centerLine, footprints) {
return merged, "paired-limits-ribbon-union", true
}
if len(footprints) > 0 {
endSweeps, sweepErr := solarCentralMonotoneEndSweepPolygons(footprints)
if sweepErr == nil {
inputs := make([][]geodata.GeoPoint, 0, 1+len(endSweeps)+2)
inputs = append(inputs, middle)
inputs = append(inputs, endSweeps...)
inputs = append(inputs, solarCentralBandInnerTransitionCaps(footprints)...)
inputs = append(inputs, solarCentralBandContactCaps(
footprints, northern[0], northern[len(northern)-1],
)...)
if merged, mergeErr := geodata.UnionPolygons(inputs); mergeErr == nil && len(merged) == 1 &&
solarCentralBandRingsCover(merged, centerLine, footprints) {
return merged, "paired-limits+central-shadow-end-sweeps", true
}
if merged, ok := solarCentralTwoLimitRibbonUnionPolygons(
north, south, centerLine, inputs[1:],
); ok && solarCentralBandRingsCover(merged, centerLine, footprints) {
return merged, "paired-limits+central-shadow-ribbon-union", true
}
}
}
// The presentation limits above intentionally trim the two U1/U4 tails
// for ordinary maps. Near a pole those trimmed ribbon pieces can fold into
// hundreds of tiny triangles and lose the physical contact endpoints. Keep
// the complete paired limits as one spherical ring before using the final
// axis-cap fallback; multiPolygonGeometry performs the map split afterwards.
if fullBand, fullErr := pairedLimitPolygon(north, south); fullErr == nil {
if len(footprints) > 0 {
if sweep, sweepErr := solarCentralShadowSweepPolygons(footprints); sweepErr == nil &&
solarCentralBandRingsCover(sweep, centerLine, footprints) {
contacts := []geodata.GeoPoint{
{Longitude: northern[0].Longitude, Latitude: northern[0].Latitude},
{Longitude: northern[len(northern)-1].Longitude, Latitude: northern[len(northern)-1].Latitude},
}
if snapSolarCentralSweepContacts(sweep, contacts) {
return sweep, "central-shadow-complete-contact-fallback", true
}
return sweep, "central-shadow-complete-fallback", true
}
}
return [][]geodata.GeoPoint{fullBand}, "paired-limits-complete-fallback", true
}
if fullBand, fullErr := pairedLimitPolygon(north, south); fullErr == nil {
if pieces := solarCentralTwoLimitRibbonPieces(north, south, centerLine); len(pieces) > 0 {
return pieces, "paired-limits-ribbon-pieces-fallback", true
}
return [][]geodata.GeoPoint{fullBand}, "paired-limits-full-limit-fallback", true
}
band, ok := solarCentralTwoLimitAxisCappedPolygon(north, south, centerLine)
if !ok {
return nil, "", false
}
return [][]geodata.GeoPoint{band}, "paired-limits-axis-cap-fallback", true
}
func solarCentralTwoLimitPairedSamples(
northern, southern []eclipsecore.SolarEclipsePathPoint,
) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) {
if len(northern) < 2 || len(northern) != len(southern) {
return nil, nil, false
}
for index := range northern {
if northern[index].Time.IsZero() || southern[index].Time.IsZero() ||
!northern[index].Time.Equal(southern[index].Time) {
return nil, nil, false
}
if index > 0 && (!northern[index-1].Time.Before(northern[index].Time) ||
!southern[index-1].Time.Before(southern[index].Time)) {
return nil, nil, false
}
}
return northern, southern, true
}
// snapSolarCentralSweepContacts moves the nearest sampled sweep vertices onto
// the exact U1/U4 contact points. The shadow footprints start at the contact
// times, but their finite angular/time sampling can leave the exported vertex
// a few kilometres away. Snapping the existing vertices preserves the sweep
// components and avoids adding overlapping endpoint triangles.
func snapSolarCentralSweepContacts(polygons [][]geodata.GeoPoint, contacts []geodata.GeoPoint) bool {
if len(polygons) == 0 || len(contacts) == 0 {
return false
}
const maximumSnapDistanceKM = 500.0
used := make(map[[2]int]bool)
for _, contact := range contacts {
bestDistance := math.Inf(1)
bestPolygon, bestPoint := -1, -1
for polygonIndex, polygon := range polygons {
for pointIndex, point := range polygon {
if used[[2]int{polygonIndex, pointIndex}] {
continue
}
distance := solarCentralBandGeoPointDistanceKM(point, contact)
if distance < bestDistance {
bestDistance = distance
bestPolygon, bestPoint = polygonIndex, pointIndex
}
}
}
if bestPolygon < 0 || bestDistance > maximumSnapDistanceKM {
return false
}
polygons[bestPolygon][bestPoint] = contact
used[[2]int{bestPolygon, bestPoint}] = true
}
return true
}
// solarCentralTwoLimitRibbonPieces keeps the fallback as a collection of
// adjacent time-slice faces instead of one polar ring. Each slice is split
// around the interpolated center-line segment, so a limit branch crossing a
// pole or the antimeridian cannot create a bow-tie polygon.
func solarCentralTwoLimitRibbonPieces(
northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint,
) [][]geodata.GeoPoint {
if len(northern) < 2 || len(northern) != len(southern) {
return nil
}
pieces := make([][]geodata.GeoPoint, 0, len(northern)+2)
for index := 1; index < len(northern); index++ {
northPrevious := geodata.GeoPoint{
Longitude: northern[index-1].Longitude, Latitude: northern[index-1].Latitude,
}
northCurrent := geodata.GeoPoint{
Longitude: northern[index].Longitude, Latitude: northern[index].Latitude,
}
southCurrent := geodata.GeoPoint{
Longitude: southern[index].Longitude, Latitude: southern[index].Latitude,
}
southPrevious := geodata.GeoPoint{
Longitude: southern[index-1].Longitude, Latitude: southern[index-1].Latitude,
}
centerPrevious := solarCentralBandCenterPointAt(centerLine, northern[index-1].Time)
centerCurrent := solarCentralBandCenterPointAt(centerLine, northern[index].Time)
for _, piece := range [][]geodata.GeoPoint{
{northPrevious, northCurrent, centerCurrent},
{northPrevious, centerCurrent, centerPrevious},
{centerPrevious, centerCurrent, southCurrent},
{centerPrevious, southCurrent, southPrevious},
} {
if solarCentralBandGeoPointDistanceKM(piece[0], piece[1]) == 0 &&
solarCentralBandGeoPointDistanceKM(piece[1], piece[2]) == 0 {
continue
}
pieces = append(pieces, piece)
}
}
if len(centerLine) >= 2 {
pieces = append(pieces,
[]geodata.GeoPoint{
{Longitude: centerLine[0].Longitude, Latitude: centerLine[0].Latitude},
{Longitude: northern[0].Longitude, Latitude: northern[0].Latitude},
{Longitude: southern[0].Longitude, Latitude: southern[0].Latitude},
},
[]geodata.GeoPoint{
{Longitude: centerLine[len(centerLine)-1].Longitude, Latitude: centerLine[len(centerLine)-1].Latitude},
{Longitude: northern[len(northern)-1].Longitude, Latitude: northern[len(northern)-1].Latitude},
{Longitude: southern[len(southern)-1].Longitude, Latitude: southern[len(southern)-1].Latitude},
},
)
}
return pieces
}
func solarCentralBandCenterPointAt(
centerLine []eclipsecore.SolarEclipsePathPoint,
target time.Time,
) geodata.GeoPoint {
if len(centerLine) == 0 {
return geodata.GeoPoint{}
}
if !target.After(centerLine[0].Time) {
return geodata.GeoPoint{Longitude: centerLine[0].Longitude, Latitude: centerLine[0].Latitude}
}
for index := 1; index < len(centerLine); index++ {
if !target.After(centerLine[index].Time) {
previous, current := centerLine[index-1], centerLine[index]
span := current.Time.Sub(previous.Time)
if span <= 0 {
return geodata.GeoPoint{Longitude: current.Longitude, Latitude: current.Latitude}
}
fraction := float64(target.Sub(previous.Time)) / float64(span)
return solarCentralBandSphericalInterpolate(
geodata.GeoPoint{Longitude: previous.Longitude, Latitude: previous.Latitude},
geodata.GeoPoint{Longitude: current.Longitude, Latitude: current.Latitude},
fraction,
)
}
}
last := centerLine[len(centerLine)-1]
return geodata.GeoPoint{Longitude: last.Longitude, Latitude: last.Latitude}
}
func solarCentralBandHorizonTail(
north, south eclipsecore.SolarEclipsePathPoint,
source []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, bool) {
if len(source) < 2 {
return nil, false
}
closure := append([]eclipsecore.SolarEclipsePathPoint(nil), source...)
forwardDistance := solarCentralBandPathDistanceKM(north, closure[0]) +
solarCentralBandPathDistanceKM(south, closure[len(closure)-1])
reverseDistance := solarCentralBandPathDistanceKM(north, closure[len(closure)-1]) +
solarCentralBandPathDistanceKM(south, closure[0])
if reverseDistance < forwardDistance {
for left, right := 0, len(closure)-1; left < right; left, right = left+1, right-1 {
closure[left], closure[right] = closure[right], closure[left]
}
}
tail := make([]geodata.GeoPoint, 0, len(closure)+3)
tail = append(tail, geodata.GeoPoint{Longitude: north.Longitude, Latitude: north.Latitude})
for _, point := range closure {
tail = append(tail, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
tail = append(tail,
geodata.GeoPoint{Longitude: south.Longitude, Latitude: south.Latitude},
tail[0],
)
return tail, true
}
func solarCentralTwoLimitRibbonUnionPolygons(
north, south, centerLine []eclipsecore.SolarEclipsePathPoint,
overlays [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, bool) {
if len(north) < 2 || len(north) != len(south) || len(centerLine) < 2 {
return nil, false
}
ribbons := make([][]geodata.GeoPoint, 0, len(north)+2)
for index := 1; index < len(north); index++ {
ribbons = append(ribbons, []geodata.GeoPoint{
{Longitude: north[index-1].Longitude, Latitude: north[index-1].Latitude},
{Longitude: north[index].Longitude, Latitude: north[index].Latitude},
{Longitude: south[index].Longitude, Latitude: south[index].Latitude},
{Longitude: south[index-1].Longitude, Latitude: south[index-1].Latitude},
})
}
ribbons = append(ribbons,
[]geodata.GeoPoint{
{Longitude: centerLine[0].Longitude, Latitude: centerLine[0].Latitude},
{Longitude: north[0].Longitude, Latitude: north[0].Latitude},
{Longitude: south[0].Longitude, Latitude: south[0].Latitude},
},
[]geodata.GeoPoint{
{Longitude: centerLine[len(centerLine)-1].Longitude, Latitude: centerLine[len(centerLine)-1].Latitude},
{Longitude: north[len(north)-1].Longitude, Latitude: north[len(north)-1].Latitude},
{Longitude: south[len(south)-1].Longitude, Latitude: south[len(south)-1].Latitude},
},
)
paths := [][]geodata.GeoPoint{
solarCentralBandGeoPoints(north),
solarCentralBandGeoPoints(south),
solarCentralBandGeoPoints(centerLine),
}
for _, inputs := range [][][]geodata.GeoPoint{
append(append([][]geodata.GeoPoint(nil), ribbons...), overlays...),
ribbons,
} {
candidates := make([][][]geodata.GeoPoint, 0, 2)
if polygons, err := geodata.UnionPolygons(inputs); err == nil {
candidates = append(candidates, polygons)
}
// At a polar two-limit contact, longitude/latitude is a singular chart:
// adjacent time-slice quads can be valid on the sphere but appear to
// reverse around the pole in the global union. Retry the same faces in a
// local gnomonic chart before selecting a disconnected fallback.
if polygons, ok := solarCentralLocalChartUnion(inputs); ok {
candidates = append(candidates, polygons)
}
// Split each temporal quad at the center-line interpolation. This is
// topologically equivalent away from a pole, but prevents a quad's
// diagonal from selecting the wrong side when the two limits wrap around
// a polar chart branch.
if len(overlays) == 0 {
pieces := solarCentralTwoLimitRibbonPieces(north, south, centerLine)
if len(pieces) > 0 {
if polygons, ok := solarCentralLocalChartUnion(pieces); ok {
candidates = append(candidates, polygons)
}
}
}
for _, polygons := range candidates {
if len(polygons) == 0 || len(polygons) != 1 ||
geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false) > 1 {
continue
}
refined := make([][]geodata.GeoPoint, 0, len(polygons))
for _, polygon := range polygons {
refined = append(refined, solarCentralBandRefineRingSpacing(polygon, 200))
}
return refined, true
}
}
return nil, false
}
// solarCentralLocalChartUnion performs a boolean union in a local tangent
// chart. Coordinates are scaled before entering the planar union so the
// generic geodata union cannot mistake chart values for global latitudes.
func solarCentralLocalChartUnion(inputs [][]geodata.GeoPoint) ([][]geodata.GeoPoint, bool) {
if len(inputs) == 0 {
return nil, false
}
toVector := func(point geodata.GeoPoint) [3]float64 {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
return [3]float64{
cosLatitude * math.Cos(longitude),
cosLatitude * math.Sin(longitude),
math.Sin(latitude),
}
}
dot := func(first, second [3]float64) float64 {
return first[0]*second[0] + first[1]*second[1] + first[2]*second[2]
}
norm := func(value [3]float64) float64 {
return math.Sqrt(dot(value, value))
}
center := [3]float64{}
maximumAbsLatitude := 0.0
polarSign := 1.0
for _, polygon := range inputs {
for _, point := range openRing(polygon) {
if absLatitude := math.Abs(point.Latitude); absLatitude > maximumAbsLatitude {
maximumAbsLatitude = absLatitude
if point.Latitude < 0 {
polarSign = -1
} else {
polarSign = 1
}
}
vector := toVector(point)
center[0] += vector[0]
center[1] += vector[1]
center[2] += vector[2]
}
}
if maximumAbsLatitude >= 75 {
// A polar event is better conditioned in a chart centred on the pole
// than in the arithmetic mean of points whose longitudes wrap around it.
center = [3]float64{0, 0, polarSign}
} else {
centerNorm := norm(center)
if centerNorm <= 1e-12 {
return nil, false
}
center[0] /= centerNorm
center[1] /= centerNorm
center[2] /= centerNorm
}
globalNorth := [3]float64{0, 0, 1}
cross := func(first, second [3]float64) [3]float64 {
return [3]float64{
first[1]*second[2] - first[2]*second[1],
first[2]*second[0] - first[0]*second[2],
first[0]*second[1] - first[1]*second[0],
}
}
east := cross(globalNorth, center)
if norm(east) <= 1e-12 {
east = cross([3]float64{1, 0, 0}, center)
}
eastNorm := norm(east)
if eastNorm <= 1e-12 {
return nil, false
}
east[0] /= eastNorm
east[1] /= eastNorm
east[2] /= eastNorm
north := cross(center, east)
northNorm := norm(north)
if northNorm <= 1e-12 {
return nil, false
}
north[0] /= northNorm
north[1] /= northNorm
north[2] /= northNorm
const chartScale = 0.5
project := func(point geodata.GeoPoint) (geodata.GeoPoint, bool) {
vector := toVector(point)
denominator := dot(vector, center)
if denominator <= 0.02 {
return geodata.GeoPoint{}, false
}
return geodata.GeoPoint{
Longitude: chartScale * dot(vector, east) / denominator * 180 / math.Pi,
Latitude: chartScale * dot(vector, north) / denominator * 180 / math.Pi,
}, true
}
unproject := func(point geodata.GeoPoint) geodata.GeoPoint {
x := point.Longitude / chartScale * math.Pi / 180
y := point.Latitude / chartScale * math.Pi / 180
vector := [3]float64{
center[0] + x*east[0] + y*north[0],
center[1] + x*east[1] + y*north[1],
center[2] + x*east[2] + y*north[2],
}
length := norm(vector)
if length <= 1e-12 {
return geodata.GeoPoint{}
}
return geodata.GeoPoint{
Longitude: normalizeLongitude(math.Atan2(vector[1]/length, vector[0]/length) * 180 / math.Pi),
Latitude: math.Asin(math.Max(-1, math.Min(1, vector[2]/length))) * 180 / math.Pi,
}
}
projected := make([][]geodata.GeoPoint, len(inputs))
for polygonIndex, polygon := range inputs {
projected[polygonIndex] = make([]geodata.GeoPoint, len(polygon))
for pointIndex, point := range polygon {
value, ok := project(point)
if !ok {
return nil, false
}
projected[polygonIndex][pointIndex] = value
}
}
merged, err := geodata.UnionPolygons(projected)
if err != nil {
return nil, false
}
result := make([][]geodata.GeoPoint, len(merged))
for polygonIndex, polygon := range merged {
result[polygonIndex] = make([]geodata.GeoPoint, len(polygon))
for pointIndex, point := range polygon {
result[polygonIndex][pointIndex] = unproject(point)
}
}
return result, true
}
func solarCentralBandGeoPoints(points []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint {
result := make([]geodata.GeoPoint, len(points))
for index, point := range points {
result[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
return result
}
func solarCentralBandRefineRingSpacing(points []geodata.GeoPoint, targetSpacingKM float64) []geodata.GeoPoint {
points = openRing(points)
if len(points) < 2 || targetSpacingKM <= 0 {
return append([]geodata.GeoPoint(nil), points...)
}
result := make([]geodata.GeoPoint, 0, len(points))
for index, start := range points {
end := points[(index+1)%len(points)]
result = append(result, start)
steps := int(math.Ceil(solarCentralBandGeoPointDistanceKM(start, end) / targetSpacingKM))
for step := 1; step < steps; step++ {
result = append(result, solarCentralBandSphericalInterpolate(
start, end, float64(step)/float64(steps),
))
}
}
return result
}
func solarCentralBandSphericalInterpolate(
first, second geodata.GeoPoint,
fraction float64,
) geodata.GeoPoint {
toVector := func(point geodata.GeoPoint) [3]float64 {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
return [3]float64{
cosLatitude * math.Cos(longitude),
cosLatitude * math.Sin(longitude),
math.Sin(latitude),
}
}
firstVector, secondVector := toVector(first), toVector(second)
dot := math.Max(-1, math.Min(1,
firstVector[0]*secondVector[0]+firstVector[1]*secondVector[1]+firstVector[2]*secondVector[2],
))
angle := math.Acos(dot)
if angle <= 1e-12 {
return first
}
firstWeight := math.Sin((1-fraction)*angle) / math.Sin(angle)
secondWeight := math.Sin(fraction*angle) / math.Sin(angle)
x := firstWeight*firstVector[0] + secondWeight*secondVector[0]
y := firstWeight*firstVector[1] + secondWeight*secondVector[1]
z := firstWeight*firstVector[2] + secondWeight*secondVector[2]
return geodata.GeoPoint{
Longitude: normalizeLongitude(math.Atan2(y, x) * 180 / math.Pi),
Latitude: math.Atan2(z, math.Hypot(x, y)) * 180 / math.Pi,
}
}
func solarCentralBandInnerTransitionCaps(
footprints []eclipsecore.SolarEclipsePartialFootprint,
) [][]geodata.GeoPoint {
samples, err := solarCentralShadowSweepSamples(footprints)
if err != nil {
return nil
}
samples = geodata.DecimateOpenBoundarySweepSamples(samples, len(samples), 40)
return geodata.OpenBoundarySweepInnerCaps(samples, 500)
}
func solarCentralBandContactCaps(
footprints []eclipsecore.SolarEclipsePartialFootprint,
startContact, endContact eclipsecore.SolarEclipsePathPoint,
) [][]geodata.GeoPoint {
if len(footprints) == 0 {
return nil
}
caps := make([][]geodata.GeoPoint, 0, 2)
appendCap := func(contact eclipsecore.SolarEclipsePathPoint, footprint eclipsecore.SolarEclipsePartialFootprint) {
segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
for _, source := range footprint.Boundaries {
segment := make([]geodata.GeoPoint, len(source))
for index, point := range source {
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, segment)
}
boundary := openRing(geodata.JoinPolylineSegments(segments))
if len(boundary) < 2 {
return
}
contactPoint := geodata.GeoPoint{Longitude: contact.Longitude, Latitude: contact.Latitude}
if solarCentralBandGeoPointDistanceKM(contactPoint, boundary[0]) > 2000 ||
solarCentralBandGeoPointDistanceKM(contactPoint, boundary[len(boundary)-1]) > 2000 {
return
}
caps = append(caps, []geodata.GeoPoint{contactPoint, boundary[0], boundary[len(boundary)-1]})
}
appendCap(startContact, footprints[0])
appendCap(endContact, footprints[len(footprints)-1])
return caps
}
func solarCentralBandGeoPointDistanceKM(first, second geodata.GeoPoint) float64 {
lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180
dlat := lat2 - lat1
dlon := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi)
h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2)
return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h))))
}
func solarCentralTwoLimitAxisCappedPolygon(
north, south, centerLine []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, bool) {
if len(north) < 2 || len(north) != len(south) || len(centerLine) < 2 {
return nil, false
}
polygon := make([]geodata.GeoPoint, 0, len(north)+len(south)+2)
polygon = append(polygon, geodata.GeoPoint{
Longitude: centerLine[0].Longitude,
Latitude: centerLine[0].Latitude,
})
for _, point := range north {
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
lastCenter := centerLine[len(centerLine)-1]
polygon = append(polygon, geodata.GeoPoint{
Longitude: lastCenter.Longitude,
Latitude: lastCenter.Latitude,
})
for index := len(south) - 1; index >= 0; index-- {
point := south[index]
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
return polygon, true
}
// The public limit series keeps the earlier/later U1/U4 contacts on both
// sides. For map rendering, the axis contacts are the canonical band caps;
// retaining both pairs creates two overlapping triangles at each horizon.
func solarCentralTwoLimitPresentationLimits(
northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint,
) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) {
if len(northern) != len(southern) || len(northern) < 4 || len(centerLine) < 2 {
return nil, nil, false
}
start := centerLine[0].Time
end := centerLine[len(centerLine)-1].Time
if start.IsZero() || end.IsZero() || !start.Before(end) ||
!northern[0].Time.Before(start) || !northern[len(northern)-1].Time.After(end) ||
solarCentralBandPointDistanceKM(northern[0], southern[0]) > 0.001 ||
solarCentralBandPointDistanceKM(northern[len(northern)-1], southern[len(southern)-1]) > 0.001 {
return nil, nil, false
}
first := 0
for first < len(northern) && !northern[first].Time.After(start) {
first++
}
last := first
for last < len(northern) && northern[last].Time.Before(end) {
last++
}
if first == 0 || last >= len(northern) || last-first < 2 {
return nil, nil, false
}
for index := first; index < last; index++ {
if northern[index].Time.IsZero() || !northern[index].Time.Equal(southern[index].Time) {
return nil, nil, false
}
}
return northern[first:last], southern[first:last], true
}
func solarCentralPathBandPolygons(
northern, southern []eclipsecore.SolarEclipsePathPoint,
) ([][]geodata.GeoPoint, string) {
if len(northern) < 2 || len(northern) != len(southern) {
return nil, ""
}
samples := make([]geodata.OpenBoundarySweepSample, 0, len(northern))
for index := range northern {
if northern[index].Time.IsZero() || !northern[index].Time.Equal(southern[index].Time) {
return nil, ""
}
samples = append(samples, geodata.OpenBoundarySweepSample{
Boundaries: [][]geodata.GeoPoint{{
{Longitude: northern[index].Longitude, Latitude: northern[index].Latitude},
{Longitude: southern[index].Longitude, Latitude: southern[index].Latitude},
}},
})
}
polygons, err := geodata.OpenBoundarySweep(samples)
if err != nil {
return nil, ""
}
usable := make([][]geodata.GeoPoint, 0, len(polygons))
for _, polygon := range polygons {
if len(openRing(polygon)) >= 3 {
usable = append(usable, polygon)
}
}
if len(usable) == 0 {
return nil, ""
}
return usable, "central-cross-section-sweep"
}
func solarCentralBandEndpointCaps(
northern, southern, centerLine []eclipsecore.SolarEclipsePathPoint,
) [][]geodata.GeoPoint {
if len(northern) == 0 || len(northern) != len(southern) || len(centerLine) == 0 {
return nil
}
caps := make([][]geodata.GeoPoint, 0, 2)
appendCap := func(center eclipsecore.SolarEclipsePathPoint, atStart bool) {
limitIndex := -1
if atStart {
for index := range northern {
if northern[index].Time.After(center.Time) {
limitIndex = index
break
}
}
} else {
for index := len(northern) - 1; index >= 0; index-- {
if northern[index].Time.Before(center.Time) {
limitIndex = index
break
}
}
}
if limitIndex < 0 {
return
}
north, south := northern[limitIndex], southern[limitIndex]
if center.Time.IsZero() || north.Time.IsZero() || south.Time.IsZero() {
return
}
if solarCentralBandPointDistanceKM(center, north) > 3000 || solarCentralBandPointDistanceKM(center, south) > 3000 {
return
}
caps = append(caps, []geodata.GeoPoint{
{Longitude: center.Longitude, Latitude: center.Latitude},
{Longitude: north.Longitude, Latitude: north.Latitude},
{Longitude: south.Longitude, Latitude: south.Latitude},
})
}
appendCap(centerLine[0], true)
appendCap(centerLine[len(centerLine)-1], false)
return caps
}
func solarCentralBandPointDistanceKM(first, second eclipsecore.SolarEclipsePathPoint) float64 {
lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180
dlat := lat2 - lat1
dlon := math.Mod((second.Longitude-first.Longitude)*math.Pi/180+math.Pi, 2*math.Pi) - math.Pi
h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2)
return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h))))
}
// Near a high-latitude apex a traced limit curve runs through a cusp: its time
// parameterisation folds back on itself, so the ribbon ring built from the two
// limits crosses itself and the exported band twists. The swept region is
// bounded by the envelope, so the samples inside the fold are dropped from both
// sides together — the pairs stay time-aligned, only the fold disappears.
const solarCentralTwoLimitFoldToleranceDegrees = 0.05
func solarCentralTwoLimitEnvelopeSamples(
northern, southern []eclipsecore.SolarEclipsePathPoint,
) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint) {
drop := make(map[int]bool)
solarCentralTwoLimitMarkFolds(northern, drop)
solarCentralTwoLimitMarkFolds(southern, drop)
if len(drop) == 0 || len(drop) >= len(northern)-2 {
return northern, southern
}
keptNorth := make([]eclipsecore.SolarEclipsePathPoint, 0, len(northern)-len(drop))
keptSouth := make([]eclipsecore.SolarEclipsePathPoint, 0, len(southern)-len(drop))
for index := range northern {
if drop[index] {
continue
}
keptNorth = append(keptNorth, northern[index])
keptSouth = append(keptSouth, southern[index])
}
return keptNorth, keptSouth
}
func solarCentralTwoLimitMarkFolds(limits []eclipsecore.SolarEclipsePathPoint, drop map[int]bool) {
if len(limits) < 4 {
return
}
// Project the curve onto its end-to-end tangent. Longitude alone is
// degenerate at polar apices and cannot distinguish a real turn from an
// antimeridian wrap; the local east component is scaled by latitude and the
// north component is retained, so both cusp types are detected.
lat0 := limits[0].Latitude * math.Pi / 180
dlon := math.Mod((limits[len(limits)-1].Longitude-limits[0].Longitude)+180, 360) - 180
dx, dy := dlon*math.Cos(lat0), limits[len(limits)-1].Latitude-limits[0].Latitude
length := math.Hypot(dx, dy)
if length <= 1e-9 {
return
}
dx, dy = dx/length, dy/length
extreme := 0.0
for index, point := range limits {
deltaLon := math.Mod((point.Longitude-limits[0].Longitude)+180, 360) - 180
delta := deltaLon*math.Cos(lat0)*dx + (point.Latitude-limits[0].Latitude)*dy
if index == 0 {
extreme = delta
continue
}
if delta-extreme < -solarCentralTwoLimitFoldToleranceDegrees {
drop[index] = true
continue
}
if delta > extreme {
extreme = delta
}
}
}
func pairedLimitPolygon(
northern, southern []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, error) {
if len(northern) != len(southern) {
return nil, fmt.Errorf("paired limits must have the same sample count")
}
count := len(northern)
if count < 2 {
return nil, fmt.Errorf("paired limits require at least two points per side")
}
for index := range northern {
if northern[index].Time.IsZero() || southern[index].Time.IsZero() {
return nil, fmt.Errorf("paired limit sample %d time is required", index)
}
if !northern[index].Time.Equal(southern[index].Time) {
return nil, fmt.Errorf("paired limit sample %d times must match", index)
}
}
polygon := make([]geodata.GeoPoint, 0, 2*count)
for _, point := range northern[:count] {
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
for index := count - 1; index >= 0; index-- {
point := southern[index]
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
return polygon, nil
}
func appendSolarPathLine(
features []feature,
role string,
points []eclipsecore.SolarEclipsePathPoint,
properties map[string]interface{},
) ([]feature, error) {
samples := make([]pathSample, len(points))
for index, point := range points {
samples[index] = solarPathSample(point)
}
return appendTimedLineFeature(features, solarEclipseEvent, role, samples, properties)
}
func appendSolarSegmentedPathLine(
features []feature,
role string,
segments [][]eclipsecore.SolarEclipsePathPoint,
properties map[string]interface{},
requireIncreasingTimes bool,
) ([]feature, error) {
samples := make([][]pathSample, len(segments))
for index, segment := range segments {
samples[index] = sampleSphericalMapPath(solarPathSamples(segment))
}
value, times, err := timedMultiLineGeometryFromSegmentsWithTimeOrder(samples, requireIncreasingTimes)
if err != nil {
return nil, fmt.Errorf("geojson: %s: %w", role, err)
}
properties = cloneProperties(properties)
properties["times"] = times
return append(features, newFeature(solarEclipseEvent, role, value, properties)), nil
}
func appendSolarRiseSetCurveFeatures(
features []feature,
curves []eclipsecore.SolarEclipseRiseSetCurve,
properties map[string]interface{},
) ([]feature, error) {
for _, curve := range curves {
curveProperties := cloneProperties(properties)
curveProperties["phase"] = string(curve.Phase)
curveProperties["horizon"] = string(curve.Direction)
curveProperties["body"] = "sun"
var err error
features, err = appendSolarSegmentedPathLine(
features, "visibility-boundary", curve.Segments, curveProperties, true,
)
if err != nil {
return nil, err
}
}
return features, nil
}
func solarPathSamples(points []eclipsecore.SolarEclipsePathPoint) []pathSample {
samples := make([]pathSample, len(points))
for index, point := range points {
samples[index] = solarPathSample(point)
}
return samples
}
func solarPathSample(point eclipsecore.SolarEclipsePathPoint) pathSample {
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
}
func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interface{} {
properties := map[string]interface{}{
"eclipse_type": string(info.Type),
"model": string(info.Model),
"centrality": string(info.Centrality),
"magnitude": info.Magnitude,
"gamma": info.Gamma,
"path_width_km": info.PathWidthKM,
"partial_begin_on_earth": formatTime(info.PartialBeginOnEarth),
"partial_end_on_earth": formatTime(info.PartialEndOnEarth),
"central_begin_on_earth": formatTime(info.CentralBeginOnEarth),
"central_end_on_earth": formatTime(info.CentralEndOnEarth),
}
if info.CentralDuration > 0 {
// The catalogued maximum duration of the central phase, measured at the
// greatest eclipse point.
properties["central_duration_seconds"] = info.CentralDuration.Seconds()
}
if info.HasCentral {
properties["central_duration"] = info.CentralDuration.String()
}
return properties
}
// dropDegenerateMultiPolygonRings 删除已经没有面积的面(顶点少于三个互不相同的点,或平面
// 面积为零),保留其余面;全部退化时原样返回,避免把"没有有效环"变成导出失败。
// dropDegenerateMultiPolygonRings removes polygons with no area left (fewer than three
// distinct vertices, or zero planar area) while keeping the rest. When every polygon is
// degenerate the value is returned unchanged so an empty result never becomes an export error.
func dropDegenerateMultiPolygonRings(value geometry) geometry {
polygons, ok := value.Coordinates.([][][][]float64)
if !ok {
return value
}
kept := make([][][][]float64, 0, len(polygons))
for _, polygon := range polygons {
hasArea := false
for _, ring := range polygon {
if !degenerateGeoJSONRing(ring) {
hasArea = true
break
}
}
if hasArea {
kept = append(kept, polygon)
}
}
if len(kept) == 0 || len(kept) == len(polygons) {
return value
}
return geometry{Type: value.Type, Coordinates: kept}
}
// degenerateGeoJSONRing 判断导出环是否已经没有面积。
// degenerateGeoJSONRing reports whether an exported ring has no area left.
func degenerateGeoJSONRing(ring [][]float64) bool {
distinct := 0
for index, point := range ring {
if len(point) < 2 {
continue
}
if index == 0 || !sameDegenerateRingPoint(ring[index-1], point) {
distinct++
}
}
if distinct > 1 && sameDegenerateRingPoint(ring[0], ring[len(ring)-1]) {
distinct--
}
if distinct < 3 {
return true
}
area := 0.0
for index := range ring {
next := ring[(index+1)%len(ring)]
if len(ring[index]) < 2 || len(next) < 2 {
return false
}
area += ring[index][0]*next[1] - next[0]*ring[index][1]
}
return math.Abs(area/2) < 1e-12
}
// sameDegenerateRingPoint 比较同一环上的两个导出点。
// sameDegenerateRingPoint compares two exported points of one ring.
func sameDegenerateRingPoint(first, second []float64) bool {
if len(first) < 2 || len(second) < 2 {
return false
}
return math.Abs(math.Remainder(first[0]-second[0], 360)) < 1e-9 &&
math.Abs(first[1]-second[1]) < 1e-9
}
func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interface{} {
return map[string]interface{}{
"eclipse_type": string(info.Type),
"penumbral_magnitude": info.PenumbralMagnitude,
"umbral_magnitude": info.UmbralMagnitude,
"penumbral_start": formatTime(info.PenumbralStart),
"partial_start": formatTime(info.PartialStart),
"total_start": formatTime(info.TotalStart),
"total_end": formatTime(info.TotalEnd),
"partial_end": formatTime(info.PartialEnd),
"penumbral_end": formatTime(info.PenumbralEnd),
}
}
func solarSubsolarPoint(value time.Time) geodata.GeoPoint {
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}
func lunarSubpoint(value time.Time) geodata.GeoPoint {
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
ra, dec := basic.HMoonTrueRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}
func lunarEclipseTimeMarkerSamples(
info eclipsecore.LunarEclipseInfo,
options TimeMarkerOptions,
) ([]pathSample, error) {
step, err := normalizeTimeMarkerStep(options.Step)
if err != nil {
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
}
location := normalizeTimeMarkerLocation(options.Location)
start, end := info.PenumbralStart, info.PenumbralEnd
capacity, err := timeMarkerCapacity(start, end, step, location)
if err != nil {
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
}
current := firstTimeMarkerAfter(start, step, location)
markers := make([]pathSample, 0, capacity)
for current.Before(end) {
point := lunarSubpoint(current)
markers = append(markers, pathSample{
Time: current,
Longitude: point.Longitude,
Latitude: point.Latitude,
})
current = current.Add(step)
}
return markers, nil
}
func normalizeLunarBoundaryPoints(value int) int {
if value <= 0 {
return defaultLunarBoundaryPoints
}
if value < minimumLunarBoundaryPoints {
return minimumLunarBoundaryPoints
}
if value > maximumLunarBoundaryPoints {
return maximumLunarBoundaryPoints
}
return value
}