16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package geojson
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"b612.me/astro/basic"
|
|
|
|
eclipsecore "b612.me/astro/eclipse"
|
|
"b612.me/astro/internal/geodata"
|
|
"b612.me/astro/internal/solarclosure"
|
|
)
|
|
|
|
const (
|
|
// 区域角色永远是面,退化则缺省;物理边界角色永远只承载曲线。
|
|
solarCentralShadowFootprintRole = "central-shadow-footprint"
|
|
solarCentralShadowBoundaryRole = "central-shadow-boundary"
|
|
// 半影用与采样序列同名的区域角色,物理边界另立角色。
|
|
// 细于该长度的足迹是首末接触处的残片,不导出。
|
|
solarCentralShadowMinimumRegionLengthKM = 1.0
|
|
// 面积下限比导出侧的零面积下限(1e-12 平方度)高 1000 倍,退化的环直接缺省。
|
|
solarCentralShadowMinimumRegionAreaDegrees2 = 1e-9
|
|
|
|
solarPartialFootprintRole = "partial-footprint"
|
|
solarPartialFootprintBoundaryRole = "partial-footprint-boundary"
|
|
)
|
|
|
|
// appendSolarHorizonClosedShadowFootprint 输出被地平线切断的瞬时中心影足迹:区域加物理边界 / horizon-cut footprint as a region plus its physical boundary.
|
|
func appendSolarHorizonClosedShadowFootprint(
|
|
features []feature,
|
|
footprint eclipsecore.SolarEclipsePartialFootprint,
|
|
properties map[string]interface{},
|
|
) ([]feature, error) {
|
|
curve, err := solarShadowFootprintCurve(footprint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
|
|
solarCentralShadowFootprintRole, formatTime(footprint.Time), err)
|
|
}
|
|
if len(curve) < 2 {
|
|
return features, nil
|
|
}
|
|
ring, boundary := solarShadowFootprintHorizonRing(
|
|
footprint.Time, footprint.Boundaries, footprint.HorizonEnds, curve,
|
|
)
|
|
if solarShadowRegionDegenerate(curve, ring) {
|
|
return features, nil
|
|
}
|
|
value, err := multiPolygonGeometry([][]geodata.GeoPoint{ring})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
|
|
solarCentralShadowFootprintRole, formatTime(footprint.Time), err)
|
|
}
|
|
regionProperties := cloneProperties(properties)
|
|
regionProperties["time"] = formatTime(footprint.Time)
|
|
regionProperties["source_boundary_closed"] = false
|
|
regionProperties["geometry_role"] = "horizon-closed-region"
|
|
regionProperties["closure"] = solarHorizonClosureProperties(
|
|
footprint.Time, footprint.Time, solarHorizonClosureExact(footprint.Boundaries, footprint.HorizonEnds),
|
|
)
|
|
regionProperties["interp_signature"] = solarShadowFootprintSignature(
|
|
footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra,
|
|
)
|
|
features = append(features, newFeature(
|
|
solarEclipseEvent, solarCentralShadowFootprintRole, value, regionProperties,
|
|
))
|
|
|
|
boundaryValue, err := geoMultiLineGeometry(boundary, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("geojson: solar %s at %s: %w",
|
|
solarCentralShadowBoundaryRole, formatTime(footprint.Time), err)
|
|
}
|
|
boundaryProperties := cloneProperties(properties)
|
|
boundaryProperties["time"] = formatTime(footprint.Time)
|
|
boundaryProperties["source_boundary_closed"] = false
|
|
boundaryProperties["geometry_role"] = "open-boundary"
|
|
boundaryProperties["interp_signature"] = solarShadowFootprintSignature(
|
|
footprint.Boundaries, false, eclipsecore.SolarEclipseShadowUmbra,
|
|
)
|
|
return append(features, newFeature(
|
|
solarEclipseEvent, solarCentralShadowBoundaryRole, boundaryValue, boundaryProperties,
|
|
)), nil
|
|
}
|
|
|
|
func solarShadowFootprintCurve(
|
|
footprint eclipsecore.SolarEclipsePartialFootprint,
|
|
) ([]geodata.GeoPoint, error) {
|
|
return solarShadowFootprintCurveFromSegments(footprint.Boundaries)
|
|
}
|
|
|
|
func solarShadowFootprintCurveFromSegments(
|
|
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
|
) ([]geodata.GeoPoint, error) {
|
|
segments := make([][]geodata.GeoPoint, 0, len(boundaries))
|
|
for _, source := range 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)
|
|
}
|
|
return geodata.JoinPolylineSegments(segments), nil
|
|
}
|
|
|
|
// solarShadowRegionDegenerate 判定影区是否退化:物理边界过短或闭合面积近零,导出侧会拒绝。
|
|
func solarShadowRegionDegenerate(curve, ring []geodata.GeoPoint) bool {
|
|
if len(curve) < 2 || solarShadowFootprintCurveLengthKM(curve) < solarCentralShadowMinimumRegionLengthKM {
|
|
return true
|
|
}
|
|
points := openRing(ring)
|
|
if len(points) < 3 {
|
|
return true
|
|
}
|
|
return math.Abs(solarShadowRegionAreaDegrees2(points)) < solarCentralShadowMinimumRegionAreaDegrees2
|
|
}
|
|
|
|
// solarShadowRegionAreaDegrees2 用展开经度算鞋带面积,跨换日线的环不会得到假的近零面积。
|
|
func solarShadowRegionAreaDegrees2(ring []geodata.GeoPoint) float64 {
|
|
if len(ring) < 3 {
|
|
return 0
|
|
}
|
|
unwrapped := make([]geodata.GeoPoint, len(ring))
|
|
unwrapped[0] = ring[0]
|
|
for index := 1; index < len(ring); index++ {
|
|
longitude := ring[index].Longitude
|
|
for longitude-unwrapped[index-1].Longitude > 180 {
|
|
longitude -= 360
|
|
}
|
|
for longitude-unwrapped[index-1].Longitude < -180 {
|
|
longitude += 360
|
|
}
|
|
unwrapped[index] = geodata.GeoPoint{Longitude: longitude, Latitude: ring[index].Latitude}
|
|
}
|
|
area := 0.0
|
|
for index, point := range unwrapped {
|
|
next := unwrapped[(index+1)%len(unwrapped)]
|
|
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
|
|
}
|
|
return area / 2
|
|
}
|
|
|
|
func solarShadowFootprintCurveLengthKM(curve []geodata.GeoPoint) float64 {
|
|
length := 0.0
|
|
for index := 1; index < len(curve); index++ {
|
|
length += solarCentralBandGeoPointDistanceKM(curve[index-1], curve[index])
|
|
}
|
|
return length
|
|
}
|
|
|
|
func solarShadowFootprintHorizonRing(
|
|
value time.Time,
|
|
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
|
horizonEnds []eclipsecore.SolarEclipsePathPoint,
|
|
curve []geodata.GeoPoint,
|
|
) ([]geodata.GeoPoint, []geodata.GeoPoint) {
|
|
return solarclosure.HorizonRing(solarclosure.Footprint{
|
|
Boundaries: solarClosureSegments(boundaries),
|
|
HorizonEnds: solarClosureEnds(horizonEnds),
|
|
Subsolar: solarSubsolarPoint(value),
|
|
}, curve)
|
|
}
|
|
|
|
func solarShadowFootprintHorizonEnds(
|
|
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
|
horizonEnds []eclipsecore.SolarEclipsePathPoint,
|
|
) []geodata.GeoPoint {
|
|
return solarclosure.HorizonEnds(solarclosure.Footprint{
|
|
Boundaries: solarClosureSegments(boundaries),
|
|
HorizonEnds: solarClosureEnds(horizonEnds),
|
|
})
|
|
}
|
|
|
|
// solarClosureSegments 按原顺序转换边界分段,不校验坐标。
|
|
func solarClosureSegments(boundaries [][]eclipsecore.SolarEclipsePathPoint) [][]geodata.GeoPoint {
|
|
segments := make([][]geodata.GeoPoint, len(boundaries))
|
|
for index, source := range boundaries {
|
|
segments[index] = solarCentralBandGeoPoints(source)
|
|
}
|
|
return segments
|
|
}
|
|
|
|
// solarClosureEnds 转换地平擦地点;点数不是 2 或坐标非法时返回 nil,闭合退回近似弧。
|
|
func solarClosureEnds(horizonEnds []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint {
|
|
if len(horizonEnds) != 2 {
|
|
return nil
|
|
}
|
|
points := make([]geodata.GeoPoint, 2)
|
|
for index, end := range horizonEnds {
|
|
if err := validateCoordinate(end.Longitude, end.Latitude); err != nil {
|
|
return nil
|
|
}
|
|
points[index] = geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude}
|
|
}
|
|
return points
|
|
}
|
|
|
|
// solarClosureFootprint 转换瞬时足迹;边界坐标非法时报错。
|
|
func solarClosureFootprint(
|
|
footprint eclipsecore.SolarEclipsePartialFootprint,
|
|
) (solarclosure.Footprint, error) {
|
|
segments := make([][]geodata.GeoPoint, len(footprint.Boundaries))
|
|
for index, source := range footprint.Boundaries {
|
|
segment := make([]geodata.GeoPoint, len(source))
|
|
for pointIndex, point := range source {
|
|
if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
|
|
return solarclosure.Footprint{}, err
|
|
}
|
|
segment[pointIndex] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
|
}
|
|
segments[index] = segment
|
|
}
|
|
return solarclosure.Footprint{
|
|
Boundaries: segments,
|
|
HorizonEnds: solarClosureEnds(footprint.HorizonEnds),
|
|
Subsolar: solarSubsolarPoint(footprint.Time),
|
|
Closed: footprint.Closed,
|
|
}, nil
|
|
}
|
|
|
|
// solarHorizonClosureExact 报告能否用两个精确擦地点闭合区域。
|
|
func solarHorizonClosureExact(
|
|
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
|
horizonEnds []eclipsecore.SolarEclipsePathPoint,
|
|
) bool {
|
|
return len(solarShadowFootprintHorizonEnds(boundaries, horizonEnds)) == 2
|
|
}
|
|
|
|
// solarHorizonClosureProperties 声明式闭合弧;exact 为假表示缺精确擦地点、按采样端点近似闭合。
|
|
// 月下点按几何时刻算,time 属性写输出时标的时刻。
|
|
func solarHorizonClosureProperties(geometryTime, labelTime time.Time, exact bool) map[string]interface{} {
|
|
subsolar := solarSubsolarPoint(geometryTime)
|
|
return map[string]interface{}{
|
|
"kind": "horizon",
|
|
"exact": exact,
|
|
"time": formatTime(labelTime),
|
|
"subsolar": []float64{subsolar.Longitude, subsolar.Latitude},
|
|
}
|
|
}
|
|
|
|
// solarShadowSegmentClosed 与 basic 层同口径:分段首末点相距不到 1 mm 才算真正闭合。
|
|
func solarShadowSegmentClosed(segment []eclipsecore.SolarEclipsePathPoint) bool {
|
|
if len(segment) <= 2 {
|
|
return false
|
|
}
|
|
return solarCentralBandPathDistanceKM(segment[0], segment[len(segment)-1]) < 1e-6
|
|
}
|
|
|
|
// solarShadowFootprintSignature 由物理边界算出与 basic 层同口径的插值签名 / interpolation signature from the physical boundary.
|
|
func solarShadowFootprintSignature(
|
|
boundaries [][]eclipsecore.SolarEclipsePathPoint,
|
|
closed bool,
|
|
kind eclipsecore.SolarEclipseShadowKind,
|
|
) string {
|
|
topology := basic.SolarEclipseShadowTopology{
|
|
Kind: basic.SolarEclipseShadowKind(kind),
|
|
Segments: len(boundaries),
|
|
Closed: closed,
|
|
}
|
|
winding := 0.0
|
|
for _, segment := range boundaries {
|
|
topology.Vertices += len(segment)
|
|
for index := 1; index < len(segment); index++ {
|
|
winding += math.Remainder(segment[index].Longitude-segment[index-1].Longitude, 360)
|
|
}
|
|
if solarShadowSegmentClosed(segment) {
|
|
winding += math.Remainder(segment[0].Longitude-segment[len(segment)-1].Longitude, 360)
|
|
}
|
|
}
|
|
if topology.Segments == 1 && math.Abs(winding) >= 180 {
|
|
topology.EnclosesPole = true
|
|
}
|
|
return topology.Signature()
|
|
}
|