9ee2163cc7
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
513 lines
17 KiB
Go
513 lines
17 KiB
Go
// 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
|
|
}
|