feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func validateSolarEclipseInput(
|
||||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||
central *eclipsecore.SolarEclipsePath,
|
||||
) error {
|
||||
info := partial.Eclipse
|
||||
if info.GreatestEclipse.IsZero() {
|
||||
return fmt.Errorf("geojson: solar eclipse greatest time is required")
|
||||
}
|
||||
if !info.HasPartial {
|
||||
return fmt.Errorf("geojson: solar eclipse must contain a partial phase")
|
||||
}
|
||||
if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() {
|
||||
return fmt.Errorf("geojson: solar eclipse partial contact times are required")
|
||||
}
|
||||
if !info.PartialBeginOnEarth.Before(info.GreatestEclipse) ||
|
||||
!info.GreatestEclipse.Before(info.PartialEndOnEarth) {
|
||||
return fmt.Errorf("geojson: solar eclipse times must be ordered partial begin, greatest, partial end")
|
||||
}
|
||||
if err := validateSolarPathPoint("solar greatest", eclipsecore.SolarEclipsePathPoint{
|
||||
Time: info.GreatestEclipse, Longitude: info.GreatestLongitude, Latitude: info.GreatestLatitude,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
previous := time.Time{}
|
||||
for index, footprint := range partial.Footprints {
|
||||
if footprint.Time.IsZero() {
|
||||
return fmt.Errorf("geojson: solar partial footprint %d time is required", index)
|
||||
}
|
||||
if !previous.IsZero() && !footprint.Time.After(previous) {
|
||||
return fmt.Errorf("geojson: solar partial footprint times must be strictly increasing")
|
||||
}
|
||||
if footprint.Time.Before(info.PartialBeginOnEarth) || footprint.Time.After(info.PartialEndOnEarth) {
|
||||
return fmt.Errorf("geojson: solar partial footprint %d time is outside the partial interval", index)
|
||||
}
|
||||
previous = footprint.Time
|
||||
}
|
||||
if central == nil {
|
||||
return nil
|
||||
}
|
||||
if !central.Eclipse.GreatestEclipse.Equal(info.GreatestEclipse) ||
|
||||
central.Eclipse.Type != info.Type || central.Eclipse.Model != info.Model {
|
||||
return fmt.Errorf("geojson: partial footprints and central path describe different eclipses")
|
||||
}
|
||||
if central.Eclipse.CentralBeginOnEarth.IsZero() || central.Eclipse.CentralEndOnEarth.IsZero() ||
|
||||
!central.Eclipse.CentralBeginOnEarth.Before(central.Eclipse.GreatestEclipse) ||
|
||||
!central.Eclipse.GreatestEclipse.Before(central.Eclipse.CentralEndOnEarth) {
|
||||
return fmt.Errorf("geojson: solar central path contact times are invalid")
|
||||
}
|
||||
if err := validateSolarPathPoint("solar central greatest", central.Greatest); err != nil {
|
||||
return err
|
||||
}
|
||||
if !central.Greatest.Time.Equal(central.Eclipse.GreatestEclipse) {
|
||||
return fmt.Errorf("geojson: solar central greatest time does not match eclipse greatest")
|
||||
}
|
||||
if err := validateSolarPathSeries("solar center line", central.CenterLine, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if central.Greatest.Time.Before(central.CenterLine[0].Time) ||
|
||||
central.Greatest.Time.After(central.CenterLine[len(central.CenterLine)-1].Time) {
|
||||
return fmt.Errorf("geojson: solar greatest time is outside the center-line interval")
|
||||
}
|
||||
if central.CenterLine[0].Time.Before(central.Eclipse.CentralBeginOnEarth) ||
|
||||
central.CenterLine[len(central.CenterLine)-1].Time.After(central.Eclipse.CentralEndOnEarth) {
|
||||
return fmt.Errorf("geojson: solar center line is outside the central interval")
|
||||
}
|
||||
if len(central.NorthernLimit) != len(central.SouthernLimit) {
|
||||
return fmt.Errorf("geojson: solar central limits must have the same sample count")
|
||||
}
|
||||
if len(central.NorthernLimit) > 0 {
|
||||
if err := validateSolarPathSeries("solar northern limit", central.NorthernLimit, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSolarPathSeries("solar southern limit", central.SouthernLimit, true); err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range central.NorthernLimit {
|
||||
if !central.NorthernLimit[index].Time.Equal(central.SouthernLimit[index].Time) {
|
||||
return fmt.Errorf("geojson: solar central limit sample %d times must match", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSolarPathSeries(name string, points []eclipsecore.SolarEclipsePathPoint, required bool) error {
|
||||
if required && len(points) < 2 {
|
||||
return fmt.Errorf("geojson: %s requires at least two points", name)
|
||||
}
|
||||
previous := time.Time{}
|
||||
for index, point := range points {
|
||||
if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
|
||||
return err
|
||||
}
|
||||
if !previous.IsZero() && !point.Time.After(previous) {
|
||||
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
|
||||
}
|
||||
previous = point.Time
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSolarPathPoint(name string, point eclipsecore.SolarEclipsePathPoint) error {
|
||||
if point.Time.IsZero() {
|
||||
return fmt.Errorf("geojson: %s time is required", name)
|
||||
}
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return fmt.Errorf("geojson: %s: %w", name, err)
|
||||
}
|
||||
if !finiteGeoJSON(point.SunAltitude) || point.SunAltitude < -90 || point.SunAltitude > 90 {
|
||||
return fmt.Errorf("geojson: %s sun altitude must be finite and within [-90, 90]", name)
|
||||
}
|
||||
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
|
||||
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLunarEclipseInfo(info eclipsecore.LunarEclipseInfo) error {
|
||||
if !info.HasPenumbral || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
|
||||
return fmt.Errorf("geojson: lunar eclipse penumbral contact times are required")
|
||||
}
|
||||
if info.Maximum.IsZero() {
|
||||
return fmt.Errorf("geojson: lunar eclipse greatest time is required")
|
||||
}
|
||||
if info.Type != eclipsecore.LunarEclipsePenumbral && info.Type != eclipsecore.LunarEclipsePartial &&
|
||||
info.Type != eclipsecore.LunarEclipseTotal {
|
||||
return fmt.Errorf("geojson: lunar eclipse type is invalid")
|
||||
}
|
||||
switch info.Type {
|
||||
case eclipsecore.LunarEclipsePenumbral:
|
||||
if info.HasPartial || info.HasTotal {
|
||||
return fmt.Errorf("geojson: penumbral eclipse cannot contain partial or total phases")
|
||||
}
|
||||
case eclipsecore.LunarEclipsePartial:
|
||||
if !info.HasPartial || info.HasTotal {
|
||||
return fmt.Errorf("geojson: partial eclipse must contain only a partial phase")
|
||||
}
|
||||
case eclipsecore.LunarEclipseTotal:
|
||||
if !info.HasPartial || !info.HasTotal {
|
||||
return fmt.Errorf("geojson: total eclipse must contain partial and total phases")
|
||||
}
|
||||
}
|
||||
if !info.HasPartial && (!info.PartialStart.IsZero() || !info.PartialEnd.IsZero()) {
|
||||
return fmt.Errorf("geojson: partial contact times require a partial phase")
|
||||
}
|
||||
if !info.HasTotal && (!info.TotalStart.IsZero() || !info.TotalEnd.IsZero()) {
|
||||
return fmt.Errorf("geojson: total contact times require a total phase")
|
||||
}
|
||||
ordered := []time.Time{info.PenumbralStart}
|
||||
if info.HasPartial {
|
||||
if info.PartialStart.IsZero() || info.PartialEnd.IsZero() {
|
||||
return fmt.Errorf("geojson: lunar eclipse partial contact times are required")
|
||||
}
|
||||
ordered = append(ordered, info.PartialStart)
|
||||
}
|
||||
if info.HasTotal {
|
||||
if info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
|
||||
return fmt.Errorf("geojson: lunar eclipse total contact times are required")
|
||||
}
|
||||
ordered = append(ordered, info.TotalStart)
|
||||
}
|
||||
ordered = append(ordered, info.Maximum)
|
||||
if info.HasTotal {
|
||||
ordered = append(ordered, info.TotalEnd)
|
||||
}
|
||||
if info.HasPartial {
|
||||
ordered = append(ordered, info.PartialEnd)
|
||||
}
|
||||
ordered = append(ordered, info.PenumbralEnd)
|
||||
for index := 1; index < len(ordered); index++ {
|
||||
if !ordered[index-1].Before(ordered[index]) {
|
||||
return fmt.Errorf("geojson: lunar eclipse contact times are not strictly ordered")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
solarEclipseEvent = "solar-eclipse"
|
||||
lunarEclipseEvent = "lunar-eclipse"
|
||||
|
||||
defaultLunarBoundaryPoints = 360
|
||||
minimumLunarBoundaryPoints = 12
|
||||
maximumLunarBoundaryPoints = 1440
|
||||
)
|
||||
|
||||
// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
|
||||
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
|
||||
func MarshalSolarEclipse(
|
||||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||
central *eclipsecore.SolarEclipsePath,
|
||||
) ([]byte, error) {
|
||||
return marshalSolarEclipse(partial, central, nil)
|
||||
}
|
||||
|
||||
// MarshalSolarEclipseWithTimeMarkers 编码日食,并沿中心线按固定间隔追加 Point 要素;已有要素不变,标记标签使用 options.Location,时间值保持 UTC。
|
||||
// MarshalSolarEclipseWithTimeMarkers encodes a solar eclipse and adds Point Features at regular intervals along the central line. Existing features are unchanged; marker labels use options.Location while time values stay UTC.
|
||||
func MarshalSolarEclipseWithTimeMarkers(
|
||||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||
central *eclipsecore.SolarEclipsePath,
|
||||
options TimeMarkerOptions,
|
||||
) ([]byte, error) {
|
||||
return marshalSolarEclipse(partial, central, &options)
|
||||
}
|
||||
|
||||
func marshalSolarEclipse(
|
||||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||||
central *eclipsecore.SolarEclipsePath,
|
||||
markerOptions *TimeMarkerOptions,
|
||||
) ([]byte, error) {
|
||||
if markerOptions != nil {
|
||||
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(partial.Footprints) == 0 {
|
||||
return nil, fmt.Errorf("geojson: solar eclipse has no partial footprints")
|
||||
}
|
||||
if err := validateSolarEclipseInput(partial, central); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
properties := map[string]interface{}{
|
||||
"eclipse_type": string(partial.Eclipse.Type),
|
||||
"model": string(partial.Eclipse.Model),
|
||||
}
|
||||
features := make([]feature, 0, len(partial.Footprints)+8)
|
||||
for _, footprint := range partial.Footprints {
|
||||
polygon, err := solarPartialFootprintPolygon(footprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
footprintProperties := cloneProperties(properties)
|
||||
footprintProperties["time"] = formatTime(footprint.Time)
|
||||
footprintProperties["source_boundary_closed"] = footprint.Closed
|
||||
if len(polygon) == 1 {
|
||||
value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude)
|
||||
if pointErr != nil {
|
||||
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), pointErr)
|
||||
}
|
||||
features = append(features, newFeature(
|
||||
solarEclipseEvent, "partial-footprint", value, footprintProperties,
|
||||
))
|
||||
continue
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{polygon})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), err)
|
||||
}
|
||||
features = append(features, newFeature(
|
||||
solarEclipseEvent, "partial-footprint", value, footprintProperties,
|
||||
))
|
||||
}
|
||||
|
||||
if central != nil {
|
||||
if len(central.NorthernLimit) > 0 {
|
||||
band, err := pairedLimitPolygon(central.NorthernLimit, central.SouthernLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar central band: %w", err)
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: solar central band: %w", err)
|
||||
}
|
||||
features = append(features, newFeature(
|
||||
solarEclipseEvent, "central-band", value, cloneProperties(properties),
|
||||
))
|
||||
}
|
||||
|
||||
var err error
|
||||
features, err = appendSolarPathLine(features, "center-line", central.CenterLine, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(central.NorthernLimit) > 0 {
|
||||
features, err = appendSolarPathLine(features, "north-limit", central.NorthernLimit, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features, err = appendSolarPathLine(features, "south-limit", central.SouthernLimit, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if markerOptions != nil {
|
||||
features, err = appendTimeMarkerFeatures(
|
||||
features,
|
||||
solarEclipseEvent,
|
||||
"center-line",
|
||||
solarPathSamples(central.CenterLine),
|
||||
*markerOptions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
greatest := pathSample{
|
||||
Time: partial.Eclipse.GreatestEclipse,
|
||||
Longitude: partial.Eclipse.GreatestLongitude,
|
||||
Latitude: partial.Eclipse.GreatestLatitude,
|
||||
}
|
||||
greatestProperties := solarEclipseMetadata(partial.Eclipse)
|
||||
if central != nil {
|
||||
greatest = solarPathSample(central.Greatest)
|
||||
greatestProperties["width_km"] = central.Greatest.WidthKM
|
||||
greatestProperties["sun_altitude_deg"] = central.Greatest.SunAltitude
|
||||
}
|
||||
var err error
|
||||
features, err = appendPointFeature(
|
||||
features, solarEclipseEvent, "greatest", greatest, greatestProperties,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalFeatureCollection(features)
|
||||
}
|
||||
|
||||
// MarshalLunarEclipse 将月食 P1/P4 可见半球和地平线边界编码为 GeoJSON。
|
||||
// MarshalLunarEclipse encodes the P1/P4 visible hemispheres and horizon boundaries as GeoJSON.
|
||||
// boundaryPoints 小于等于零时使用 360;其他值限制在 [12, 1440]。
|
||||
// boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440].
|
||||
func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) {
|
||||
return marshalLunarEclipse(info, boundaryPoints, nil)
|
||||
}
|
||||
|
||||
// MarshalLunarEclipseWithTimeMarkers 编码月食,并沿半影开始到结束的月下点轨迹追加 Point 要素。
|
||||
// MarshalLunarEclipseWithTimeMarkers encodes a lunar eclipse and adds Point Features along the sublunar track from penumbral start through end.
|
||||
// 已有要素保持不变;标记标签使用 options.Location,时间值保持 UTC。
|
||||
// Existing features are unchanged; marker labels use options.Location while time values stay UTC.
|
||||
func MarshalLunarEclipseWithTimeMarkers(
|
||||
info eclipsecore.LunarEclipseInfo,
|
||||
boundaryPoints int,
|
||||
options TimeMarkerOptions,
|
||||
) ([]byte, error) {
|
||||
return marshalLunarEclipse(info, boundaryPoints, &options)
|
||||
}
|
||||
|
||||
func marshalLunarEclipse(
|
||||
info eclipsecore.LunarEclipseInfo,
|
||||
boundaryPoints int,
|
||||
markerOptions *TimeMarkerOptions,
|
||||
) ([]byte, error) {
|
||||
if markerOptions != nil {
|
||||
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateLunarEclipseInfo(info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints)
|
||||
properties := map[string]interface{}{
|
||||
"eclipse_type": string(info.Type),
|
||||
"boundary_points": boundaryPoints,
|
||||
}
|
||||
features := make([]feature, 0, 5)
|
||||
|
||||
contacts := []struct {
|
||||
role string
|
||||
horizonRole string
|
||||
time time.Time
|
||||
}{
|
||||
{role: "visible-at-p1", horizonRole: "p1-horizon", time: info.PenumbralStart},
|
||||
{role: "visible-at-p4", horizonRole: "p4-horizon", time: info.PenumbralEnd},
|
||||
}
|
||||
for _, contact := range contacts {
|
||||
center := lunarSubpoint(contact.time)
|
||||
polygons := geodata.VisibleHemispherePolygons(
|
||||
center, geodata.ProjectionEquirectangular, boundaryPoints,
|
||||
)
|
||||
value, err := multiPolygonGeometryFromFragments(polygons)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
|
||||
}
|
||||
contactProperties := cloneProperties(properties)
|
||||
contactProperties["time"] = formatTime(contact.time)
|
||||
features = append(features, newFeature(
|
||||
lunarEclipseEvent, contact.role, value, contactProperties,
|
||||
))
|
||||
|
||||
horizon := geodata.SphericalCircle(center, 90, boundaryPoints)
|
||||
horizonValue, err := geoMultiLineGeometry(horizon, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err)
|
||||
}
|
||||
features = append(features, newFeature(
|
||||
lunarEclipseEvent,
|
||||
contact.horizonRole,
|
||||
horizonValue,
|
||||
map[string]interface{}{
|
||||
"eclipse_type": string(info.Type),
|
||||
"time": formatTime(contact.time),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
maximum := lunarSubpoint(info.Maximum)
|
||||
features, err := appendPointFeature(
|
||||
features,
|
||||
lunarEclipseEvent,
|
||||
"greatest",
|
||||
pathSample{Time: info.Maximum, Longitude: maximum.Longitude, Latitude: maximum.Latitude},
|
||||
lunarEclipseMetadata(info),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if markerOptions != nil {
|
||||
markers, markerErr := lunarEclipseTimeMarkerSamples(info, *markerOptions)
|
||||
if markerErr != nil {
|
||||
return nil, markerErr
|
||||
}
|
||||
features, err = appendTimeMarkerPointFeatures(
|
||||
features,
|
||||
lunarEclipseEvent,
|
||||
"sublunar-track",
|
||||
markers,
|
||||
markerOptions.Location,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return marshalFeatureCollection(features)
|
||||
}
|
||||
|
||||
func solarPartialFootprintPolygon(
|
||||
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||||
) ([]geodata.GeoPoint, error) {
|
||||
if footprint.Time.IsZero() {
|
||||
return nil, fmt.Errorf("geojson: solar partial footprint time is required")
|
||||
}
|
||||
segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
|
||||
for _, source := range footprint.Boundaries {
|
||||
segment := make([]geodata.GeoPoint, len(source))
|
||||
for index, point := range source {
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
boundary := geodata.JoinPolylineSegments(segments)
|
||||
boundary = openRing(boundary)
|
||||
if len(boundary) == 1 && !footprint.Closed {
|
||||
return boundary, nil
|
||||
}
|
||||
minimumPoints := 3
|
||||
if !footprint.Closed {
|
||||
minimumPoints = 2
|
||||
}
|
||||
if len(boundary) < minimumPoints {
|
||||
return nil, fmt.Errorf("geojson: solar partial footprint boundary is incomplete")
|
||||
}
|
||||
polygon := append([]geodata.GeoPoint(nil), boundary...)
|
||||
if !footprint.Closed {
|
||||
terminator := geodata.SphericalCircle(solarSubsolarPoint(footprint.Time), 90, 360)
|
||||
arc := geodata.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])
|
||||
if len(arc) > 1 {
|
||||
polygon = append(polygon, arc[1:]...)
|
||||
}
|
||||
}
|
||||
if len(openRing(polygon)) < 3 {
|
||||
return nil, fmt.Errorf("geojson: solar partial footprint polygon is incomplete")
|
||||
}
|
||||
return polygon, nil
|
||||
}
|
||||
|
||||
func pairedLimitPolygon(
|
||||
northern, southern []eclipsecore.SolarEclipsePathPoint,
|
||||
) ([]geodata.GeoPoint, error) {
|
||||
if len(northern) != len(southern) {
|
||||
return nil, fmt.Errorf("paired limits must have the same sample count")
|
||||
}
|
||||
count := len(northern)
|
||||
if count < 2 {
|
||||
return nil, fmt.Errorf("paired limits require at least two points per side")
|
||||
}
|
||||
for index := range northern {
|
||||
if northern[index].Time.IsZero() || southern[index].Time.IsZero() {
|
||||
return nil, fmt.Errorf("paired limit sample %d time is required", index)
|
||||
}
|
||||
if !northern[index].Time.Equal(southern[index].Time) {
|
||||
return nil, fmt.Errorf("paired limit sample %d times must match", index)
|
||||
}
|
||||
}
|
||||
polygon := make([]geodata.GeoPoint, 0, 2*count)
|
||||
for _, point := range northern[:count] {
|
||||
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
for index := count - 1; index >= 0; index-- {
|
||||
point := southern[index]
|
||||
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
return polygon, nil
|
||||
}
|
||||
|
||||
func appendSolarPathLine(
|
||||
features []feature,
|
||||
role string,
|
||||
points []eclipsecore.SolarEclipsePathPoint,
|
||||
properties map[string]interface{},
|
||||
) ([]feature, error) {
|
||||
samples := make([]pathSample, len(points))
|
||||
for index, point := range points {
|
||||
samples[index] = solarPathSample(point)
|
||||
}
|
||||
return appendTimedLineFeature(features, solarEclipseEvent, role, samples, properties)
|
||||
}
|
||||
|
||||
func solarPathSamples(points []eclipsecore.SolarEclipsePathPoint) []pathSample {
|
||||
samples := make([]pathSample, len(points))
|
||||
for index, point := range points {
|
||||
samples[index] = solarPathSample(point)
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
func solarPathSample(point eclipsecore.SolarEclipsePathPoint) pathSample {
|
||||
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
|
||||
func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"eclipse_type": string(info.Type),
|
||||
"model": string(info.Model),
|
||||
"centrality": string(info.Centrality),
|
||||
"magnitude": info.Magnitude,
|
||||
"gamma": info.Gamma,
|
||||
"path_width_km": info.PathWidthKM,
|
||||
"partial_begin_on_earth": formatTime(info.PartialBeginOnEarth),
|
||||
"partial_end_on_earth": formatTime(info.PartialEndOnEarth),
|
||||
"central_begin_on_earth": formatTime(info.CentralBeginOnEarth),
|
||||
"central_end_on_earth": formatTime(info.CentralEndOnEarth),
|
||||
}
|
||||
}
|
||||
|
||||
func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"eclipse_type": string(info.Type),
|
||||
"penumbral_magnitude": info.PenumbralMagnitude,
|
||||
"umbral_magnitude": info.UmbralMagnitude,
|
||||
"penumbral_start": formatTime(info.PenumbralStart),
|
||||
"partial_start": formatTime(info.PartialStart),
|
||||
"total_start": formatTime(info.TotalStart),
|
||||
"total_end": formatTime(info.TotalEnd),
|
||||
"partial_end": formatTime(info.PartialEnd),
|
||||
"penumbral_end": formatTime(info.PenumbralEnd),
|
||||
}
|
||||
}
|
||||
|
||||
func solarSubsolarPoint(value time.Time) geodata.GeoPoint {
|
||||
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
|
||||
ra, dec := basic.HSunApparentRaDec(ttJDE)
|
||||
utJDE := basic.TD2UT(ttJDE, false)
|
||||
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
|
||||
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
|
||||
}
|
||||
|
||||
func lunarSubpoint(value time.Time) geodata.GeoPoint {
|
||||
ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
|
||||
ra, dec := basic.HMoonTrueRaDec(ttJDE)
|
||||
utJDE := basic.TD2UT(ttJDE, false)
|
||||
longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
|
||||
return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
|
||||
}
|
||||
|
||||
func lunarEclipseTimeMarkerSamples(
|
||||
info eclipsecore.LunarEclipseInfo,
|
||||
options TimeMarkerOptions,
|
||||
) ([]pathSample, error) {
|
||||
step, err := normalizeTimeMarkerStep(options.Step)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
|
||||
}
|
||||
location := normalizeTimeMarkerLocation(options.Location)
|
||||
start, end := info.PenumbralStart, info.PenumbralEnd
|
||||
capacity, err := timeMarkerCapacity(start, end, step, location)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
|
||||
}
|
||||
current := firstTimeMarkerAfter(start, step, location)
|
||||
markers := make([]pathSample, 0, capacity)
|
||||
for current.Before(end) {
|
||||
point := lunarSubpoint(current)
|
||||
markers = append(markers, pathSample{
|
||||
Time: current,
|
||||
Longitude: point.Longitude,
|
||||
Latitude: point.Latitude,
|
||||
})
|
||||
current = current.Add(step)
|
||||
}
|
||||
return markers, nil
|
||||
}
|
||||
|
||||
func normalizeLunarBoundaryPoints(value int) int {
|
||||
if value <= 0 {
|
||||
return defaultLunarBoundaryPoints
|
||||
}
|
||||
if value < minimumLunarBoundaryPoints {
|
||||
return minimumLunarBoundaryPoints
|
||||
}
|
||||
if value > maximumLunarBoundaryPoints {
|
||||
return maximumLunarBoundaryPoints
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
)
|
||||
|
||||
func ExampleMarshalSolarEclipse() {
|
||||
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||
date,
|
||||
eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 10 * time.Minute,
|
||||
BoundaryPoints: 180,
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
central, hasCentral := eclipse.SolarEclipseCentralPath(
|
||||
date,
|
||||
eclipse.SolarEclipsePathOptions{
|
||||
Step: time.Minute,
|
||||
TargetSpacingKM: 20,
|
||||
},
|
||||
)
|
||||
var centralPath *eclipse.SolarEclipsePath
|
||||
if hasCentral {
|
||||
centralPath = ¢ral
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalSolarEclipse(partial, centralPath)
|
||||
fmt.Println(err == nil, json.Valid(data))
|
||||
// Output: true true
|
||||
}
|
||||
|
||||
func ExampleMarshalSolarEclipseWithTimeMarkers() {
|
||||
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(
|
||||
date,
|
||||
eclipse.SolarEclipsePartialFootprintOptions{Step: 20 * time.Minute, BoundaryPoints: 36},
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(
|
||||
date,
|
||||
eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute},
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(
|
||||
partial,
|
||||
¢ral,
|
||||
geojson.TimeMarkerOptions{Step: 30 * time.Minute, Location: time.FixedZone("CST", 8*60*60)},
|
||||
)
|
||||
fmt.Println(err == nil, json.Valid(data))
|
||||
// Output: true true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
package geojson_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/eclipse"
|
||||
"b612.me/astro/geojson"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
type decodedCollection struct {
|
||||
Type string `json:"type"`
|
||||
Features []decodedFeature `json:"features"`
|
||||
}
|
||||
|
||||
type decodedFeature struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]interface{} `json:"properties"`
|
||||
Geometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates json.RawMessage `json:"coordinates"`
|
||||
} `json:"geometry"`
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseFeatureCollection(t *testing.T) {
|
||||
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 20 * time.Minute,
|
||||
BoundaryPoints: 36,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
||||
if !ok {
|
||||
t.Fatal("expected solar central path")
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertRoles(t, collection,
|
||||
"partial-footprint", "central-band", "center-line", "north-limit", "south-limit", "greatest")
|
||||
assertCollectionCoordinates(t, collection)
|
||||
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
|
||||
assertTimedLineAligned(t, featureWithRole(t, collection, "center-line"))
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseAllowsSingleLimitCentrality(t *testing.T) {
|
||||
date := time.Date(2003, time.May, 30, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 20 * time.Minute, BoundaryPoints: 24,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute})
|
||||
if !ok || central.Eclipse.Centrality != eclipse.SolarEclipseCentralOneLimit {
|
||||
t.Fatalf("expected one-limit central eclipse, got ok=%v centrality=%s", ok, central.Eclipse.Centrality)
|
||||
}
|
||||
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
if len(featuresWithRole(collection, "center-line")) != 1 || len(featuresWithRole(collection, "central-band")) != 0 {
|
||||
t.Fatal("single-limit central eclipse should export center line without a band")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseAllowsLowSampleOpenFootprints(t *testing.T) {
|
||||
for _, fixture := range []struct {
|
||||
date time.Time
|
||||
step time.Duration
|
||||
}{
|
||||
{time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||||
{time.Date(2025, time.March, 29, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||||
} {
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: fixture.step, BoundaryPoints: 12,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("%s: expected solar partial footprints", fixture.date.Format("2006-01-02"))
|
||||
}
|
||||
if _, err := geojson.MarshalSolarEclipse(partial, nil); err != nil {
|
||||
t.Fatalf("%s low-sample GeoJSON: %v", fixture.date.Format("2006-01-02"), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseWithTimeMarkers(t *testing.T) {
|
||||
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||||
Step: 20 * time.Minute,
|
||||
BoundaryPoints: 36,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected solar partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
||||
if !ok {
|
||||
t.Fatal("expected solar central path")
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, ¢ral, geojson.TimeMarkerOptions{
|
||||
Step: time.Hour,
|
||||
Location: time.FixedZone("CST", 8*60*60),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalSolarEclipseWithTimeMarkers: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
markers := featuresWithRole(collection, "time-marker")
|
||||
if len(markers) == 0 {
|
||||
t.Fatal("solar eclipse has no time markers")
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if marker.Properties["source_role"] != "center-line" {
|
||||
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
|
||||
}
|
||||
label, ok := marker.Properties["label"].(string)
|
||||
if !ok || len(label) != len("15:04") || label[2] != ':' {
|
||||
t.Fatalf("invalid time marker label %q", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalLunarEclipseUsesRequestedBoundarySampling(t *testing.T) {
|
||||
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("expected lunar eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalLunarEclipse(info, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalLunarEclipse: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertRoles(t, collection, "visible-at-p1", "visible-at-p4", "p1-horizon", "p4-horizon", "greatest")
|
||||
assertCollectionCoordinates(t, collection)
|
||||
visible := featureWithRole(t, collection, "visible-at-p1")
|
||||
if got := visible.Properties["boundary_points"]; got != float64(24) {
|
||||
t.Fatalf("boundary_points=%v, want 24", got)
|
||||
}
|
||||
assertClosedMultiPolygon(t, visible)
|
||||
horizon := featureWithRole(t, collection, "p1-horizon")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(horizon.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode P1 horizon: %v", err)
|
||||
}
|
||||
pointCount := 0
|
||||
for _, line := range lines {
|
||||
pointCount += len(line)
|
||||
}
|
||||
if pointCount < 24 {
|
||||
t.Fatalf("P1 horizon has %d points, want at least 24", pointCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalLunarEclipseWithTimeMarkers(t *testing.T) {
|
||||
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("expected lunar eclipse")
|
||||
}
|
||||
data, err := geojson.MarshalLunarEclipseWithTimeMarkers(info, 24, geojson.TimeMarkerOptions{Step: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalLunarEclipseWithTimeMarkers: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
markers := featuresWithRole(collection, "time-marker")
|
||||
if len(markers) == 0 {
|
||||
t.Fatal("lunar eclipse has no time markers")
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if marker.Properties["source_role"] != "sublunar-track" {
|
||||
t.Fatalf("time marker source_role=%v, want sublunar-track", marker.Properties["source_role"])
|
||||
}
|
||||
}
|
||||
firstLabel, _ := markers[0].Properties["label"].(string)
|
||||
lastLabel, _ := markers[len(markers)-1].Properties["label"].(string)
|
||||
if firstLabel != "09:00" || lastLabel != "14:00" {
|
||||
t.Fatalf("lunar marker endpoints = %q..%q, want 09:00..14:00", firstLabel, lastLabel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalLunarEclipseRejectsInvalidContactOrder(t *testing.T) {
|
||||
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("expected lunar eclipse")
|
||||
}
|
||||
info.Maximum = info.PenumbralStart.Add(-time.Minute)
|
||||
if _, err := geojson.MarshalLunarEclipse(info, 24); err == nil {
|
||||
t.Fatal("reversed lunar eclipse contacts were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationSplitsAntimeridian(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
center := occultationSamples(start, []float64{160, 175, -175, -160}, []float64{8, 4, 0, -4})
|
||||
north := occultationSamples(start, []float64{158, 174, -174, -158}, []float64{18, 14, 10, 6})
|
||||
south := occultationSamples(start, []float64{162, 176, -176, -162}, []float64{-2, -6, -10, -14})
|
||||
path := moon.StarOccultationPath{
|
||||
TargetID: "HR 4799",
|
||||
Start: north[0],
|
||||
Greatest: center[2],
|
||||
End: north[len(north)-1],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: north,
|
||||
SouthernLimit: south,
|
||||
Step: time.Hour,
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalStarOccultation(path)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertRoles(t, collection, "occultation-band", "center-line", "north-limit", "south-limit", "start", "greatest", "end")
|
||||
assertCollectionCoordinates(t, collection)
|
||||
centerFeature := featureWithRole(t, collection, "center-line")
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(centerFeature.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode center line: %v", err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("center line has %d antimeridian segments, want 2", len(lines))
|
||||
}
|
||||
for _, line := range lines {
|
||||
for index := 1; index < len(line); index++ {
|
||||
if math.Abs(line[index][0]-line[index-1][0]) > 180 {
|
||||
t.Fatalf("center line still crosses antimeridian: %#v", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTimedLineAligned(t, centerFeature)
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationHandlesExactAntimeridian(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
center := occultationSamples(start, []float64{-180, 180, 150}, []float64{2, 1, 0})
|
||||
north := occultationSamples(start, []float64{-179, 179, 178}, []float64{12, 11, 10})
|
||||
south := occultationSamples(start, []float64{-179, 179, 178}, []float64{-8, -9, -10})
|
||||
path := moon.StarOccultationPath{
|
||||
TargetID: "exact-antimeridian", Start: north[0], Greatest: center[1], End: north[2],
|
||||
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south,
|
||||
Step: time.Hour,
|
||||
}
|
||||
if _, err := geojson.MarshalStarOccultation(path); err != nil {
|
||||
t.Fatalf("exact antimeridian path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationWithTimeMarkers(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
center := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{2, 1, 0, -1})
|
||||
north := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{12, 11, 10, 9})
|
||||
south := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{-8, -9, -10, -11})
|
||||
path := moon.StarOccultationPath{
|
||||
TargetID: "HR 4799",
|
||||
Start: north[0],
|
||||
Greatest: center[1],
|
||||
End: north[len(north)-1],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: north,
|
||||
SouthernLimit: south,
|
||||
Step: time.Hour,
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
markers := featuresWithRole(collection, "time-marker")
|
||||
if len(markers) != 3 {
|
||||
t.Fatalf("got %d time markers, want 3", len(markers))
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if marker.Properties["source_role"] != "center-line" {
|
||||
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
|
||||
}
|
||||
if marker.Geometry.Type != "Point" {
|
||||
t.Fatalf("time marker geometry=%q, want Point", marker.Geometry.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeMarkerInterpolationUsesShortestAntimeridianPath(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
center := occultationSamples(start, []float64{170, -170}, []float64{2, 0})
|
||||
north := occultationSamples(start, []float64{168, -168}, []float64{12, 10})
|
||||
south := occultationSamples(start, []float64{172, -172}, []float64{-8, -10})
|
||||
path := moon.StarOccultationPath{
|
||||
TargetID: "HR 4799",
|
||||
Start: north[0],
|
||||
Greatest: center[0],
|
||||
End: north[len(north)-1],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: north,
|
||||
SouthernLimit: south,
|
||||
Step: time.Hour,
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: 15 * time.Minute})
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
|
||||
}
|
||||
markers := featuresWithRole(decodeCollection(t, data), "time-marker")
|
||||
if len(markers) != 3 {
|
||||
t.Fatalf("got %d time markers, want 3", len(markers))
|
||||
}
|
||||
for _, marker := range markers {
|
||||
var coordinate []float64
|
||||
if err := json.Unmarshal(marker.Geometry.Coordinates, &coordinate); err != nil {
|
||||
t.Fatalf("decode time marker: %v", err)
|
||||
}
|
||||
if math.Abs(coordinate[0]) < 170 {
|
||||
t.Fatalf("time marker crossed through longitude %.6f instead of the antimeridian", coordinate[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationIncludesPartialAndTotalFootprints(t *testing.T) {
|
||||
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||||
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||
totalNorth := occultationSamples(start, []float64{22, 30, 38}, []float64{8, 7, 6})
|
||||
totalSouth := occultationSamples(start, []float64{22, 30, 38}, []float64{-4, -5, -6})
|
||||
for index := range totalNorth {
|
||||
at := start.Add(time.Duration(index+1) * 30 * time.Minute)
|
||||
totalNorth[index].Time = at
|
||||
totalSouth[index].Time = at
|
||||
}
|
||||
path := moon.PlanetOccultationPath{
|
||||
Planet: moon.OccultationSaturn,
|
||||
TargetID: "Saturn",
|
||||
Start: north[0],
|
||||
Greatest: center[1],
|
||||
End: north[len(north)-1],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: north,
|
||||
SouthernLimit: south,
|
||||
PartialFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 18, -10, 42, 14)},
|
||||
HasTotalBand: true,
|
||||
TotalStart: totalNorth[0],
|
||||
TotalEnd: totalNorth[len(totalNorth)-1],
|
||||
TotalComplete: true,
|
||||
NorthernTotalLimit: totalNorth,
|
||||
SouthernTotalLimit: totalSouth,
|
||||
TotalFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 23, -5, 37, 9)},
|
||||
GreatestTotalWidthKM: 2500,
|
||||
Step: time.Hour,
|
||||
TargetSpacingKM: 50,
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalPlanetOccultation(path)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
assertRoles(t, collection,
|
||||
"partial-footprint", "total-footprint", "center-line", "north-limit", "south-limit",
|
||||
"north-total-limit", "south-total-limit", "start", "total-start", "greatest", "total-end", "end")
|
||||
assertCollectionCoordinates(t, collection)
|
||||
if got := featureWithRole(t, collection, "greatest").Properties["planet"]; got != "saturn" {
|
||||
t.Fatalf("planet=%v, want saturn", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationAllowsMissingCenterLine(t *testing.T) {
|
||||
start := time.Date(2024, time.September, 5, 0, 0, 0, 0, time.UTC)
|
||||
paths, err := moon.FindPlanetOccultationPaths(
|
||||
start, start.AddDate(0, 0, 1), moon.OccultationVenus, moon.OccultationPathOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPlanetOccultationPaths: %v", err)
|
||||
}
|
||||
if len(paths) != 1 || len(paths[0].CenterLine) != 0 {
|
||||
t.Fatalf("unexpected Venus path count/center line: paths=%d center=%d", len(paths), len(paths[0].CenterLine))
|
||||
}
|
||||
|
||||
data, err := geojson.MarshalPlanetOccultationWithTimeMarkers(
|
||||
paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err)
|
||||
}
|
||||
collection := decodeCollection(t, data)
|
||||
if len(featuresWithRole(collection, "center-line")) != 0 || len(featuresWithRole(collection, "time-marker")) != 0 {
|
||||
t.Fatal("edge-only planetary path contains center-line features")
|
||||
}
|
||||
assertRoles(t, collection, "north-limit", "south-limit", "start", "greatest", "end")
|
||||
}
|
||||
|
||||
func TestMarshalSolarEclipseRejectsMisalignedLimits(t *testing.T) {
|
||||
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||||
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{})
|
||||
if !ok {
|
||||
t.Fatal("expected solar partial footprints")
|
||||
}
|
||||
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{})
|
||||
if !ok {
|
||||
t.Fatal("expected solar central path")
|
||||
}
|
||||
central.SouthernLimit = central.SouthernLimit[:len(central.SouthernLimit)-1]
|
||||
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err == nil {
|
||||
t.Fatal("misaligned solar limits were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStarOccultationRejectsInvalidPathData(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
valid := sampleStarOccultationPath(start)
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*moon.StarOccultationPath)
|
||||
}{
|
||||
{name: "misaligned limits", mutate: func(path *moon.StarOccultationPath) {
|
||||
path.SouthernLimit = path.SouthernLimit[:len(path.SouthernLimit)-1]
|
||||
}},
|
||||
{name: "mismatched limit times", mutate: func(path *moon.StarOccultationPath) {
|
||||
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
|
||||
}},
|
||||
{name: "non-monotonic line", mutate: func(path *moon.StarOccultationPath) {
|
||||
path.CenterLine[1].Time = path.CenterLine[0].Time
|
||||
}},
|
||||
{name: "zero event time", mutate: func(path *moon.StarOccultationPath) {
|
||||
path.Start.Time = time.Time{}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := valid
|
||||
path.CenterLine = append([]moon.OccultationPathPoint(nil), valid.CenterLine...)
|
||||
path.NorthernLimit = append([]moon.OccultationPathPoint(nil), valid.NorthernLimit...)
|
||||
path.SouthernLimit = append([]moon.OccultationPathPoint(nil), valid.SouthernLimit...)
|
||||
test.mutate(&path)
|
||||
if _, err := geojson.MarshalStarOccultation(path); err == nil {
|
||||
t.Fatal("invalid stellar occultation path was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPlanetOccultationRejectsInvalidFootprintPolygon(t *testing.T) {
|
||||
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||||
path := samplePlanetOccultationPath(start)
|
||||
path.PartialFootprints = []moon.PlanetOccultationFootprint{sampleFootprint(start, 10, -10, 20, 10)}
|
||||
path.PartialFootprints[0].Polygons = append(path.PartialFootprints[0].Polygons, []moon.OccultationPathPoint{
|
||||
{Longitude: 30, Latitude: 0}, {Longitude: 31, Latitude: 0},
|
||||
})
|
||||
if _, err := geojson.MarshalPlanetOccultation(path); err == nil {
|
||||
t.Fatal("invalid footprint polygon was silently dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalFunctionsRejectIncompleteInput(t *testing.T) {
|
||||
if _, err := geojson.MarshalSolarEclipse(eclipse.SolarEclipsePartialFootprintsInfo{}, nil); err == nil {
|
||||
t.Fatal("empty solar eclipse input was accepted")
|
||||
}
|
||||
if _, err := geojson.MarshalLunarEclipse(eclipse.LunarEclipseInfo{}, 360); err == nil {
|
||||
t.Fatal("empty lunar eclipse input was accepted")
|
||||
}
|
||||
if _, err := geojson.MarshalStarOccultation(moon.StarOccultationPath{}); err == nil {
|
||||
t.Fatal("empty stellar occultation input was accepted")
|
||||
}
|
||||
if _, err := geojson.MarshalPlanetOccultation(moon.PlanetOccultationPath{}); err == nil {
|
||||
t.Fatal("empty planetary occultation input was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeMarkerOptionsRejectNegativeStep(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||||
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||
path := moon.StarOccultationPath{
|
||||
TargetID: "HR 4799",
|
||||
Start: north[0],
|
||||
Greatest: center[1],
|
||||
End: north[len(north)-1],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: north,
|
||||
SouthernLimit: south,
|
||||
}
|
||||
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: -time.Minute}); err == nil {
|
||||
t.Fatal("negative time-marker step was accepted")
|
||||
}
|
||||
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||||
t.Fatal("sub-minute time-marker step was accepted")
|
||||
}
|
||||
|
||||
excessiveEnd := path.CenterLine[0].Time.Add(24*time.Hour + 2*time.Minute)
|
||||
path.End.Time = excessiveEnd
|
||||
path.CenterLine[len(path.CenterLine)-1].Time = excessiveEnd
|
||||
path.NorthernLimit[len(path.NorthernLimit)-1].Time = excessiveEnd
|
||||
path.SouthernLimit[len(path.SouthernLimit)-1].Time = excessiveEnd
|
||||
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Minute}); err == nil {
|
||||
t.Fatal("excessive time-marker count was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeMarkerOptionsAreValidatedWithoutCenterLine(t *testing.T) {
|
||||
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||||
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||
path := moon.StarOccultationPath{
|
||||
TargetID: "edge-only", Start: north[0], Greatest: north[1], End: north[2], Complete: true,
|
||||
NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||||
}
|
||||
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||||
t.Fatal("edge-only stellar path accepted sub-minute markers")
|
||||
}
|
||||
planet := moon.PlanetOccultationPath{
|
||||
Planet: moon.OccultationVenus, TargetID: "Venus", Start: north[0], Greatest: north[1], End: north[2],
|
||||
Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||||
}
|
||||
if _, err := geojson.MarshalPlanetOccultationWithTimeMarkers(planet, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||||
t.Fatal("edge-only planetary path accepted sub-minute markers")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCollection(t *testing.T, data []byte) decodedCollection {
|
||||
t.Helper()
|
||||
var collection decodedCollection
|
||||
if err := json.Unmarshal(data, &collection); err != nil {
|
||||
t.Fatalf("decode GeoJSON: %v", err)
|
||||
}
|
||||
if collection.Type != "FeatureCollection" {
|
||||
t.Fatalf("collection type=%q, want FeatureCollection", collection.Type)
|
||||
}
|
||||
if len(collection.Features) == 0 {
|
||||
t.Fatal("GeoJSON contains no features")
|
||||
}
|
||||
for _, feature := range collection.Features {
|
||||
if feature.Type != "Feature" {
|
||||
t.Fatalf("feature type=%q, want Feature", feature.Type)
|
||||
}
|
||||
if _, ok := feature.Properties["event"]; !ok {
|
||||
t.Fatal("feature has no event property")
|
||||
}
|
||||
if _, ok := feature.Properties["role"]; !ok {
|
||||
t.Fatal("feature has no role property")
|
||||
}
|
||||
}
|
||||
return collection
|
||||
}
|
||||
|
||||
func assertRoles(t *testing.T, collection decodedCollection, roles ...string) {
|
||||
t.Helper()
|
||||
for _, role := range roles {
|
||||
featureWithRole(t, collection, role)
|
||||
}
|
||||
}
|
||||
|
||||
func featureWithRole(t *testing.T, collection decodedCollection, role string) decodedFeature {
|
||||
t.Helper()
|
||||
for _, feature := range collection.Features {
|
||||
if feature.Properties["role"] == role {
|
||||
return feature
|
||||
}
|
||||
}
|
||||
t.Fatalf("GeoJSON is missing role %q", role)
|
||||
return decodedFeature{}
|
||||
}
|
||||
|
||||
func featuresWithRole(collection decodedCollection, role string) []decodedFeature {
|
||||
result := make([]decodedFeature, 0)
|
||||
for _, feature := range collection.Features {
|
||||
if feature.Properties["role"] == role {
|
||||
result = append(result, feature)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertCollectionCoordinates(t *testing.T, collection decodedCollection) {
|
||||
t.Helper()
|
||||
for _, feature := range collection.Features {
|
||||
var coordinates interface{}
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &coordinates); err != nil {
|
||||
t.Fatalf("decode %v coordinates: %v", feature.Properties["role"], err)
|
||||
}
|
||||
assertCoordinateTree(t, coordinates)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCoordinateTree(t *testing.T, value interface{}) {
|
||||
t.Helper()
|
||||
items, ok := value.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("coordinate node has type %T", value)
|
||||
}
|
||||
if len(items) >= 2 {
|
||||
longitude, lonOK := items[0].(float64)
|
||||
latitude, latOK := items[1].(float64)
|
||||
if lonOK && latOK {
|
||||
if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
|
||||
t.Fatalf("invalid longitude %.12f", longitude)
|
||||
}
|
||||
if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
|
||||
t.Fatalf("invalid latitude %.12f", latitude)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
assertCoordinateTree(t, item)
|
||||
}
|
||||
}
|
||||
|
||||
func assertClosedMultiPolygon(t *testing.T, feature decodedFeature) {
|
||||
t.Helper()
|
||||
if feature.Geometry.Type != "MultiPolygon" {
|
||||
t.Fatalf("%v geometry=%q, want MultiPolygon", feature.Properties["role"], feature.Geometry.Type)
|
||||
}
|
||||
var polygons [][][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||||
t.Fatalf("decode %v polygon: %v", feature.Properties["role"], err)
|
||||
}
|
||||
if len(polygons) == 0 {
|
||||
t.Fatalf("%v has no polygons", feature.Properties["role"])
|
||||
}
|
||||
for _, polygon := range polygons {
|
||||
if len(polygon) == 0 || len(polygon[0]) < 4 {
|
||||
t.Fatalf("%v contains an incomplete ring", feature.Properties["role"])
|
||||
}
|
||||
ring := polygon[0]
|
||||
first, last := ring[0], ring[len(ring)-1]
|
||||
if first[0] != last[0] || first[1] != last[1] {
|
||||
t.Fatalf("%v ring is not closed", feature.Properties["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTimedLineAligned(t *testing.T, feature decodedFeature) {
|
||||
t.Helper()
|
||||
var lines [][][]float64
|
||||
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||||
t.Fatalf("decode line coordinates: %v", err)
|
||||
}
|
||||
timeSegments, ok := feature.Properties["times"].([]interface{})
|
||||
if !ok || len(timeSegments) != len(lines) {
|
||||
t.Fatalf("times do not align with %d line segments: %#v", len(lines), feature.Properties["times"])
|
||||
}
|
||||
for index, rawSegment := range timeSegments {
|
||||
times, ok := rawSegment.([]interface{})
|
||||
if !ok || len(times) != len(lines[index]) {
|
||||
t.Fatalf("times segment %d does not align with %d coordinates", index, len(lines[index]))
|
||||
}
|
||||
for _, rawTime := range times {
|
||||
value, ok := rawTime.(string)
|
||||
if !ok {
|
||||
t.Fatalf("time has type %T", rawTime)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value); err != nil {
|
||||
t.Fatalf("invalid RFC3339 time %q: %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func occultationSamples(start time.Time, longitudes, latitudes []float64) []moon.OccultationPathPoint {
|
||||
result := make([]moon.OccultationPathPoint, len(longitudes))
|
||||
for index := range result {
|
||||
result[index] = moon.OccultationPathPoint{
|
||||
Time: start.Add(time.Duration(index) * time.Hour),
|
||||
Longitude: longitudes[index],
|
||||
Latitude: latitudes[index],
|
||||
MoonAltitude: 30,
|
||||
WidthKM: 3000,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sampleStarOccultationPath(start time.Time) moon.StarOccultationPath {
|
||||
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||||
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||||
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||||
return moon.StarOccultationPath{
|
||||
TargetID: "HR 4799", Start: north[0], Greatest: center[1], End: north[len(north)-1],
|
||||
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func samplePlanetOccultationPath(start time.Time) moon.PlanetOccultationPath {
|
||||
star := sampleStarOccultationPath(start)
|
||||
return moon.PlanetOccultationPath{
|
||||
Planet: moon.OccultationSaturn, TargetID: "Saturn",
|
||||
Start: star.Start, Greatest: star.Greatest, End: star.End, Complete: true,
|
||||
CenterLine: star.CenterLine, NorthernLimit: star.NorthernLimit, SouthernLimit: star.SouthernLimit,
|
||||
Step: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func sampleFootprint(at time.Time, west, south, east, north float64) moon.PlanetOccultationFootprint {
|
||||
return moon.PlanetOccultationFootprint{
|
||||
Time: at,
|
||||
Polygons: [][]moon.OccultationPathPoint{{
|
||||
{Longitude: west, Latitude: south},
|
||||
{Longitude: east, Latitude: south},
|
||||
{Longitude: east, Latitude: north},
|
||||
{Longitude: west, Latitude: north},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package geojson
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
const lunarOccultationEvent = "lunar-occultation"
|
||||
|
||||
// MarshalStarOccultation 将月掩恒星的全球掩带和中心线编码为 GeoJSON。
|
||||
// MarshalStarOccultation encodes a global stellar occultation band and center line as GeoJSON.
|
||||
func MarshalStarOccultation(path moon.StarOccultationPath) ([]byte, error) {
|
||||
return marshalStarOccultation(path, nil)
|
||||
}
|
||||
|
||||
// MarshalStarOccultationWithTimeMarkers 编码恒星月掩,并沿中心线按固定间隔追加 Point 要素。
|
||||
// MarshalStarOccultationWithTimeMarkers encodes a stellar occultation and adds Point Features at regular intervals along its center line.
|
||||
func MarshalStarOccultationWithTimeMarkers(
|
||||
path moon.StarOccultationPath,
|
||||
options TimeMarkerOptions,
|
||||
) ([]byte, error) {
|
||||
return marshalStarOccultation(path, &options)
|
||||
}
|
||||
|
||||
func marshalStarOccultation(path moon.StarOccultationPath, markerOptions *TimeMarkerOptions) ([]byte, error) {
|
||||
if markerOptions != nil {
|
||||
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateStarOccultationPathData(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
properties := map[string]interface{}{
|
||||
"target_type": "star",
|
||||
"target_id": path.TargetID,
|
||||
"complete": path.Complete,
|
||||
"step_seconds": path.Step.Seconds(),
|
||||
"target_spacing_km": path.TargetSpacingKM,
|
||||
}
|
||||
band, err := occultationBandPolygon(path.NorthernLimit, path.SouthernLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: stellar occultation band: %w", err)
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: stellar occultation band: %w", err)
|
||||
}
|
||||
features := []feature{
|
||||
newFeature(lunarOccultationEvent, "occultation-band", value, cloneProperties(properties)),
|
||||
}
|
||||
if len(path.CenterLine) > 0 {
|
||||
features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if markerOptions != nil && len(path.CenterLine) > 0 {
|
||||
features, err = appendTimeMarkerFeatures(
|
||||
features,
|
||||
lunarOccultationEvent,
|
||||
"center-line",
|
||||
occultationPathSamples(path.CenterLine),
|
||||
*markerOptions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, marker := range []struct {
|
||||
role string
|
||||
point moon.OccultationPathPoint
|
||||
}{
|
||||
{role: "start", point: path.Start},
|
||||
{role: "greatest", point: path.Greatest},
|
||||
{role: "end", point: path.End},
|
||||
} {
|
||||
features, err = appendOccultationPoint(features, marker.role, marker.point, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return marshalFeatureCollection(features)
|
||||
}
|
||||
|
||||
// MarshalPlanetOccultation 将月掩行星的部分掩、全掩和中心线编码为 GeoJSON。
|
||||
// MarshalPlanetOccultation encodes partial, total, and center-line planetary occultation geometry as GeoJSON.
|
||||
func MarshalPlanetOccultation(path moon.PlanetOccultationPath) ([]byte, error) {
|
||||
return marshalPlanetOccultation(path, nil)
|
||||
}
|
||||
|
||||
// MarshalPlanetOccultationWithTimeMarkers 编码行星月掩,并沿中心线按固定间隔追加 Point 要素。
|
||||
// MarshalPlanetOccultationWithTimeMarkers encodes a planetary occultation and adds Point Features at regular intervals along its center line.
|
||||
func MarshalPlanetOccultationWithTimeMarkers(
|
||||
path moon.PlanetOccultationPath,
|
||||
options TimeMarkerOptions,
|
||||
) ([]byte, error) {
|
||||
return marshalPlanetOccultation(path, &options)
|
||||
}
|
||||
|
||||
func marshalPlanetOccultation(path moon.PlanetOccultationPath, markerOptions *TimeMarkerOptions) ([]byte, error) {
|
||||
if markerOptions != nil {
|
||||
if err := validateTimeMarkerOptions(*markerOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := path.Planet.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("geojson: planetary occultation target: %w", err)
|
||||
}
|
||||
if err := validatePlanetOccultationPathData(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
properties := map[string]interface{}{
|
||||
"target_type": "planet",
|
||||
"target_id": path.TargetID,
|
||||
"planet": string(path.Planet),
|
||||
"complete": path.Complete,
|
||||
"has_total_band": path.HasTotalBand,
|
||||
"total_complete": path.TotalComplete,
|
||||
"step_seconds": path.Step.Seconds(),
|
||||
"target_spacing_km": path.TargetSpacingKM,
|
||||
"greatest_total_width_km": path.GreatestTotalWidthKM,
|
||||
}
|
||||
features := make([]feature, 0, len(path.PartialFootprints)+len(path.TotalFootprints)+12)
|
||||
var err error
|
||||
if len(path.PartialFootprints) > 0 {
|
||||
features, err = appendOccultationFootprints(
|
||||
features, "partial-footprint", path.PartialFootprints, properties,
|
||||
)
|
||||
} else {
|
||||
features, err = appendOccultationBand(
|
||||
features, "partial-band", path.NorthernLimit, path.SouthernLimit, properties,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if path.HasTotalBand {
|
||||
if len(path.TotalFootprints) > 0 {
|
||||
features, err = appendOccultationFootprints(
|
||||
features, "total-footprint", path.TotalFootprints, properties,
|
||||
)
|
||||
} else {
|
||||
features, err = appendOccultationBand(
|
||||
features, "total-band", path.NorthernTotalLimit, path.SouthernTotalLimit, properties,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(path.CenterLine) > 0 {
|
||||
features, err = appendOccultationPathLine(features, "center-line", path.CenterLine, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
features, err = appendOccultationPathLine(features, "north-limit", path.NorthernLimit, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features, err = appendOccultationPathLine(features, "south-limit", path.SouthernLimit, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path.HasTotalBand {
|
||||
features, err = appendOccultationPathLine(
|
||||
features, "north-total-limit", path.NorthernTotalLimit, properties,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features, err = appendOccultationPathLine(
|
||||
features, "south-total-limit", path.SouthernTotalLimit, properties,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if markerOptions != nil && len(path.CenterLine) > 0 {
|
||||
features, err = appendTimeMarkerFeatures(
|
||||
features,
|
||||
lunarOccultationEvent,
|
||||
"center-line",
|
||||
occultationPathSamples(path.CenterLine),
|
||||
*markerOptions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
markers := []struct {
|
||||
role string
|
||||
point moon.OccultationPathPoint
|
||||
}{
|
||||
{role: "start", point: path.Start},
|
||||
}
|
||||
if path.HasTotalBand {
|
||||
markers = append(markers, struct {
|
||||
role string
|
||||
point moon.OccultationPathPoint
|
||||
}{role: "total-start", point: path.TotalStart})
|
||||
}
|
||||
markers = append(markers, struct {
|
||||
role string
|
||||
point moon.OccultationPathPoint
|
||||
}{role: "greatest", point: path.Greatest})
|
||||
if path.HasTotalBand {
|
||||
markers = append(markers, struct {
|
||||
role string
|
||||
point moon.OccultationPathPoint
|
||||
}{role: "total-end", point: path.TotalEnd})
|
||||
}
|
||||
markers = append(markers, struct {
|
||||
role string
|
||||
point moon.OccultationPathPoint
|
||||
}{role: "end", point: path.End})
|
||||
for _, marker := range markers {
|
||||
features, err = appendOccultationPoint(features, marker.role, marker.point, properties)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return marshalFeatureCollection(features)
|
||||
}
|
||||
|
||||
func appendOccultationBand(
|
||||
features []feature,
|
||||
role string,
|
||||
northern, southern []moon.OccultationPathPoint,
|
||||
properties map[string]interface{},
|
||||
) ([]feature, error) {
|
||||
band, err := occultationBandPolygon(northern, southern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: %s: %w", role, err)
|
||||
}
|
||||
value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: %s: %w", role, err)
|
||||
}
|
||||
return append(features, newFeature(
|
||||
lunarOccultationEvent, role, value, cloneProperties(properties),
|
||||
)), nil
|
||||
}
|
||||
|
||||
func appendOccultationFootprints(
|
||||
features []feature,
|
||||
role string,
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
properties map[string]interface{},
|
||||
) ([]feature, error) {
|
||||
appended := 0
|
||||
for _, footprint := range footprints {
|
||||
if footprint.Time.IsZero() {
|
||||
return nil, fmt.Errorf("geojson: %s time is required", role)
|
||||
}
|
||||
polygons := make([][]geodata.GeoPoint, 0, len(footprint.Polygons))
|
||||
for _, source := range footprint.Polygons {
|
||||
polygon := make([]geodata.GeoPoint, len(source))
|
||||
for index, point := range source {
|
||||
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
polygons = append(polygons, polygon)
|
||||
}
|
||||
value, err := multiPolygonGeometry(polygons)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geojson: %s at %s: %w", role, formatTime(footprint.Time), err)
|
||||
}
|
||||
footprintProperties := cloneProperties(properties)
|
||||
footprintProperties["time"] = formatTime(footprint.Time)
|
||||
features = append(features, newFeature(
|
||||
lunarOccultationEvent, role, value, footprintProperties,
|
||||
))
|
||||
appended++
|
||||
}
|
||||
if appended == 0 {
|
||||
return nil, fmt.Errorf("geojson: %s has no valid polygons", role)
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
func occultationBandPolygon(
|
||||
northern, southern []moon.OccultationPathPoint,
|
||||
) ([]geodata.GeoPoint, error) {
|
||||
if len(northern) != len(southern) {
|
||||
return nil, fmt.Errorf("paired limits must have the same sample count")
|
||||
}
|
||||
count := len(northern)
|
||||
if count < 2 {
|
||||
return nil, fmt.Errorf("paired limits require at least two points per side")
|
||||
}
|
||||
for index := range northern {
|
||||
if northern[index].Time.IsZero() || southern[index].Time.IsZero() {
|
||||
return nil, fmt.Errorf("paired limit sample %d time is required", index)
|
||||
}
|
||||
if !northern[index].Time.Equal(southern[index].Time) {
|
||||
return nil, fmt.Errorf("paired limit sample %d times must match", index)
|
||||
}
|
||||
}
|
||||
polygon := make([]geodata.GeoPoint, 0, 2*count)
|
||||
for _, point := range northern[:count] {
|
||||
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
for index := count - 1; index >= 0; index-- {
|
||||
point := southern[index]
|
||||
polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||||
}
|
||||
return polygon, nil
|
||||
}
|
||||
|
||||
func appendOccultationPathLine(
|
||||
features []feature,
|
||||
role string,
|
||||
points []moon.OccultationPathPoint,
|
||||
properties map[string]interface{},
|
||||
) ([]feature, error) {
|
||||
samples := make([]pathSample, len(points))
|
||||
for index, point := range points {
|
||||
samples[index] = occultationPathSample(point)
|
||||
}
|
||||
return appendTimedLineFeature(features, lunarOccultationEvent, role, samples, properties)
|
||||
}
|
||||
|
||||
func occultationPathSamples(points []moon.OccultationPathPoint) []pathSample {
|
||||
samples := make([]pathSample, len(points))
|
||||
for index, point := range points {
|
||||
samples[index] = occultationPathSample(point)
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
func appendOccultationPoint(
|
||||
features []feature,
|
||||
role string,
|
||||
point moon.OccultationPathPoint,
|
||||
properties map[string]interface{},
|
||||
) ([]feature, error) {
|
||||
pointProperties := cloneProperties(properties)
|
||||
pointProperties["moon_altitude_deg"] = point.MoonAltitude
|
||||
pointProperties["width_km"] = point.WidthKM
|
||||
return appendPointFeature(
|
||||
features, lunarOccultationEvent, role, occultationPathSample(point), pointProperties,
|
||||
)
|
||||
}
|
||||
|
||||
func occultationPathSample(point moon.OccultationPathPoint) pathSample {
|
||||
return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
|
||||
func validateStarOccultationPathData(path moon.StarOccultationPath) error {
|
||||
if !path.Complete {
|
||||
return fmt.Errorf("geojson: occultation path is incomplete")
|
||||
}
|
||||
if err := (moon.OccultationPathOptions{Step: path.Step, TargetSpacingKM: path.TargetSpacingKM}).Validate(); err != nil {
|
||||
return fmt.Errorf("geojson: invalid occultation path sampling metadata: %w", err)
|
||||
}
|
||||
if err := validateOccultationPathPoint("start", path.Start); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOccultationPathPoint("greatest", path.Greatest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOccultationPathPoint("end", path.End); err != nil {
|
||||
return err
|
||||
}
|
||||
if path.Greatest.Time.Before(path.Start.Time) || path.End.Time.Before(path.Greatest.Time) {
|
||||
return fmt.Errorf("geojson: occultation times must be ordered start, greatest, end")
|
||||
}
|
||||
if err := validateOccultationPathSeries("center line", path.CenterLine, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOccultationPathSeries("northern limit", path.NorthernLimit, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOccultationPathSeries("southern limit", path.SouthernLimit, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(path.NorthernLimit) != len(path.SouthernLimit) {
|
||||
return fmt.Errorf("geojson: occultation northern and southern limits must have the same sample count")
|
||||
}
|
||||
for index := range path.NorthernLimit {
|
||||
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
|
||||
return fmt.Errorf("geojson: occultation limit sample %d times must match", index)
|
||||
}
|
||||
}
|
||||
last := len(path.NorthernLimit) - 1
|
||||
if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
|
||||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
|
||||
return fmt.Errorf("geojson: occultation limits must span start through end")
|
||||
}
|
||||
if len(path.CenterLine) > 0 {
|
||||
if path.CenterLine[0].Time.Before(path.Start.Time) ||
|
||||
path.CenterLine[len(path.CenterLine)-1].Time.After(path.End.Time) {
|
||||
return fmt.Errorf("geojson: occultation center line must be inside start and end")
|
||||
}
|
||||
if path.Greatest.Time.Before(path.CenterLine[0].Time) ||
|
||||
path.Greatest.Time.After(path.CenterLine[len(path.CenterLine)-1].Time) {
|
||||
return fmt.Errorf("geojson: occultation greatest time is outside the center-line interval")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePlanetOccultationPathData(path moon.PlanetOccultationPath) error {
|
||||
starPath := moon.StarOccultationPath{
|
||||
TargetID: path.TargetID, Start: path.Start, Greatest: path.Greatest, End: path.End,
|
||||
Complete: path.Complete, CenterLine: path.CenterLine,
|
||||
NorthernLimit: path.NorthernLimit, SouthernLimit: path.SouthernLimit,
|
||||
Step: path.Step, TargetSpacingKM: path.TargetSpacingKM,
|
||||
}
|
||||
if err := validateStarOccultationPathData(starPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if !path.HasTotalBand {
|
||||
if path.TotalComplete || !path.TotalStart.Time.IsZero() || !path.TotalEnd.Time.IsZero() ||
|
||||
len(path.NorthernTotalLimit) != 0 || len(path.SouthernTotalLimit) != 0 ||
|
||||
len(path.TotalFootprints) != 0 || path.GreatestTotalWidthKM != 0 {
|
||||
return fmt.Errorf("geojson: total-band fields require HasTotalBand")
|
||||
}
|
||||
return validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time)
|
||||
}
|
||||
if !path.TotalComplete {
|
||||
return fmt.Errorf("geojson: total-occultation band is incomplete")
|
||||
}
|
||||
if err := validateOccultationPathPoint("total start", path.TotalStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOccultationPathPoint("total end", path.TotalEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
if !path.Start.Time.Before(path.TotalStart.Time) || !path.TotalStart.Time.Before(path.Greatest.Time) ||
|
||||
!path.Greatest.Time.Before(path.TotalEnd.Time) || !path.TotalEnd.Time.Before(path.End.Time) {
|
||||
return fmt.Errorf("geojson: total-band times must be inside outer start, greatest, and end")
|
||||
}
|
||||
if !finiteGeoJSON(path.GreatestTotalWidthKM) || path.GreatestTotalWidthKM <= 0 ||
|
||||
!finiteGeoJSON(path.Greatest.WidthKM) || path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
|
||||
return fmt.Errorf("geojson: total-band width must be positive and narrower than the outer band")
|
||||
}
|
||||
if err := validateOccultationPathSeries("northern total limit", path.NorthernTotalLimit, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOccultationPathSeries("southern total limit", path.SouthernTotalLimit, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) {
|
||||
return fmt.Errorf("geojson: total northern and southern limits must have the same sample count")
|
||||
}
|
||||
for index := range path.NorthernTotalLimit {
|
||||
if !path.NorthernTotalLimit[index].Time.Equal(path.SouthernTotalLimit[index].Time) {
|
||||
return fmt.Errorf("geojson: total limit sample %d times must match", index)
|
||||
}
|
||||
}
|
||||
last := len(path.NorthernTotalLimit) - 1
|
||||
if !path.NorthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
|
||||
!path.SouthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
|
||||
!path.NorthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) ||
|
||||
!path.SouthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) {
|
||||
return fmt.Errorf("geojson: total limits must span total start through total end")
|
||||
}
|
||||
if err := validateOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time)
|
||||
}
|
||||
|
||||
func validateOccultationPathSeries(name string, points []moon.OccultationPathPoint, required bool) error {
|
||||
if required && len(points) < 2 {
|
||||
return fmt.Errorf("geojson: %s requires at least two points", name)
|
||||
}
|
||||
previous := time.Time{}
|
||||
for index, point := range points {
|
||||
if err := validateOccultationPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
|
||||
return err
|
||||
}
|
||||
if !previous.IsZero() && !point.Time.After(previous) {
|
||||
return fmt.Errorf("geojson: %s times must be strictly increasing", name)
|
||||
}
|
||||
previous = point.Time
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
|
||||
if point.Time.IsZero() {
|
||||
return fmt.Errorf("geojson: %s time is required", name)
|
||||
}
|
||||
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
||||
return fmt.Errorf("geojson: %s: %w", name, err)
|
||||
}
|
||||
if !finiteGeoJSON(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
|
||||
return fmt.Errorf("geojson: %s Moon altitude must be finite and within [-90, 90]", name)
|
||||
}
|
||||
if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
|
||||
return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOccultationFootprints(
|
||||
name string,
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
start, end time.Time,
|
||||
) error {
|
||||
previous := time.Time{}
|
||||
for index, footprint := range footprints {
|
||||
if footprint.Time.IsZero() {
|
||||
return fmt.Errorf("geojson: %s footprint[%d] time is required", name, index)
|
||||
}
|
||||
if footprint.Time.Before(start) || footprint.Time.After(end) {
|
||||
return fmt.Errorf("geojson: %s footprint[%d] time must be inside its contact interval", name, index)
|
||||
}
|
||||
if !previous.IsZero() && !footprint.Time.After(previous) {
|
||||
return fmt.Errorf("geojson: %s footprint times must be strictly increasing", name)
|
||||
}
|
||||
previous = footprint.Time
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user