feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
+520
-137
@@ -3,11 +3,12 @@ package svg
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
"b612.me/astro/internal/occultationgeo"
|
||||
"b612.me/astro/internal/svgmap"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
@@ -81,33 +82,30 @@ func PlanetOccultationPathSVG(
|
||||
starShape.TargetID = targetID
|
||||
}
|
||||
options.Projection = MapProjection(resolvePlanetOccultationMapProjection(path, options.Projection))
|
||||
// 默认文案会先写回 options,必须在填充之前记下调用方真正给出的字段。
|
||||
flags := starOccultationSVGTextFlags(options)
|
||||
options = planetOccultationSVGDefaults(path, options)
|
||||
return renderOccultationPathSVG(starShape, &path, options), nil
|
||||
diagram, err := renderOccultationPathSVG(starShape, &path, options, flags)
|
||||
if err != nil {
|
||||
// 几何引擎失败不是数据格式错误,不能裹成 ErrInvalidPlanetOccultationPath 哨兵。
|
||||
return "", fmt.Errorf("planetary occultation SVG band geometry: %w", err)
|
||||
}
|
||||
return diagram, nil
|
||||
}
|
||||
|
||||
func resolvePlanetOccultationMapProjection(
|
||||
path moon.PlanetOccultationPath,
|
||||
requested MapProjection,
|
||||
) svgmap.Projection {
|
||||
minimumLatitude := path.Greatest.Latitude
|
||||
maximumLatitude := path.Greatest.Latitude
|
||||
for _, series := range [][]moon.OccultationPathPoint{
|
||||
path.CenterLine, path.NorthernLimit, path.SouthernLimit,
|
||||
path.NorthernTotalLimit, path.SouthernTotalLimit,
|
||||
} {
|
||||
for _, point := range series {
|
||||
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
|
||||
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
|
||||
minimumLatitude, maximumLatitude := planetOccultationBandLatitudeRange(path)
|
||||
// 掩带包住极点时等经纬会把它拉成横贯上下边缘的长条,必须改用极区图;
|
||||
// 判据要看带子本身的纬度范围,掩甚点在中纬也可能绕极。
|
||||
if requested == MapProjectionAuto {
|
||||
if maximumLatitude >= occultationPolarBandReach {
|
||||
return svgmap.ProjectionNorthPolar
|
||||
}
|
||||
}
|
||||
for _, footprints := range [][]moon.PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} {
|
||||
for _, footprint := range footprints {
|
||||
for _, polygon := range footprint.Polygons {
|
||||
for _, point := range polygon {
|
||||
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
|
||||
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
if minimumLatitude <= -occultationPolarBandReach {
|
||||
return svgmap.ProjectionSouthPolar
|
||||
}
|
||||
}
|
||||
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
|
||||
@@ -123,6 +121,7 @@ func planetOccultationStarShape(path moon.PlanetOccultationPath, targetID string
|
||||
CenterLine: path.CenterLine,
|
||||
NorthernLimit: path.NorthernLimit,
|
||||
SouthernLimit: path.SouthernLimit,
|
||||
RiseSetCurves: path.RiseSetCurves,
|
||||
Step: path.Step,
|
||||
TargetSpacingKM: path.TargetSpacingKM,
|
||||
}
|
||||
@@ -135,13 +134,33 @@ func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error {
|
||||
if err := validateStarOccultationPath(planetOccultationStarShape(path, path.TargetID)); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
|
||||
}
|
||||
if err := validateOccultationGreatestTimeContours(ErrInvalidPlanetOccultationPath, path.GreatestTimeContours); 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 {
|
||||
len(path.TotalFootprints) != 0 || len(path.TotalBandFootprints) != 0 ||
|
||||
len(path.TotalBandContours) != 0 || len(path.TotalRiseSetCurves) != 0 ||
|
||||
path.GreatestTotalWidthKM != 0 {
|
||||
return fmt.Errorf("%w: total-band fields require HasTotalBand", ErrInvalidPlanetOccultationPath)
|
||||
}
|
||||
return validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time)
|
||||
if err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationContours(
|
||||
"partial band contours", path.PartialBandContours, path.Start.Time, path.End.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationContours(
|
||||
"partial visibility contours", path.PartialVisibilityContours, path.Start.Time, path.End.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePlanetOccultationFootprints(
|
||||
"partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time,
|
||||
)
|
||||
}
|
||||
if !path.TotalComplete {
|
||||
return fmt.Errorf("%w: global total-occultation band is incomplete", ErrInvalidPlanetOccultationPath)
|
||||
@@ -163,13 +182,51 @@ func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error {
|
||||
if err := validatePlanetOccultationTotalLimits(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := occultationgeo.ValidateRiseSetCurves(path.TotalRiseSetCurves, path.TotalStart.Time, path.TotalEnd.Time); err != nil {
|
||||
return fmt.Errorf("%w: invalid total rise/set curves: %v", ErrInvalidPlanetOccultationPath, err)
|
||||
}
|
||||
if err := validatePlanetOccultationContours(
|
||||
"partial band contours", path.PartialBandContours, path.Start.Time, path.End.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationContours(
|
||||
"partial visibility contours", path.PartialVisibilityContours, path.Start.Time, path.End.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationContours(
|
||||
"total band contours", path.TotalBandContours, path.TotalStart.Time, path.TotalEnd.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationContours(
|
||||
"total visibility contours", path.TotalVisibilityContours, path.TotalStart.Time, path.TotalEnd.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationFootprints(
|
||||
"partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanetOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return validatePlanetOccultationFootprints(
|
||||
"total compact band", path.TotalBandFootprints, path.TotalStart.Time, path.TotalEnd.Time,
|
||||
)
|
||||
}
|
||||
|
||||
func validatePlanetOccultationContours(
|
||||
name string,
|
||||
contours [][]moon.OccultationPathPoint,
|
||||
start, end time.Time,
|
||||
) error {
|
||||
return validateOccultationContours(ErrInvalidPlanetOccultationPath, name, contours, start, end)
|
||||
}
|
||||
|
||||
func validatePlanetOccultationFootprints(
|
||||
@@ -177,34 +234,17 @@ func validatePlanetOccultationFootprints(
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
start, end time.Time,
|
||||
) error {
|
||||
for footprintIndex, footprint := range footprints {
|
||||
if footprint.Time.Before(start) || footprint.Time.After(end) {
|
||||
return fmt.Errorf("%w: %s footprint[%d] time must be inside its contact interval",
|
||||
ErrInvalidPlanetOccultationPath, name, footprintIndex)
|
||||
}
|
||||
if footprintIndex > 0 && !footprint.Time.After(footprints[footprintIndex-1].Time) {
|
||||
return fmt.Errorf("%w: %s footprint times must be strictly increasing",
|
||||
ErrInvalidPlanetOccultationPath, name)
|
||||
}
|
||||
if len(footprint.Polygons) == 0 {
|
||||
return fmt.Errorf("%w: %s footprint[%d] must contain a polygon",
|
||||
ErrInvalidPlanetOccultationPath, name, footprintIndex)
|
||||
}
|
||||
for polygonIndex, polygon := range footprint.Polygons {
|
||||
if len(polygon) < 3 {
|
||||
return fmt.Errorf("%w: %s footprint[%d].polygon[%d] must contain at least three points",
|
||||
ErrInvalidPlanetOccultationPath, name, footprintIndex, polygonIndex)
|
||||
}
|
||||
for pointIndex, point := range polygon {
|
||||
pointName := fmt.Sprintf("%s footprint[%d].polygon[%d][%d]", name, footprintIndex, polygonIndex, pointIndex)
|
||||
if err := validateStarOccultationPathPoint(pointName, point); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
|
||||
}
|
||||
if !point.Time.Equal(footprint.Time) {
|
||||
return fmt.Errorf("%w: %s time must match its footprint", ErrInvalidPlanetOccultationPath, pointName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return validateOccultationFootprints(ErrInvalidPlanetOccultationPath, name, footprints, start, end)
|
||||
}
|
||||
|
||||
func validateOccultationFootprints(
|
||||
baseError error,
|
||||
name string,
|
||||
footprints []moon.OccultationFootprint,
|
||||
start, end time.Time,
|
||||
) error {
|
||||
if err := occultationgeo.ValidateFootprints(footprints, start, end); err != nil {
|
||||
return fmt.Errorf("%w: %s %v", baseError, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -307,18 +347,24 @@ func planetOccultationSVGSummaryText(path moon.PlanetOccultationPath, options Pl
|
||||
|
||||
func planetOccultationSVGGreatestText(path moon.PlanetOccultationPath, language string) string {
|
||||
coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude)
|
||||
// 带宽用南北限的地面间距。Greatest.WidthKM 是另一种构造(接触锥可见弧上横向偏移的极差),
|
||||
// 数值可以远大于实际带宽,两者不可互换。
|
||||
partialWidth := path.GreatestLimitSeparationKM
|
||||
if partialWidth <= 0 {
|
||||
partialWidth = path.Greatest.WidthKM
|
||||
}
|
||||
if path.HasTotalBand {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km | total-band width %.1f km",
|
||||
coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM)
|
||||
coordinates, partialWidth, path.GreatestTotalWidthKM)
|
||||
}
|
||||
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km | 全掩带宽 %.1f km",
|
||||
coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM)
|
||||
coordinates, partialWidth, path.GreatestTotalWidthKM)
|
||||
}
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, path.Greatest.WidthKM)
|
||||
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, partialWidth)
|
||||
}
|
||||
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, path.Greatest.WidthKM)
|
||||
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, partialWidth)
|
||||
}
|
||||
|
||||
func planetOccultationSVGFooter(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string {
|
||||
@@ -345,86 +391,310 @@ func writePlanetOccultationMap(
|
||||
starShape moon.StarOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
options StarOccultationSVGOptions,
|
||||
) {
|
||||
) error {
|
||||
layout.mapFrame().WriteOcean(b)
|
||||
writeStarOccultationGraticule(b, layout)
|
||||
writeStarOccultationLand(b, layout)
|
||||
if len(path.PartialFootprints) > 0 {
|
||||
writePlanetOccultationFootprintSweep(b, path.PartialFootprints, layout,
|
||||
"partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34)
|
||||
} else {
|
||||
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
|
||||
"partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34)
|
||||
partial, err := writePlanetOccultationPartialBand(b, path, layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total := planetOccultationBand{}
|
||||
if path.HasTotalBand {
|
||||
if len(path.TotalFootprints) > 0 {
|
||||
writePlanetOccultationFootprintSweep(b, path.TotalFootprints, layout,
|
||||
"total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72)
|
||||
} else {
|
||||
writeOccultationBand(b, path.NorthernTotalLimit, path.SouthernTotalLimit, layout,
|
||||
"total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72)
|
||||
total, err = writePlanetOccultationTotalBand(b, path, layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(path.PartialFootprints) == 0 {
|
||||
writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.25, "")
|
||||
writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.25, "")
|
||||
if !partial.draw.compact && len(path.PartialFootprints) == 0 {
|
||||
writeStarOccultationGeoLine(b, path.NorthernLimit, layout,
|
||||
occultationLineStyle{className: "northern-limit", color: "#a66f18", strokeWidth: 1.25, boundary: true})
|
||||
writeStarOccultationGeoLine(b, path.SouthernLimit, layout,
|
||||
occultationLineStyle{className: "southern-limit", color: "#a66f18", strokeWidth: 1.25, boundary: true})
|
||||
}
|
||||
if path.HasTotalBand && len(path.TotalFootprints) == 0 {
|
||||
writeStarOccultationGeoLine(b, path.NorthernTotalLimit, layout, "northern-total-limit", "#355878", 1.3, "")
|
||||
writeStarOccultationGeoLine(b, path.SouthernTotalLimit, layout, "southern-total-limit", "#355878", 1.3, "")
|
||||
if path.HasTotalBand && !total.draw.compact && len(path.TotalFootprints) == 0 {
|
||||
writeStarOccultationGeoLine(b, path.NorthernTotalLimit, layout,
|
||||
occultationLineStyle{className: "northern-total-limit", color: "#355878", strokeWidth: 1.3, boundary: true})
|
||||
writeStarOccultationGeoLine(b, path.SouthernTotalLimit, layout,
|
||||
occultationLineStyle{className: "southern-total-limit", color: "#355878", strokeWidth: 1.3, boundary: true})
|
||||
}
|
||||
writeStarOccultationGeoLine(b, starShape.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4")
|
||||
// Keep the physical phase curves above both translucent footprint bands;
|
||||
// do not turn them into a closed static-band outline.
|
||||
writeOccultationRiseSetCurves(b, path.RiseSetCurves, layout, "occultation")
|
||||
writeOccultationRiseSetCurves(b, path.TotalRiseSetCurves, layout, "occultation-total")
|
||||
writeOccultationHorizonConnectors(
|
||||
b, partial.footprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, layout,
|
||||
false,
|
||||
)
|
||||
// 事件标记先占位、后绘制:三族标签共用一张占位表,后一族必须绕开先放置的标签。
|
||||
placer := &occultationLabelPlacer{}
|
||||
eventMarkers := planetOccultationEventMarkerDraws(path, layout, options.Language, placer)
|
||||
writeOccultationGreatestTimeContours(b, path.GreatestTimeContours, layout, options, placer)
|
||||
writeStarOccultationGeoLine(b, starShape.CenterLine, layout,
|
||||
occultationLineStyle{className: "center-line", color: "#59676b", strokeWidth: 1.6, dash: "5 4"})
|
||||
writeStarOccultationVisibleCenterLine(b, starShape.CenterLine, layout)
|
||||
excluded := []time.Time{path.Start.Time, path.End.Time}
|
||||
if path.HasTotalBand {
|
||||
excluded = append(excluded, path.TotalStart.Time, path.TotalEnd.Time)
|
||||
}
|
||||
writeOccultationTimeMarkers(b, starShape.CenterLine, layout, options, excluded, path.Greatest.Time)
|
||||
writePlanetOccultationEventMarkers(b, path, layout, options.Language)
|
||||
writeOccultationTimeMarkers(b, starShape.CenterLine, layout, options, excluded, path.Greatest.Time, placer)
|
||||
writePlanetOccultationEventMarkers(b, path, eventMarkers)
|
||||
layout.mapFrame().WriteFrame(b)
|
||||
writePlanetOccultationLegend(b, layout, options.Language, path.HasTotalBand)
|
||||
writePlanetOccultationLegend(
|
||||
b, layout, options.Language, path.HasTotalBand, len(path.RiseSetCurves) > 0,
|
||||
len(path.GreatestTimeContours) > 0,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePlanetOccultationFootprintSweep(
|
||||
// planetOccultationBand 是一次行星掩带填充的结果及它采用的足迹。
|
||||
type planetOccultationBand struct {
|
||||
draw occultationBandDraw
|
||||
footprints []moon.PlanetOccultationFootprint
|
||||
}
|
||||
|
||||
// writePlanetOccultationPartialBand 绘制偏掩带;没有足迹时退回静态限带。
|
||||
func writePlanetOccultationPartialBand(
|
||||
b *strings.Builder,
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
path moon.PlanetOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
layerClass, pathClass, color string,
|
||||
opacity float64,
|
||||
) {
|
||||
var geometry strings.Builder
|
||||
for _, footprint := range footprints {
|
||||
for _, polygon := range footprint.Polygons {
|
||||
geographic := make([]svgmap.GeoPoint, len(polygon))
|
||||
for index, point := range polygon {
|
||||
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
) (planetOccultationBand, error) {
|
||||
footprints := path.PartialBandFootprints
|
||||
compact := len(footprints) > 0
|
||||
if len(footprints) == 0 {
|
||||
footprints = path.PartialFootprints
|
||||
}
|
||||
if len(footprints) == 0 {
|
||||
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
|
||||
"partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34)
|
||||
return planetOccultationBand{}, nil
|
||||
}
|
||||
draw, err := (occultationFootprintSweep{
|
||||
footprints: footprints,
|
||||
contours: path.PartialBandContours,
|
||||
visibilityContours: path.PartialVisibilityContours,
|
||||
northern: path.NorthernLimit,
|
||||
southern: path.SouthernLimit,
|
||||
curves: path.RiseSetCurves,
|
||||
layout: layout,
|
||||
layerClass: "partial-occultation-band-layer",
|
||||
pathClass: "occultation-band",
|
||||
color: "#e0ae43",
|
||||
opacity: 0.34,
|
||||
compactBand: compact,
|
||||
kind: partialOccultationFootprintSweep,
|
||||
}).write(b)
|
||||
if err != nil {
|
||||
return planetOccultationBand{}, err
|
||||
}
|
||||
return planetOccultationBand{draw: draw, footprints: footprints}, nil
|
||||
}
|
||||
|
||||
// writePlanetOccultationTotalBand 绘制全掩带;没有足迹时退回静态内接触限带。
|
||||
func writePlanetOccultationTotalBand(
|
||||
b *strings.Builder,
|
||||
path moon.PlanetOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
) (planetOccultationBand, error) {
|
||||
footprints := path.TotalBandFootprints
|
||||
compact := len(footprints) > 0
|
||||
if len(footprints) == 0 {
|
||||
footprints = path.TotalFootprints
|
||||
}
|
||||
if len(footprints) == 0 {
|
||||
writeOccultationBand(b, path.NorthernTotalLimit, path.SouthernTotalLimit, layout,
|
||||
"total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72)
|
||||
return planetOccultationBand{}, nil
|
||||
}
|
||||
draw, err := (occultationFootprintSweep{
|
||||
footprints: footprints,
|
||||
contours: path.TotalBandContours,
|
||||
visibilityContours: path.TotalVisibilityContours,
|
||||
northern: path.NorthernTotalLimit,
|
||||
southern: path.SouthernTotalLimit,
|
||||
curves: path.TotalRiseSetCurves,
|
||||
layout: layout,
|
||||
layerClass: "total-occultation-band-layer",
|
||||
pathClass: "total-occultation-band",
|
||||
color: "#607d98",
|
||||
opacity: 0.72,
|
||||
compactBand: compact,
|
||||
kind: totalOccultationFootprintSweep,
|
||||
}).write(b)
|
||||
if err != nil {
|
||||
return planetOccultationBand{}, err
|
||||
}
|
||||
return planetOccultationBand{draw: draw, footprints: footprints}, nil
|
||||
}
|
||||
|
||||
type occultationFootprintSweepKind uint8
|
||||
|
||||
const (
|
||||
starOccultationFootprintSweep occultationFootprintSweepKind = iota
|
||||
partialOccultationFootprintSweep
|
||||
totalOccultationFootprintSweep
|
||||
)
|
||||
|
||||
// occultationBandSource 说明填充掩带几何的来源。
|
||||
type occultationBandSource uint8
|
||||
|
||||
const (
|
||||
occultationBandAbsent occultationBandSource = iota
|
||||
occultationBandFootprints
|
||||
occultationBandEnvelope
|
||||
occultationBandSweep
|
||||
)
|
||||
|
||||
// occultationBandDraw 报告一次掩带填充的结果。
|
||||
type occultationBandDraw struct {
|
||||
source occultationBandSource
|
||||
// compact 表示填充由合并后的紧凑带构造,调用方不再叠加限线。
|
||||
compact bool
|
||||
}
|
||||
|
||||
func (draw occultationBandDraw) drawn() bool { return draw.source != occultationBandAbsent }
|
||||
|
||||
// claimBoundary 报告图例能否声明掩带边界:解析包络与直接绘制的瞬时足迹都可以,
|
||||
// 只有足迹扫掠合并回退不保证边界就是解析边界。
|
||||
func (draw occultationBandDraw) claimBoundary() bool {
|
||||
return draw.source == occultationBandEnvelope || draw.source == occultationBandFootprints
|
||||
}
|
||||
|
||||
// occultationFootprintSweep 是一次掩带填充需要的几何输入与画布样式。
|
||||
type occultationFootprintSweep struct {
|
||||
footprints []moon.PlanetOccultationFootprint
|
||||
contours [][]moon.OccultationPathPoint
|
||||
visibilityContours [][]moon.OccultationPathPoint
|
||||
northern, southern []moon.OccultationPathPoint
|
||||
curves []moon.OccultationRiseSetCurve
|
||||
layout starOccultationSVGLayout
|
||||
layerClass string
|
||||
pathClass string
|
||||
color string
|
||||
opacity float64
|
||||
compactBand bool
|
||||
kind occultationFootprintSweepKind
|
||||
}
|
||||
|
||||
// write 写出一个复合填充路径;几何引擎报错时返回错误,不退回原始瞬时足迹。
|
||||
func (s occultationFootprintSweep) write(b *strings.Builder) (occultationBandDraw, error) {
|
||||
rings, source, err := s.resolveRings()
|
||||
if err != nil {
|
||||
return occultationBandDraw{}, err
|
||||
}
|
||||
draw := occultationBandDraw{compact: s.compactBand && len(rings) > 0}
|
||||
if len(rings) == 0 {
|
||||
return draw, nil
|
||||
}
|
||||
draw.source = source
|
||||
stroke, strokeWidth := s.outlineStyle(source)
|
||||
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-opacity="0.88" stroke-width="%.2f"><path class="%s" fill-rule="nonzero" d="`,
|
||||
s.layerClass, s.color, s.opacity, stroke, strokeWidth, s.pathClass)
|
||||
for _, ring := range rings {
|
||||
writeOccultationPathCommands(b, ring)
|
||||
b.WriteString("Z ")
|
||||
}
|
||||
b.WriteString(`"/></g>`)
|
||||
return draw, nil
|
||||
}
|
||||
|
||||
// resolveRings 返回裁剪到画布、定向并简化后的填充环。
|
||||
func (s occultationFootprintSweep) resolveRings() ([][]occultationScreenPoint, occultationBandSource, error) {
|
||||
source := occultationBandFootprints
|
||||
var polygons [][]geodata.GeoPoint
|
||||
if s.compactBand {
|
||||
merged, authoritative, err := s.mergedBandPolygons()
|
||||
if err != nil {
|
||||
return nil, source, err
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil, source, errors.New("occultation band geometry produced no polygon")
|
||||
}
|
||||
polygons = merged
|
||||
source = occultationBandSweep
|
||||
if authoritative {
|
||||
source = occultationBandEnvelope
|
||||
}
|
||||
} else {
|
||||
polygons = planetOccultationFootprintPolygons(s.footprints)
|
||||
}
|
||||
rings := make([][]occultationScreenPoint, 0, len(polygons))
|
||||
for _, polygon := range polygons {
|
||||
geographic := make([]svgmap.GeoPoint, len(polygon))
|
||||
for index, point := range polygon {
|
||||
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
for _, fragment := range svgmap.PolygonFragments(geographic, s.layout.mapFrame().Clip()) {
|
||||
if len(fragment) < 3 {
|
||||
continue
|
||||
}
|
||||
for _, fragment := range svgmap.PolygonFragments(geographic, layout.projection) {
|
||||
if len(fragment) < 3 {
|
||||
continue
|
||||
if planetOccultationProjectedArea(s.layout, fragment) < 0 {
|
||||
for left, right := 0, len(fragment)-1; left < right; left, right = left+1, right-1 {
|
||||
fragment[left], fragment[right] = fragment[right], fragment[left]
|
||||
}
|
||||
if planetOccultationProjectedArea(layout, fragment) < 0 {
|
||||
for left, right := 0, len(fragment)-1; left < right; left, right = left+1, right-1 {
|
||||
fragment[left], fragment[right] = fragment[right], fragment[left]
|
||||
}
|
||||
}
|
||||
for index, point := range fragment {
|
||||
x, y := layout.project(point.Longitude, point.Latitude)
|
||||
command := "L"
|
||||
if index == 0 {
|
||||
command = "M"
|
||||
}
|
||||
fmt.Fprintf(&geometry, "%s %.3f %.3f ", command, x, y)
|
||||
}
|
||||
geometry.WriteString("Z ")
|
||||
}
|
||||
rings = append(rings, s.layout.projectOccultationGeoRing(fragment))
|
||||
}
|
||||
}
|
||||
if geometry.Len() == 0 {
|
||||
return
|
||||
return rings, source, nil
|
||||
}
|
||||
|
||||
// mergedBandPolygons 命中缓存时直接复用只读的合并结果:紧凑带的合并远贵于逐足迹绘制。
|
||||
func (s occultationFootprintSweep) mergedBandPolygons() ([][]geodata.GeoPoint, bool, error) {
|
||||
key := occultationBandCacheKey{kind: s.kind, digest: occultationBandDigest(s)}
|
||||
if cached, ok := lookupOccultationBandPolygons(key); ok {
|
||||
return cached.polygons, cached.authoritative, nil
|
||||
}
|
||||
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="none"><path class="%s" fill-rule="nonzero" d="%s"/></g>`,
|
||||
layerClass, color, opacity, pathClass, geometry.String())
|
||||
polygons, authoritative, err := s.mergeBandPolygons()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
storeOccultationBandPolygons(key, occultationBandPolygons{polygons: polygons, authoritative: authoritative})
|
||||
return polygons, authoritative, nil
|
||||
}
|
||||
|
||||
func (s occultationFootprintSweep) mergeBandPolygons() ([][]geodata.GeoPoint, bool, error) {
|
||||
if len(s.contours) == 0 {
|
||||
if s.kind == totalOccultationFootprintSweep {
|
||||
return occultationgeo.VisibleTotalBandPolygons(s.footprints, s.northern, s.southern, s.curves)
|
||||
}
|
||||
return occultationgeo.VisibleBandPolygons(s.footprints, s.northern, s.southern, s.curves)
|
||||
}
|
||||
if s.kind == totalOccultationFootprintSweep {
|
||||
return occultationgeo.VisibleTotalBandPolygonsFromAnalyticContours(
|
||||
s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves,
|
||||
)
|
||||
}
|
||||
if s.kind == starOccultationFootprintSweep {
|
||||
return occultationgeo.VisibleStarBandPolygonsFromAnalyticContours(
|
||||
s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves,
|
||||
)
|
||||
}
|
||||
return occultationgeo.VisibleBandPolygonsFromAnalyticContours(
|
||||
s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves,
|
||||
)
|
||||
}
|
||||
|
||||
// outlineStyle 只给解析轮廓描边;总掩带保持纯填充外观。
|
||||
func (s occultationFootprintSweep) outlineStyle(source occultationBandSource) (string, float64) {
|
||||
if source != occultationBandEnvelope || s.pathClass == "total-occultation-band" {
|
||||
return "none", 0
|
||||
}
|
||||
return s.color, 1.3
|
||||
}
|
||||
|
||||
func planetOccultationFootprintPolygons(footprints []moon.PlanetOccultationFootprint) [][]geodata.GeoPoint {
|
||||
polygons := make([][]geodata.GeoPoint, 0, len(footprints))
|
||||
for _, footprint := range footprints {
|
||||
for _, polygon := range footprint.Polygons {
|
||||
geographic := make([]geodata.GeoPoint, len(polygon))
|
||||
for index, point := range polygon {
|
||||
geographic[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
polygons = append(polygons, geographic)
|
||||
}
|
||||
}
|
||||
return polygons
|
||||
}
|
||||
|
||||
func planetOccultationProjectedArea(layout starOccultationSVGLayout, points []svgmap.GeoPoint) float64 {
|
||||
@@ -447,12 +717,13 @@ type planetOccultationProjectedEventMarker struct {
|
||||
placement starOccultationEventMarkerPlacement
|
||||
}
|
||||
|
||||
func writePlanetOccultationEventMarkers(
|
||||
b *strings.Builder,
|
||||
// planetOccultationEventMarkerDraws 计算全部事件标记;近似重合的外接触与内接触先分开标签再登记占位。
|
||||
func planetOccultationEventMarkerDraws(
|
||||
path moon.PlanetOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
language string,
|
||||
) {
|
||||
placer *occultationLabelPlacer,
|
||||
) []planetOccultationProjectedEventMarker {
|
||||
markers := []planetOccultationProjectedEventMarker{
|
||||
{point: path.Start, label: planetOccultationEventLabel("start", language), kind: "start"},
|
||||
}
|
||||
@@ -485,6 +756,34 @@ func writePlanetOccultationEventMarkers(
|
||||
separatePlanetOccultationMarkerPair(markers, 0, 1, layout)
|
||||
separatePlanetOccultationMarkerPair(markers, 4, 3, layout)
|
||||
}
|
||||
visible := make([]starOccultationEventMarkerDraw, 0, len(markers))
|
||||
for index := range markers {
|
||||
if !markers[index].visible {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, starOccultationEventMarkerDraw{
|
||||
x: markers[index].x, y: markers[index].y,
|
||||
label: markers[index].label, kind: markers[index].kind,
|
||||
placement: markers[index].placement,
|
||||
})
|
||||
}
|
||||
reserveOccultationEventMarkerLabels(visible, layout, placer)
|
||||
offset := 0
|
||||
for index := range markers {
|
||||
if !markers[index].visible {
|
||||
continue
|
||||
}
|
||||
markers[index].placement = visible[offset].placement
|
||||
offset++
|
||||
}
|
||||
return markers
|
||||
}
|
||||
|
||||
func writePlanetOccultationEventMarkers(
|
||||
b *strings.Builder,
|
||||
path moon.PlanetOccultationPath,
|
||||
markers []planetOccultationProjectedEventMarker,
|
||||
) {
|
||||
drawOrder := make([]int, len(markers))
|
||||
for index := range drawOrder {
|
||||
drawOrder[index] = index
|
||||
@@ -537,39 +836,87 @@ func writePlanetOccultationLegend(
|
||||
layout starOccultationSVGLayout,
|
||||
language string,
|
||||
hasTotal bool,
|
||||
hasRiseSet bool,
|
||||
hasIsochrones bool,
|
||||
) {
|
||||
labels := []string{"部分掩带", "可见中心线", "几何中心线"}
|
||||
type legendItem struct {
|
||||
label string
|
||||
kind string
|
||||
}
|
||||
items := []legendItem{{"部分掩带", "partial"}}
|
||||
if hasTotal {
|
||||
labels = []string{"部分掩带", "全掩带", "可见中心线", "几何中心线"}
|
||||
items = append(items, legendItem{"全掩带", "total"})
|
||||
}
|
||||
items = append(items, legendItem{"可见中心线", "visible"}, legendItem{"几何中心线", "geometric"})
|
||||
if hasRiseSet {
|
||||
items = append(items, legendItem{"初掩/掩甚/终掩月升月落线", "rise-set"})
|
||||
}
|
||||
if hasIsochrones {
|
||||
items = append(items, legendItem{"掩甚时刻等时线", "isochrone"})
|
||||
}
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
labels = []string{"Partial band", "Visible center", "Geometric center"}
|
||||
if hasTotal {
|
||||
labels = []string{"Partial band", "Total band", "Visible center", "Geometric center"}
|
||||
for index := range items {
|
||||
switch items[index].kind {
|
||||
case "partial":
|
||||
items[index].label = "Partial band"
|
||||
case "total":
|
||||
items[index].label = "Total band"
|
||||
case "visible":
|
||||
items[index].label = "Visible center"
|
||||
case "geometric":
|
||||
items[index].label = "Geometric center"
|
||||
case "rise-set":
|
||||
items[index].label = "Rise/set phase lines"
|
||||
case "isochrone":
|
||||
items[index].label = "Greatest-time isochrones"
|
||||
}
|
||||
}
|
||||
}
|
||||
y := layout.mapY + layout.mapHeight + 30
|
||||
itemWidth := layout.mapWidth / float64(len(labels))
|
||||
for index, label := range labels {
|
||||
x := layout.mapX + float64(index)*itemWidth
|
||||
switch {
|
||||
case index == 0:
|
||||
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="#e0ae43" fill-opacity="0.55"/>`, x, y-10)
|
||||
case hasTotal && index == 1:
|
||||
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="#607d98" fill-opacity="0.82"/>`, x, y-10)
|
||||
legend := make([]occultationLegendItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
current := occultationLegendItem{label: item.label, markerWidth: 18}
|
||||
switch item.kind {
|
||||
case "partial":
|
||||
current.fill, current.fillOpacity = "#e0ae43", 0.55
|
||||
case "total":
|
||||
current.fill, current.fillOpacity = "#607d98", 0.82
|
||||
case "geometric":
|
||||
current.color, current.dash = "#59676b", "5 4"
|
||||
case "rise-set":
|
||||
current.color = "#d97706"
|
||||
case "isochrone":
|
||||
current.color = "#1f6fb2"
|
||||
default:
|
||||
color, dash := "#087f8c", ""
|
||||
if index == len(labels)-1 {
|
||||
color, dash = "#59676b", "5 4"
|
||||
}
|
||||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="2"`, x, y-5, x+18, y-5, color)
|
||||
if dash != "" {
|
||||
fmt.Fprintf(b, ` stroke-dasharray="%s"`, dash)
|
||||
}
|
||||
b.WriteString(`/>`)
|
||||
current.color = "#087f8c"
|
||||
}
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="9">%s</text>`,
|
||||
x+23, y, html.EscapeString(label))
|
||||
legend = append(legend, current)
|
||||
}
|
||||
writeOccultationLegendItems(b, y, layout, legend, 9)
|
||||
}
|
||||
|
||||
func writeOccultationHorizonConnectors(
|
||||
b *strings.Builder,
|
||||
footprints []moon.OccultationFootprint,
|
||||
northern, southern []moon.OccultationPathPoint,
|
||||
curves []moon.OccultationRiseSetCurve,
|
||||
layout starOccultationSVGLayout,
|
||||
pointSource bool,
|
||||
) {
|
||||
connectors := occultationgeo.HorizonConnectorSegments(footprints, curves, northern, southern)
|
||||
if pointSource {
|
||||
connectors = occultationgeo.StarHorizonConnectorSegments(footprints, curves, northern, southern)
|
||||
}
|
||||
for _, connector := range connectors {
|
||||
if len(connector.Points) < 2 {
|
||||
continue
|
||||
}
|
||||
points := occultationgeo.DensifyOccultationPathPoints(connector.Points, 35)
|
||||
className := fmt.Sprintf("occultation-rise-set-boundary occultation-horizon-%s", connector.Direction)
|
||||
writeStarOccultationGeoLine(
|
||||
b, points, layout,
|
||||
occultationLineStyle{className: className, color: "#d97706", strokeWidth: 1.35},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,3 +992,39 @@ func planetOccultationChineseName(planet moon.OccultationPlanet) string {
|
||||
return "行星"
|
||||
}
|
||||
}
|
||||
|
||||
// occultationPolarBandReach 是判定掩带已经包住极点的纬度阈值。
|
||||
const occultationPolarBandReach = 89.0
|
||||
|
||||
// planetOccultationBandLatitudeRange 返回掩带(中心线、南北限与足印)的纬度范围。
|
||||
func planetOccultationBandLatitudeRange(path moon.PlanetOccultationPath) (float64, float64) {
|
||||
minimumLatitude, maximumLatitude := path.Greatest.Latitude, path.Greatest.Latitude
|
||||
for _, series := range [][]moon.OccultationPathPoint{
|
||||
path.CenterLine, path.NorthernLimit, path.SouthernLimit,
|
||||
path.NorthernTotalLimit, path.SouthernTotalLimit,
|
||||
} {
|
||||
for _, point := range series {
|
||||
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
|
||||
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
for _, footprints := range [][]moon.PlanetOccultationFootprint{
|
||||
path.PartialFootprints, path.PartialBandFootprints,
|
||||
path.TotalFootprints, path.TotalBandFootprints,
|
||||
} {
|
||||
for _, footprint := range footprints {
|
||||
for _, polygon := range footprint.Polygons {
|
||||
for _, point := range polygon {
|
||||
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
|
||||
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minimumLatitude, maximumLatitude
|
||||
}
|
||||
|
||||
// planetOccultationOrthographicCentre 取掩甚点作为正射球面的视点。
|
||||
func planetOccultationOrthographicCentre(path moon.PlanetOccultationPath) svgmap.GeoPoint {
|
||||
return svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user