Files
astro/moon/svg/occultation_planet.go
T

1031 lines
39 KiB
Go
Raw Normal View History

package svg
import (
"errors"
"fmt"
"math"
"strings"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
// ErrInvalidPlanetOccultationPath 表示有限盘面路径数据格式错误。
// ErrInvalidPlanetOccultationPath reports malformed finite-disk path data.
var ErrInvalidPlanetOccultationPath = errors.New("invalid planetary occultation path")
// ErrInvalidPlanetOccultationSVGOptions 表示行星 SVG 画布选项无效。
// ErrInvalidPlanetOccultationSVGOptions reports invalid planetary SVG canvas options.
var ErrInvalidPlanetOccultationSVGOptions = errors.New("invalid planetary occultation SVG options")
const (
planetOccultationSVGMinimumWidth = 640
planetOccultationSVGMinimumHeight = 480
)
// PlanetOccultationSVGOptions 控制全球行星月掩路径 SVG。
// PlanetOccultationSVGOptions controls a global planetary-occultation path SVG.
type PlanetOccultationSVGOptions = StarOccultationSVGOptions
// FindPlanetOccultationSVGs 搜索时间窗口并渲染每条全球有限盘面行星月掩路径。
// FindPlanetOccultationSVGs searches a time window and renders every global finite-disk planetary occultation path.
func FindPlanetOccultationSVGs(
start, end time.Time,
planet moon.OccultationPlanet,
pathOptions moon.OccultationPathOptions,
options PlanetOccultationSVGOptions,
) ([]string, error) {
paths, err := moon.FindPlanetOccultationPaths(start, end, planet, pathOptions)
if err != nil {
return nil, err
}
diagrams := make([]string, 0, len(paths))
for _, path := range paths {
diagram, renderErr := PlanetOccultationPathSVG(path, options)
if renderErr != nil {
return nil, renderErr
}
diagrams = append(diagrams, diagram)
}
return diagrams, nil
}
// PlanetOccultationPathSVG 渲染已计算的有限盘面行星路径。
// PlanetOccultationPathSVG renders a computed finite-disk planetary path.
func PlanetOccultationPathSVG(
path moon.PlanetOccultationPath,
options PlanetOccultationSVGOptions,
) (string, error) {
if err := validatePlanetOccultationPath(path); err != nil {
return "", err
}
targetID := path.TargetID
if targetID == "" {
targetID = path.Planet.String()
}
if err := validateStarOccultationSVGOptions(options); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationSVGOptions, err)
}
if options.Width > 0 && options.Width < planetOccultationSVGMinimumWidth {
return "", fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidPlanetOccultationSVGOptions, planetOccultationSVGMinimumWidth)
}
if options.Height > 0 && options.Height < planetOccultationSVGMinimumHeight {
return "", fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidPlanetOccultationSVGOptions, planetOccultationSVGMinimumHeight)
}
options = normalizeStarOccultationSVGOptions(options)
starShape := planetOccultationStarShape(path, targetID)
if targetID == path.Planet.String() && options.Language == starOccultationSVGLanguageChinese {
targetID = planetOccultationChineseName(path.Planet)
starShape.TargetID = targetID
}
options.Projection = MapProjection(resolvePlanetOccultationMapProjection(path, options.Projection))
// 默认文案会先写回 options,必须在填充之前记下调用方真正给出的字段。
flags := starOccultationSVGTextFlags(options)
options = planetOccultationSVGDefaults(path, options)
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, maximumLatitude := planetOccultationBandLatitudeRange(path)
// 掩带包住极点时等经纬会把它拉成横贯上下边缘的长条,必须改用极区图;
// 判据要看带子本身的纬度范围,掩甚点在中纬也可能绕极。
if requested == MapProjectionAuto {
if maximumLatitude >= occultationPolarBandReach {
return svgmap.ProjectionNorthPolar
}
if minimumLatitude <= -occultationPolarBandReach {
return svgmap.ProjectionSouthPolar
}
}
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
}
func planetOccultationStarShape(path moon.PlanetOccultationPath, targetID string) moon.StarOccultationPath {
return moon.StarOccultationPath{
TargetID: targetID,
Start: path.Start,
Greatest: path.Greatest,
End: path.End,
Complete: path.Complete,
CenterLine: path.CenterLine,
NorthernLimit: path.NorthernLimit,
SouthernLimit: path.SouthernLimit,
RiseSetCurves: path.RiseSetCurves,
Step: path.Step,
TargetSpacingKM: path.TargetSpacingKM,
}
}
func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error {
if err := path.Planet.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
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 || 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)
}
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)
}
if err := validateStarOccultationPathPoint("total start", path.TotalStart); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if err := validateStarOccultationPathPoint("total end", path.TotalEnd); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if !path.TotalStart.Time.After(path.Start.Time) || !path.TotalStart.Time.Before(path.Greatest.Time) ||
!path.TotalEnd.Time.After(path.Greatest.Time) || !path.TotalEnd.Time.Before(path.End.Time) {
return fmt.Errorf("%w: total-band times must be inside outer start, greatest, and end", ErrInvalidPlanetOccultationPath)
}
if !starOccultationFinite(path.GreatestTotalWidthKM) || path.GreatestTotalWidthKM <= 0 ||
path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
return fmt.Errorf("%w: total-band width must be positive and narrower than the outer band", ErrInvalidPlanetOccultationPath)
}
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 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(
name string,
footprints []moon.PlanetOccultationFootprint,
start, end time.Time,
) error {
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
}
func validatePlanetOccultationTotalLimits(path moon.PlanetOccultationPath) error {
if len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) || len(path.NorthernTotalLimit) < 2 {
return fmt.Errorf("%w: total northern and southern limits must contain matching samples", ErrInvalidPlanetOccultationPath)
}
for index := range path.NorthernTotalLimit {
for _, item := range []struct {
name string
point moon.OccultationPathPoint
}{
{fmt.Sprintf("northern total limit[%d]", index), path.NorthernTotalLimit[index]},
{fmt.Sprintf("southern total limit[%d]", index), path.SouthernTotalLimit[index]},
} {
if err := validateStarOccultationPathPoint(item.name, item.point); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
}
if !path.NorthernTotalLimit[index].Time.Equal(path.SouthernTotalLimit[index].Time) {
return fmt.Errorf("%w: total limit sample %d times must match", ErrInvalidPlanetOccultationPath, index)
}
if index > 0 && !path.NorthernTotalLimit[index].Time.After(path.NorthernTotalLimit[index-1].Time) {
return fmt.Errorf("%w: total limit times must be strictly increasing", ErrInvalidPlanetOccultationPath)
}
}
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("%w: total limits must span total start through total end", ErrInvalidPlanetOccultationPath)
}
return nil
}
func planetOccultationSVGDefaults(
path moon.PlanetOccultationPath,
options PlanetOccultationSVGOptions,
) PlanetOccultationSVGOptions {
if options.SummaryText == "" {
options.SummaryText = planetOccultationSVGSummaryText(path, options)
}
if options.GreatestText == "" {
options.GreatestText = planetOccultationSVGGreatestText(path, options.Language)
}
if options.MapTitle == "" {
if options.Language == starOccultationSVGLanguageEnglish {
options.MapTitle = "Global partial- and total-occultation bands"
} else {
options.MapTitle = "全球部分掩带与全掩带"
}
}
if options.ContactsTitle == "" {
if options.Language == starOccultationSVGLanguageEnglish {
options.ContactsTitle = "Global phases"
} else {
options.ContactsTitle = "全球阶段"
}
}
if options.FooterNote == "" {
options.FooterNote = planetOccultationSVGFooter(path, options)
}
return options
}
func planetOccultationSVGSummaryText(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string {
start := path.Start.Time.In(options.Location)
greatest := path.Greatest.Time.In(options.Location)
end := path.End.Time.In(options.Location)
zone := starOccultationLocationLabel(greatest, options.Location)
if !path.HasTotalBand {
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Partial begins %s | Greatest %s | Partial ends %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
return fmt.Sprintf("外掩始 %s | 掩甚 %s | 外掩终 %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
totalStart := path.TotalStart.Time.In(options.Location)
totalEnd := path.TotalEnd.Time.In(options.Location)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Partial begins %s | Total begins %s | Greatest %s | Total ends %s | Partial ends %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(totalStart, !starOccultationSameDate(start, totalStart)),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(totalEnd, !starOccultationSameDate(start, totalEnd)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
return fmt.Sprintf("外掩始 %s | 全掩始 %s | 掩甚 %s | 全掩终 %s | 外掩终 %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(totalStart, !starOccultationSameDate(start, totalStart)),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(totalEnd, !starOccultationSameDate(start, totalEnd)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
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, partialWidth, path.GreatestTotalWidthKM)
}
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km | 全掩带宽 %.1f km",
coordinates, partialWidth, path.GreatestTotalWidthKM)
}
if language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, partialWidth)
}
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, partialWidth)
}
func planetOccultationSVGFooter(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string {
projection := starOccultationProjectionLabel(options.Projection, options.Language)
ringNote := ""
if path.Planet == moon.OccultationSaturn {
if options.Language == starOccultationSVGLanguageEnglish {
ringNote = " Saturn's rings are not included in contact geometry."
} else {
ringNote = "土星环不参与接触计算。"
}
}
if options.Language == starOccultationSVGLanguageEnglish {
return projection + "; Natural Earth 1:50m physical land, no administrative boundaries. " +
"The outer-contact cone bounds any disk overlap; the inner-contact cone bounds full planet-disk coverage." + ringNote
}
return projection + ";Natural Earth 1:50m 物理陆地底图,不含行政边界;" +
"外切锥面界定任意圆盘重叠,内切锥面界定行星圆盘完全被月球遮住。" + ringNote
}
func writePlanetOccultationMap(
b *strings.Builder,
path moon.PlanetOccultationPath,
starShape moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) error {
layout.mapFrame().WriteOcean(b)
writeStarOccultationGraticule(b, layout)
writeStarOccultationLand(b, layout)
partial, err := writePlanetOccultationPartialBand(b, path, layout)
if err != nil {
return err
}
total := planetOccultationBand{}
if path.HasTotalBand {
total, err = writePlanetOccultationTotalBand(b, path, layout)
if err != nil {
return err
}
}
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 && !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})
}
// 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, placer)
writePlanetOccultationEventMarkers(b, path, eventMarkers)
layout.mapFrame().WriteFrame(b)
writePlanetOccultationLegend(
b, layout, options.Language, path.HasTotalBand, len(path.RiseSetCurves) > 0,
len(path.GreatestTimeContours) > 0,
)
return nil
}
// planetOccultationBand 是一次行星掩带填充的结果及它采用的足迹。
type planetOccultationBand struct {
draw occultationBandDraw
footprints []moon.PlanetOccultationFootprint
}
// writePlanetOccultationPartialBand 绘制偏掩带;没有足迹时退回静态限带。
func writePlanetOccultationPartialBand(
b *strings.Builder,
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
) (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
}
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]
}
}
rings = append(rings, s.layout.projectOccultationGeoRing(fragment))
}
}
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
}
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 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
x1, y1 := layout.project(point.Longitude, point.Latitude)
x2, y2 := layout.project(next.Longitude, next.Latitude)
area += x1*y2 - x2*y1
}
return area / 2
}
type planetOccultationProjectedEventMarker struct {
point moon.OccultationPathPoint
label string
kind string
x, y float64
visible bool
placement starOccultationEventMarkerPlacement
}
// planetOccultationEventMarkerDraws 计算全部事件标记;近似重合的外接触与内接触先分开标签再登记占位。
func planetOccultationEventMarkerDraws(
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
language string,
placer *occultationLabelPlacer,
) []planetOccultationProjectedEventMarker {
markers := []planetOccultationProjectedEventMarker{
{point: path.Start, label: planetOccultationEventLabel("start", language), kind: "start"},
}
if path.HasTotalBand {
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.TotalStart, label: planetOccultationEventLabel("total-start", language), kind: "total-start",
})
}
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.Greatest, label: planetOccultationEventLabel("greatest", language), kind: "greatest",
})
if path.HasTotalBand {
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.TotalEnd, label: planetOccultationEventLabel("total-end", language), kind: "total-end",
})
}
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.End, label: planetOccultationEventLabel("end", language), kind: "end",
})
for index := range markers {
markers[index].x, markers[index].y, markers[index].visible = layout.mapFrame().Project(
markers[index].point.Longitude, markers[index].point.Latitude,
)
markers[index].placement = starOccultationDefaultEventMarkerPlacement(
markers[index].x, markers[index].y, markers[index].kind, layout,
)
}
if path.HasTotalBand {
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
}
if path.HasTotalBand {
// 先绘制接近重合的外接触,再绘制较小的内接触点,使两个真实地理位置都保持可读。
// Draw the near-coincident outer contacts first, then the smaller inner contacts, so both true geographic positions remain legible.
drawOrder = []int{0, 4, 1, 3, 2}
}
for _, index := range drawOrder {
marker := markers[index]
if marker.visible {
writeStarOccultationProjectedEventMarker(
b, marker.x, marker.y, marker.label, marker.kind, marker.placement,
)
}
}
}
func separatePlanetOccultationMarkerPair(
markers []planetOccultationProjectedEventMarker,
outerIndex, totalIndex int,
layout starOccultationSVGLayout,
) {
outer := &markers[outerIndex]
total := &markers[totalIndex]
if !outer.visible || !total.visible || math.Hypot(outer.x-total.x, outer.y-total.y) >= 30 {
return
}
upper := math.Min(outer.y, total.y) - 10
lower := math.Max(outer.y, total.y) + 18
top := layout.mapY + 12
bottom := layout.mapY + layout.mapHeight - 5
if lower > bottom {
lower = math.Min(outer.y, total.y) - 10
upper = lower - 16
}
if upper < top {
upper = math.Max(outer.y, total.y) + 16
lower = upper + 16
}
outer.placement.labelY = math.Max(top, math.Min(bottom, upper))
total.placement.labelY = math.Max(top, math.Min(bottom, lower))
outer.placement.leader = true
total.placement.leader = true
}
func writePlanetOccultationLegend(
b *strings.Builder,
layout starOccultationSVGLayout,
language string,
hasTotal bool,
hasRiseSet bool,
hasIsochrones bool,
) {
type legendItem struct {
label string
kind string
}
items := []legendItem{{"部分掩带", "partial"}}
if hasTotal {
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 {
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
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:
current.color = "#087f8c"
}
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},
)
}
}
func writePlanetOccultationEventsPanel(
b *strings.Builder,
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
options PlanetOccultationSVGOptions,
) {
writeOccultationEventsPanel(b, planetOccultationSVGEventRows(path, options.Language), layout, options)
}
func planetOccultationSVGEventRows(path moon.PlanetOccultationPath, language string) []starOccultationEventRow {
rows := []starOccultationEventRow{
{name: planetOccultationEventLabel("start", language), point: path.Start},
}
if path.HasTotalBand {
rows = append(rows, starOccultationEventRow{name: planetOccultationEventLabel("total-start", language), point: path.TotalStart})
}
rows = append(rows, starOccultationEventRow{name: planetOccultationEventLabel("greatest", language), point: path.Greatest})
if path.HasTotalBand {
rows = append(rows, starOccultationEventRow{name: planetOccultationEventLabel("total-end", language), point: path.TotalEnd})
}
return append(rows, starOccultationEventRow{name: planetOccultationEventLabel("end", language), point: path.End})
}
func planetOccultationEventLabel(kind, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch kind {
case "start":
return "Partial begins"
case "total-start":
return "Total begins"
case "greatest":
return "Greatest"
case "total-end":
return "Total ends"
default:
return "Partial ends"
}
}
switch kind {
case "start":
return "外掩始"
case "total-start":
return "全掩始"
case "greatest":
return "掩甚"
case "total-end":
return "全掩终"
default:
return "外掩终"
}
}
func planetOccultationChineseName(planet moon.OccultationPlanet) string {
switch planet {
case moon.OccultationMercury:
return "水星"
case moon.OccultationVenus:
return "金星"
case moon.OccultationMars:
return "火星"
case moon.OccultationJupiter:
return "木星"
case moon.OccultationSaturn:
return "土星"
case moon.OccultationUranus:
return "天王星"
case moon.OccultationNeptune:
return "海王星"
default:
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}
}