2026-08-06 12:00:56 +08:00
// Package geojson 将日月食和月掩地理结果编码为 RFC 7946 GeoJSON FeatureCollections。
// 坐标是 WGS84 经度和纬度,单位为度;地图投影和样式由应用处理。
// Package geojson encodes eclipse and lunar-occultation geographic results as RFC 7946 GeoJSON FeatureCollections.
// Coordinates are WGS84 longitude and latitude in degrees; map projection and styling remain application concerns.
//
// 每个要素都包含 event 和 role 属性。带时间的 MultiLineString 要素还包含与坐标段对齐的嵌套 times 数组。
// Times 编码为 UTC RFC 3339 字符串;路径采样由上游日月食和月掩选项控制后再传入本包。
// WithTimeMarkers 变体还会追加 role 为 time-marker 的 Point 要素,标签按请求地点格式化。
2026-09-17 12:27:40 +08:00
// 月掩 fallback 掩带通常为 MultiPolygon;支路跳变留下的孤立零时长截面以 MultiLineString 表达,
// 与连续扫掠同时存在时使用 GeometryCollection,避免用虚假多边形重新连接跳变。
2026-08-06 12:00:56 +08:00
// Every feature has event and role properties. Timed MultiLineString features also contain a nested times array aligned with their coordinate segments.
// Times are encoded as UTC RFC 3339 strings. Path sampling is controlled by the source eclipse and occultation options before values reach this package.
// The WithTimeMarkers variants additionally append Point Features whose role is time-marker and whose label is formatted for the requested location.
2026-09-17 12:27:40 +08:00
// Fallback occultation bands normally use MultiPolygon. Isolated zero-duration sections left by branch changes use MultiLineString,
// combined with a continuous sweep in a GeometryCollection, so discontinuities are never reconnected by a false polygon.
2026-08-06 12:00:56 +08:00
package geojson
import (
"encoding/json"
"fmt"
"math"
"time"
"b612.me/astro/internal/geodata"
)
const (
featureCollectionType = "FeatureCollection"
minimumTimeMarkerStep = time . Minute
maximumTimeMarkerCount = 1440
)
type featureCollection struct {
Type string `json:"type"`
Features [] feature `json:"features"`
}
type feature struct {
Type string `json:"type"`
Properties map [ string ] interface {} `json:"properties"`
Geometry geometry `json:"geometry"`
}
type geometry struct {
Type string `json:"type"`
2026-09-17 12:27:40 +08:00
Coordinates interface {} `json:"coordinates,omitempty"`
Geometries [] geometry `json:"geometries,omitempty"`
2026-08-06 12:00:56 +08:00
}
type pathSample struct {
Time time . Time
Longitude float64
Latitude float64
}
// TimeMarkerOptions 控制供地图客户端绘制路径时间标签的可选 Point Feature。
// TimeMarkerOptions controls optional Point Features used by map clients to draw time labels along a moving event path.
// Step 控制标记间隔;零值使用 30 分钟,并对齐到下一个本地整点边界。
// Step controls the marker interval; zero uses 30 minutes and aligns markers to the next local clock boundary.
// Location 控制 HH:MM 标签,默认 UTC;底层 time 属性仍为 UTC RFC 3339。
// Location controls the HH:MM label and defaults to UTC. The underlying time property remains UTC RFC 3339.
// 正 Step 至少为一分钟,单次导出最多 1440 个标记。
// Positive Step values must be at least one minute, and one export is limited to 1440 markers.
type TimeMarkerOptions struct {
// Step 是时间标记之间的间隔;零值使用 30 分钟。
// Step is the interval between time markers; zero uses 30 minutes.
Step time . Duration
// Location 是格式化 HH:MM 标签时使用的时区;nil 使用 UTC。
// Location is the timezone used to format HH:MM labels; nil uses UTC.
Location * time . Location
}
func marshalFeatureCollection ( features [] feature ) ([] byte , error ) {
if len ( features ) == 0 {
return nil , fmt . Errorf ( "geojson: no geographic features" )
}
2026-09-17 12:27:40 +08:00
return encodeFeatureCollection ( features )
}
func marshalEmptyFeatureCollection () ([] byte , error ) {
return encodeFeatureCollection ( make ([] feature , 0 ))
}
func encodeFeatureCollection ( features [] feature ) ([] byte , error ) {
2026-08-06 12:00:56 +08:00
value , err := json . Marshal ( featureCollection { Type : featureCollectionType , Features : features })
if err != nil {
return nil , fmt . Errorf ( "geojson: encode feature collection: %w" , err )
}
return value , nil
}
func newFeature ( event , role string , value geometry , properties map [ string ] interface {}) feature {
if properties == nil {
properties = make ( map [ string ] interface {})
}
properties [ "event" ] = event
properties [ "role" ] = role
return feature { Type : "Feature" , Properties : properties , Geometry : value }
}
func appendTimedLineFeature (
features [] feature ,
event , role string ,
points [] pathSample ,
properties map [ string ] interface {},
) ([] feature , error ) {
value , times , err := timedMultiLineGeometry ( points )
if err != nil {
return nil , fmt . Errorf ( "geojson: %s: %w" , role , err )
}
properties = cloneProperties ( properties )
properties [ "times" ] = times
return append ( features , newFeature ( event , role , value , properties )), nil
}
func appendPointFeature (
features [] feature ,
event , role string ,
point pathSample ,
properties map [ string ] interface {},
) ([] feature , error ) {
if point . Time . IsZero () {
return nil , fmt . Errorf ( "geojson: %s: point time is required" , role )
}
value , err := pointGeometry ( point . Longitude , point . Latitude )
if err != nil {
return nil , fmt . Errorf ( "geojson: %s: %w" , role , err )
}
properties = cloneProperties ( properties )
properties [ "time" ] = formatTime ( point . Time )
return append ( features , newFeature ( event , role , value , properties )), nil
}
func appendTimeMarkerFeatures (
features [] feature ,
event , sourceRole string ,
points [] pathSample ,
options TimeMarkerOptions ,
) ([] feature , error ) {
markers , err := timeMarkerPoints ( points , options )
if err != nil {
return nil , fmt . Errorf ( "geojson: %s time markers: %w" , sourceRole , err )
}
return appendTimeMarkerPointFeatures ( features , event , sourceRole , markers , options . Location )
}
func validateTimeMarkerOptions ( options TimeMarkerOptions ) error {
if _ , err := normalizeTimeMarkerStep ( options . Step ); err != nil {
return fmt . Errorf ( "geojson: time markers: %w" , err )
}
return nil
}
func appendTimeMarkerPointFeatures (
features [] feature ,
event , sourceRole string ,
markers [] pathSample ,
location * time . Location ,
) ([] feature , error ) {
location = normalizeTimeMarkerLocation ( location )
for _ , marker := range markers {
properties := map [ string ] interface {}{
"source_role" : sourceRole ,
"label" : marker . Time . In ( location ). Format ( "15:04" ),
}
var err error
features , err = appendPointFeature ( features , event , "time-marker" , marker , properties )
if err != nil {
return nil , err
}
}
return features , nil
}
func timeMarkerPoints ( points [] pathSample , options TimeMarkerOptions ) ([] pathSample , error ) {
step , err := normalizeTimeMarkerStep ( options . Step )
if err != nil {
return nil , err
}
if len ( points ) < 2 {
return nil , nil
}
location := normalizeTimeMarkerLocation ( options . Location )
for index , point := range points {
if point . Time . IsZero () {
return nil , fmt . Errorf ( "sample %d has a zero time" , index )
}
if err := validateCoordinate ( point . Longitude , point . Latitude ); err != nil {
return nil , fmt . Errorf ( "sample %d: %w" , index , err )
}
if index > 0 && ! point . Time . After ( points [ index - 1 ]. Time ) {
return nil , fmt . Errorf ( "sample times must be strictly increasing" )
}
}
start , end := points [ 0 ]. Time , points [ len ( points ) - 1 ]. Time
capacity , err := timeMarkerCapacity ( start , end , step , location )
if err != nil {
return nil , err
}
current := firstTimeMarkerAfter ( start , step , location )
markers := make ([] pathSample , 0 , capacity )
segment := 1
for current . Before ( end ) {
for segment < len ( points ) && points [ segment ]. Time . Before ( current ) {
segment ++
}
if segment >= len ( points ) {
break
}
a , b := points [ segment - 1 ], points [ segment ]
span := b . Time . Sub ( a . Time )
if span > 0 {
fraction := float64 ( current . Sub ( a . Time )) / float64 ( span )
markers = append ( markers , interpolateTimeMarker ( a , b , fraction , current ))
}
current = current . Add ( step )
}
return markers , nil
}
func normalizeTimeMarkerStep ( value time . Duration ) ( time . Duration , error ) {
if value < 0 {
return 0 , fmt . Errorf ( "step must be zero or positive" )
}
if value == 0 {
return 30 * time . Minute , nil
}
if value < minimumTimeMarkerStep {
return 0 , fmt . Errorf ( "step must be at least %s" , minimumTimeMarkerStep )
}
return value , nil
}
func timeMarkerCapacity ( start , end time . Time , step time . Duration , location * time . Location ) ( int , error ) {
if ! end . After ( start ) {
return 0 , fmt . Errorf ( "marker time range must be strictly increasing" )
}
first := firstTimeMarkerAfter ( start , step , normalizeTimeMarkerLocation ( location ))
if ! first . Before ( end ) {
return 0 , nil
}
count := 1 + ( end . Sub ( first ) - time . Nanosecond ) / step
if count > maximumTimeMarkerCount {
return 0 , fmt . Errorf ( "time marker count %d exceeds limit %d" , count , maximumTimeMarkerCount )
}
return int ( count ), nil
}
func firstTimeMarkerAfter ( value time . Time , step time . Duration , location * time . Location ) time . Time {
local := value . In ( location )
dayStart := time . Date ( local . Year (), local . Month (), local . Day (), 0 , 0 , 0 , 0 , location )
elapsed := local . Sub ( dayStart )
return dayStart . Add (( elapsed / step + 1 ) * step )
}
func interpolateTimeMarker ( a , b pathSample , fraction float64 , value time . Time ) pathSample {
deltaLongitude := b . Longitude - a . Longitude
if deltaLongitude > 180 {
deltaLongitude -= 360
} else if deltaLongitude < - 180 {
deltaLongitude += 360
}
return pathSample {
Time : value ,
Longitude : normalizeLongitude ( a . Longitude + fraction * deltaLongitude ),
Latitude : a . Latitude + fraction * ( b . Latitude - a . Latitude ),
}
}
func normalizeTimeMarkerLocation ( location * time . Location ) * time . Location {
if location == nil {
return time . UTC
}
return location
}
func cloneProperties ( source map [ string ] interface {}) map [ string ] interface {} {
result := make ( map [ string ] interface {}, len ( source ) + 2 )
for key , value := range source {
result [ key ] = value
}
return result
}
func pointGeometry ( longitude , latitude float64 ) ( geometry , error ) {
if err := validateCoordinate ( longitude , latitude ); err != nil {
return geometry {}, err
}
return geometry { Type : "Point" , Coordinates : [] float64 { longitude , latitude }}, nil
}
func timedMultiLineGeometry ( points [] pathSample ) ( geometry , [][] string , error ) {
2026-09-17 12:27:40 +08:00
return timedMultiLineGeometryFromSegments ([][] pathSample { points })
}
func timedMultiLineGeometryFromSegments ( sourceSegments [][] pathSample ) ( geometry , [][] string , error ) {
return timedMultiLineGeometryFromSegmentsWithTimeOrder ( sourceSegments , true )
}
func timedMultiLineGeometryFromSegmentsWithTimeOrder (
sourceSegments [][] pathSample ,
requireIncreasingTimes bool ,
) ( geometry , [][] string , error ) {
coordinates := make ([][][] float64 , 0 , len ( sourceSegments ))
times := make ([][] string , 0 , len ( sourceSegments ))
for _ , source := range sourceSegments {
if len ( source ) == 0 {
continue
2026-08-06 12:00:56 +08:00
}
2026-09-17 12:27:40 +08:00
if len ( source ) == 1 {
point := source [ 0 ]
if point . Time . IsZero () {
return geometry {}, nil , fmt . Errorf ( "geojson: timed line contains a zero time" )
}
if err := validateCoordinate ( point . Longitude , point . Latitude ); err != nil {
return geometry {}, nil , err
}
position := [] float64 { point . Longitude , point . Latitude }
formattedTime := formatTime ( point . Time )
coordinates = append ( coordinates , [][] float64 { position , append ([] float64 ( nil ), position ... )})
times = append ( times , [] string { formattedTime , formattedTime })
continue
}
segments , err := splitTimedLine ( source , requireIncreasingTimes )
if err != nil {
return geometry {}, nil , err
}
for _ , segment := range segments {
line := make ([][] float64 , len ( segment ))
lineTimes := make ([] string , len ( segment ))
for index , point := range segment {
line [ index ] = [] float64 { point . Longitude , point . Latitude }
lineTimes [ index ] = formatTime ( point . Time )
}
coordinates = append ( coordinates , line )
times = append ( times , lineTimes )
}
}
if len ( coordinates ) == 0 {
return geometry {}, nil , fmt . Errorf ( "geojson: line has no valid segments" )
2026-08-06 12:00:56 +08:00
}
return geometry { Type : "MultiLineString" , Coordinates : coordinates }, times , nil
}
func geoMultiLineGeometry ( points [] geodata . GeoPoint , closeLine bool ) ( geometry , error ) {
if len ( points ) < 2 {
return geometry {}, fmt . Errorf ( "geojson: line requires at least two points" )
}
for _ , point := range points {
if err := validateCoordinate ( point . Longitude , point . Latitude ); err != nil {
return geometry {}, err
}
}
geographic := append ([] geodata . GeoPoint ( nil ), points ... )
if closeLine && ! geodata . SameGeoPoint ( geographic [ 0 ], geographic [ len ( geographic ) - 1 ]) {
geographic = append ( geographic , geographic [ 0 ])
}
2026-09-17 12:27:40 +08:00
segments := geodata . PolylineSegments ( geographic , geodata . ClipView { Projection : geodata . ProjectionEquirectangular })
2026-08-06 12:00:56 +08:00
coordinates := make ([][][] float64 , 0 , len ( segments ))
for _ , segment := range segments {
if len ( segment ) < 2 {
continue
}
line := make ([][] float64 , len ( segment ))
for index , point := range segment {
line [ index ] = [] float64 { point . Longitude , point . Latitude }
}
coordinates = append ( coordinates , line )
}
if len ( coordinates ) == 0 {
return geometry {}, fmt . Errorf ( "geojson: line has no valid segments" )
}
return geometry { Type : "MultiLineString" , Coordinates : coordinates }, nil
}
2026-09-17 12:27:40 +08:00
// multiPolygonFillGeometry tags fill-only polygons, which tolerate a coarser
// map chord bound than the path strokes.
func multiPolygonFillGeometry ( polygons [][] geodata . GeoPoint ) ( geometry , error ) {
return multiPolygonGeometryWithin (
polygons , sphericalFillChordLimitKM , sphericalFillChordErrorDegrees ,
)
}
2026-08-06 12:00:56 +08:00
func multiPolygonGeometry ( polygons [][] geodata . GeoPoint ) ( geometry , error ) {
2026-09-17 12:27:40 +08:00
return multiPolygonGeometryWithin (
polygons , sphericalMapChordLimitKM , sphericalMapChordErrorDegrees ,
)
}
func multiPolygonGeometryWithin (
polygons [][] geodata . GeoPoint ,
chordLimitKM , chordErrorDegrees float64 ,
) ( geometry , error ) {
2026-08-06 12:00:56 +08:00
fragments := make ([][] geodata . GeoPoint , 0 , len ( polygons ))
for index , polygon := range polygons {
polygon = openRing ( polygon )
if len ( polygon ) < 3 {
return geometry {}, fmt . Errorf ( "geojson: polygon %d requires at least three points" , index )
}
for _ , point := range polygon {
if err := validateCoordinate ( point . Longitude , point . Latitude ); err != nil {
return geometry {}, err
}
}
fragments = append ( fragments ,
2026-09-17 12:27:40 +08:00
geodata . PolygonFragments (
sampleSphericalMapRingWithin ( polygon , chordLimitKM , chordErrorDegrees ),
geodata . ClipView { Projection : geodata . ProjectionEquirectangular },
) ... )
2026-08-06 12:00:56 +08:00
}
return multiPolygonGeometryFromFragments ( fragments )
}
func multiPolygonGeometryFromFragments ( fragments [][] geodata . GeoPoint ) ( geometry , error ) {
coordinates := make ([][][][] float64 , 0 , len ( fragments ))
for _ , fragment := range fragments {
ring , err := geoJSONRing ( fragment )
if err != nil {
return geometry {}, err
}
coordinates = append ( coordinates , [][][] float64 { ring })
}
if len ( coordinates ) == 0 {
return geometry {}, fmt . Errorf ( "geojson: polygon has no valid rings" )
}
return geometry { Type : "MultiPolygon" , Coordinates : coordinates }, nil
}
func geoJSONRing ( points [] geodata . GeoPoint ) ([][] float64 , error ) {
points = openRing ( points )
if len ( points ) < 3 {
return nil , fmt . Errorf ( "geojson: polygon ring requires at least three points" )
}
for _ , point := range points {
if err := validateCoordinate ( point . Longitude , point . Latitude ); err != nil {
return nil , err
}
}
area := polygonArea ( points )
if math . Abs ( area ) < 1e-12 {
return nil , fmt . Errorf ( "geojson: polygon ring has zero area" )
}
if area < 0 {
points = append ([] geodata . GeoPoint ( nil ), points ... )
for left , right := 0 , len ( points ) - 1 ; left < right ; left , right = left + 1 , right - 1 {
points [ left ], points [ right ] = points [ right ], points [ left ]
}
}
ring := make ([][] float64 , 0 , len ( points ) + 1 )
for _ , point := range points {
ring = append ( ring , [] float64 { point . Longitude , point . Latitude })
}
return append ( ring , [] float64 { points [ 0 ]. Longitude , points [ 0 ]. Latitude }), nil
}
func openRing ( points [] geodata . GeoPoint ) [] geodata . GeoPoint {
if len ( points ) > 1 && geodata . SameGeoPoint ( points [ 0 ], points [ len ( points ) - 1 ]) {
return points [: len ( points ) - 1 ]
}
return points
}
func polygonArea ( points [] geodata . GeoPoint ) float64 {
area := 0.0
for index , point := range points {
next := points [( index + 1 ) % len ( points )]
area += point . Longitude * next . Latitude - next . Longitude * point . Latitude
}
return area / 2
}
2026-09-17 12:27:40 +08:00
func splitTimedLine ( points [] pathSample , requireIncreasingTimes bool ) ([][] pathSample , error ) {
2026-08-06 12:00:56 +08:00
if len ( points ) < 2 {
return nil , fmt . Errorf ( "geojson: line requires at least two points" )
}
for index , point := range points {
if point . Time . IsZero () {
return nil , fmt . Errorf ( "geojson: timed line contains a zero time" )
}
if err := validateCoordinate ( point . Longitude , point . Latitude ); err != nil {
return nil , err
}
2026-09-17 12:27:40 +08:00
if requireIncreasingTimes && index > 0 && ! point . Time . After ( points [ index - 1 ]. Time ) {
2026-08-06 12:00:56 +08:00
return nil , fmt . Errorf ( "geojson: timed line times must be strictly increasing" )
}
}
segments := make ([][] pathSample , 0 , 2 )
current := [] pathSample { points [ 0 ]}
previousUnwrapped := points [ 0 ]
worldShift := 0.0
for index := 1 ; index < len ( points ); index ++ {
b := points [ index ]
for b . Longitude - previousUnwrapped . Longitude > 180 {
b . Longitude -= 360
}
for b . Longitude - previousUnwrapped . Longitude < - 180 {
b . Longitude += 360
}
localLongitude := b . Longitude - worldShift
if localLongitude >= - 180 && localLongitude <= 180 {
b . Longitude = localLongitude
current = append ( current , b )
previousUnwrapped = points [ index ]
previousUnwrapped . Longitude = b . Longitude + worldShift
continue
}
boundary := 180.0
if localLongitude < - 180 {
boundary = - 180
}
unwrappedBoundary := boundary + worldShift
fraction := ( unwrappedBoundary - previousUnwrapped . Longitude ) /
( b . Longitude - previousUnwrapped . Longitude )
crossing := interpolatePathSample ( previousUnwrapped , b , fraction , boundary )
if ! crossing . Time . Equal ( current [ len ( current ) - 1 ]. Time ) {
current = append ( current , crossing )
}
if len ( current ) >= 2 {
segments = append ( segments , current )
}
crossing . Longitude = - boundary
if boundary > 0 {
worldShift += 360
} else {
worldShift -= 360
}
b . Longitude -= worldShift
current = [] pathSample { crossing , b }
previousUnwrapped = points [ index ]
previousUnwrapped . Longitude = b . Longitude + worldShift
}
if len ( current ) >= 2 {
segments = append ( segments , current )
}
if len ( segments ) == 0 {
return nil , fmt . Errorf ( "geojson: line has no valid segments" )
}
return segments , nil
}
func interpolatePathSample ( a , b pathSample , fraction , longitude float64 ) pathSample {
duration := b . Time . Sub ( a . Time )
return pathSample {
Time : a . Time . Add ( time . Duration ( float64 ( duration ) * fraction )),
Longitude : longitude ,
Latitude : a . Latitude + ( b . Latitude - a . Latitude ) * fraction ,
}
}
func validateCoordinate ( longitude , latitude float64 ) error {
if math . IsNaN ( longitude ) || math . IsInf ( longitude , 0 ) || longitude < - 180 || longitude > 180 {
return fmt . Errorf ( "geojson: longitude must be finite and within [-180, 180]" )
}
if math . IsNaN ( latitude ) || math . IsInf ( latitude , 0 ) || latitude < - 90 || latitude > 90 {
return fmt . Errorf ( "geojson: latitude must be finite and within [-90, 90]" )
}
return nil
}
func finiteGeoJSON ( value float64 ) bool {
return ! math . IsNaN ( value ) && ! math . IsInf ( value , 0 )
}
func formatTime ( value time . Time ) string {
if value . IsZero () {
return ""
}
return value . UTC (). Format ( time . RFC3339Nano )
}
func normalizeLongitude ( value float64 ) float64 {
value = math . Mod ( value + 180 , 360 )
if value < 0 {
value += 360
}
return value - 180
}