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
|
||||
}
|
||||
Reference in New Issue
Block a user