feat: 新增月掩与日月食地理绘图并提升观测计算精度

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+512
View File
@@ -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
}