feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user