// 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 要素,标签按请求地点格式化。 // 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. 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"` Coordinates interface{} `json:"coordinates"` } 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") } 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) { segments, err := splitTimedLine(points) if err != nil { return geometry{}, nil, err } coordinates := make([][][]float64, 0, len(segments)) times := make([][]string, 0, len(segments)) 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) } 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]) } segments := geodata.PolylineSegments(geographic, geodata.ProjectionEquirectangular) 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 } func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) { 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, geodata.PolygonFragments(polygon, geodata.ProjectionEquirectangular)...) } 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 } func splitTimedLine(points []pathSample) ([][]pathSample, error) { 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 } if index > 0 && !point.Time.After(points[index-1].Time) { 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 }