16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
821 lines
35 KiB
Go
821 lines
35 KiB
Go
package svg
|
||
|
||
import (
|
||
"b612.me/astro/internal/timenote"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"b612.me/astro"
|
||
"b612.me/astro/internal/svgasset"
|
||
"b612.me/astro/internal/svgchart"
|
||
"b612.me/astro/moon"
|
||
)
|
||
|
||
const (
|
||
localPlanetOccultationSVGDefaultWidth = 1040
|
||
localPlanetOccultationSVGDefaultHeight = 760
|
||
localPlanetOccultationSVGMinimumWidth = 760
|
||
localPlanetOccultationSVGMinimumHeight = 600
|
||
localPlanetOccultationSVGDefaultStep = 2 * time.Minute
|
||
)
|
||
|
||
// ErrInvalidLocalPlanetOccultationSVGOptions 表示本地行星图表选项无效。
|
||
// ErrInvalidLocalPlanetOccultationSVGOptions reports invalid local planetary chart options.
|
||
var ErrInvalidLocalPlanetOccultationSVGOptions = errors.New("invalid local planetary occultation SVG options")
|
||
|
||
// ErrInvalidLocalPlanetOccultationInfo 表示固定地点行星事件数据格式错误。
|
||
// ErrInvalidLocalPlanetOccultationInfo reports malformed fixed-site planetary event data.
|
||
var ErrInvalidLocalPlanetOccultationInfo = errors.New("invalid local planetary occultation info")
|
||
|
||
// LocalPlanetOccultationSVGOptions 控制固定地点月球与行星盘面图。
|
||
// LocalPlanetOccultationSVGOptions controls a fixed-site Moon-planet disk chart.
|
||
type LocalPlanetOccultationSVGOptions struct {
|
||
// Width 和 Height 是 SVG 画布尺寸;非正值使用默认值。
|
||
// Width and Height are SVG canvas dimensions. Values <= 0 use defaults.
|
||
Width int
|
||
Height int
|
||
// Step 是月心行星轨迹请求采样步长;零值使用两分钟,负值无效,正值小于一秒时提升到一秒。长事件可能增大实际步长,以保持基础轨迹在有界采样预算内。
|
||
// Step is the requested Moon-centered planetary-track sampling step. Zero uses two minutes; negative values are invalid. Positive values below one second are raised to one second, and long events may use a larger effective step to keep the base track within its bounded sample budget.
|
||
Step time.Duration
|
||
// 空文本字段使用本地化的自动标签。
|
||
// Empty text fields use localized automatic labels.
|
||
Title string
|
||
SummaryText string
|
||
GreatestText string
|
||
OverviewTitle string
|
||
PhasePanelsTitle string
|
||
ContactsTitle string
|
||
DirectionText string
|
||
FooterNote string
|
||
// Language 为 "en" 时使用英文,其他值使用中文。
|
||
// Language uses English for "en" and Chinese for every other value.
|
||
Language string
|
||
// Location 控制显示的事件时刻;nil 使用 UTC+8。
|
||
// Location controls displayed event times. Nil uses UTC+8.
|
||
Location *time.Location
|
||
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
|
||
// nil 或 UTC(否则渲染器返回错误),并在图注里声明尺度。
|
||
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
|
||
// Location to be nil or UTC (otherwise the renderer returns an error) and declares the scale.
|
||
TimeScale astro.TimeScale
|
||
}
|
||
|
||
type localPlanetOccultationEventFrame struct {
|
||
label string
|
||
name string
|
||
time time.Time
|
||
hidden bool
|
||
frame moon.PlanetOccultationDiagramFrame
|
||
appearance localOccultationMoonAppearance
|
||
}
|
||
|
||
// FindLocalPlanetOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次有限盘面行星月掩。空切片表示没有本地事件。
|
||
// FindLocalPlanetOccultationSVGs searches one fixed observing site and renders every finite-disk planetary occultation in the requested time window. An empty slice means no local event.
|
||
func FindLocalPlanetOccultationSVGs(
|
||
start, end time.Time,
|
||
planet moon.OccultationPlanet,
|
||
longitude, latitude, height float64,
|
||
searchOptions moon.OccultationSearchOptions,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) ([]string, error) {
|
||
if err := validateLocalPlanetOccultationSVGOptions(options); err != nil {
|
||
return nil, err
|
||
}
|
||
events, err := moon.FindPlanetOccultations(
|
||
start, end, planet, longitude, latitude, height, searchOptions,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
diagrams := make([]string, 0, len(events))
|
||
for _, event := range events {
|
||
diagram, renderErr := LocalPlanetOccultationSVG(event, options)
|
||
if renderErr != nil {
|
||
return nil, renderErr
|
||
}
|
||
diagrams = append(diagrams, diagram)
|
||
}
|
||
return diagrams, nil
|
||
}
|
||
|
||
// LocalPlanetOccultationSVG 渲染已求解的固定地点行星月掩的月心轨迹及适用的 C1-C4 阶段面板;不会运行事件搜索。
|
||
// LocalPlanetOccultationSVG renders an already solved fixed-site planetary occultation as a Moon-centered track with all applicable C1-C4 stage panels. It does not run the event search.
|
||
func LocalPlanetOccultationSVG(
|
||
info moon.PlanetOccultationInfo,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) (string, error) {
|
||
if options.TimeScale == astro.TimeScaleUT1 && options.Location != nil && options.Location != time.UTC {
|
||
return "", fmt.Errorf("occultation SVG: UT1 labels do not take a non-UTC location")
|
||
}
|
||
if err := validateLocalPlanetOccultationInfo(info); err != nil {
|
||
return "", err
|
||
}
|
||
if err := validateLocalPlanetOccultationSVGOptions(options); err != nil {
|
||
return "", err
|
||
}
|
||
if options.TimeScale == astro.TimeScaleUT1 {
|
||
options.Location = time.UTC
|
||
}
|
||
|
||
options = normalizeLocalPlanetOccultationSVGOptions(options)
|
||
diagram := moon.PlanetOccultationDiagram(info, moon.PlanetOccultationDiagramOptions{
|
||
StepDays: options.Step.Hours() / 24,
|
||
})
|
||
if len(diagram.Frames) == 0 {
|
||
return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalPlanetOccultationInfo)
|
||
}
|
||
if options.TimeScale == astro.TimeScaleUT1 {
|
||
info = moon.PlanetOccultationInfoInUT1(info)
|
||
}
|
||
return renderLocalPlanetOccultationSVG(info, diagram, options), nil
|
||
}
|
||
|
||
func validateLocalPlanetOccultationSVGOptions(options LocalPlanetOccultationSVGOptions) error {
|
||
if options.Width > 0 && options.Width < localPlanetOccultationSVGMinimumWidth {
|
||
return fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidLocalPlanetOccultationSVGOptions, localPlanetOccultationSVGMinimumWidth)
|
||
}
|
||
if options.Height > 0 && options.Height < localPlanetOccultationSVGMinimumHeight {
|
||
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidLocalPlanetOccultationSVGOptions, localPlanetOccultationSVGMinimumHeight)
|
||
}
|
||
if options.Step < 0 {
|
||
return fmt.Errorf("%w: step cannot be negative", ErrInvalidLocalPlanetOccultationSVGOptions)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateLocalPlanetOccultationInfo(info moon.PlanetOccultationInfo) error {
|
||
if err := info.Planet.Validate(); err != nil {
|
||
return fmt.Errorf("%w: %v", ErrInvalidLocalPlanetOccultationInfo, err)
|
||
}
|
||
if err := info.Observer.Validate(); err != nil {
|
||
return fmt.Errorf("%w: %v", ErrInvalidLocalPlanetOccultationInfo, err)
|
||
}
|
||
if !info.ContactsComplete || info.ExternalImmersion.IsZero() || info.Greatest.IsZero() || info.ExternalEmersion.IsZero() {
|
||
return fmt.Errorf("%w: complete external contacts and greatest time are required", ErrInvalidLocalPlanetOccultationInfo)
|
||
}
|
||
if info.Greatest.Before(info.ExternalImmersion) || info.ExternalEmersion.Before(info.Greatest) {
|
||
return fmt.Errorf("%w: event times must be ordered", ErrInvalidLocalPlanetOccultationInfo)
|
||
}
|
||
if info.Type == moon.OccultationTotal {
|
||
if !info.HasInternalContacts || info.InternalImmersion.IsZero() || info.InternalEmersion.IsZero() ||
|
||
!info.InternalImmersion.After(info.ExternalImmersion) || !info.InternalImmersion.Before(info.Greatest) ||
|
||
!info.InternalEmersion.After(info.Greatest) || !info.InternalEmersion.Before(info.ExternalEmersion) {
|
||
return fmt.Errorf("%w: a total event requires ordered C2 and C3 contacts", ErrInvalidLocalPlanetOccultationInfo)
|
||
}
|
||
} else if info.Type != moon.OccultationPartial && info.Type != moon.OccultationGrazing {
|
||
return fmt.Errorf("%w: unsupported finite-disk geometry %q", ErrInvalidLocalPlanetOccultationInfo, info.Type)
|
||
} else if info.HasInternalContacts || !info.InternalImmersion.IsZero() || !info.InternalEmersion.IsZero() {
|
||
return fmt.Errorf("%w: partial and grazing events cannot contain internal contacts", ErrInvalidLocalPlanetOccultationInfo)
|
||
}
|
||
if !starOccultationFinite(info.MinimumSeparationArcsec) || info.MinimumSeparationArcsec < 0 ||
|
||
!starOccultationFinite(info.MoonSemidiameterArcsec) || info.MoonSemidiameterArcsec <= 0 ||
|
||
!starOccultationFinite(info.PlanetSemidiameterArcsec) || info.PlanetSemidiameterArcsec <= 0 ||
|
||
info.PlanetSemidiameterArcsec >= info.MoonSemidiameterArcsec ||
|
||
!starOccultationFinite(info.PositionAngleDeg) ||
|
||
!starOccultationFinite(info.MoonAltitudeAtGreatest) ||
|
||
!starOccultationFinite(info.MoonAzimuthAtGreatest) {
|
||
return fmt.Errorf("%w: event geometry must be finite", ErrInvalidLocalPlanetOccultationInfo)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeLocalPlanetOccultationSVGOptions(options LocalPlanetOccultationSVGOptions) LocalPlanetOccultationSVGOptions {
|
||
if options.Width <= 0 {
|
||
options.Width = localPlanetOccultationSVGDefaultWidth
|
||
}
|
||
if options.Height <= 0 {
|
||
options.Height = localPlanetOccultationSVGDefaultHeight
|
||
}
|
||
if options.Step <= 0 {
|
||
options.Step = localPlanetOccultationSVGDefaultStep
|
||
}
|
||
if options.Location == nil {
|
||
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
|
||
}
|
||
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
|
||
options.Language = starOccultationSVGLanguageEnglish
|
||
} else {
|
||
options.Language = starOccultationSVGLanguageChinese
|
||
}
|
||
return options
|
||
}
|
||
|
||
func renderLocalPlanetOccultationSVG(
|
||
info moon.PlanetOccultationInfo,
|
||
diagram moon.PlanetOccultationDiagramResult,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) string {
|
||
title := localPlanetOccultationSVGTitle(info, options)
|
||
headerLines := localPlanetOccultationSVGHeaderLines(info, options)
|
||
// 页眉字号 14/13,省略号按较大的 14 估算;行数上限只与画布高度有关,先按空页眉取上限。
|
||
headerLimit := localPlanetOccultationSVGLayoutFor(options, 72).headerMaxLines
|
||
headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14, headerLimit)
|
||
headerBottom := 72.0 + float64(len(headerLines))*19
|
||
layout := localPlanetOccultationSVGLayoutFor(options, headerBottom)
|
||
events := localPlanetOccultationSVGEventFrames(info, diagram.Frames, options.Language)
|
||
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
|
||
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
|
||
b.WriteString(`<defs>`)
|
||
b.WriteString(svgasset.MoonFaceSymbol())
|
||
b.WriteString(`<marker id="local-planet-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></marker>`)
|
||
b.WriteString(`</defs>`)
|
||
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
|
||
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`, layout.width-44, layout.height-36)
|
||
titleSize := starOccultationTitleFontSize(title, layout.width)
|
||
titleText := title
|
||
if options.Title != "" {
|
||
titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize))
|
||
}
|
||
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
|
||
layout.width/2, titleSize, html.EscapeString(titleText))
|
||
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
|
||
for index, line := range headerLines {
|
||
fontSize, fill := 13, "#3b4143"
|
||
if index == 0 {
|
||
fontSize, fill = 14, "#222222"
|
||
}
|
||
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="%d" text-anchor="middle">%s</text>`,
|
||
layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line))
|
||
}
|
||
|
||
writeLocalPlanetOccultationOverview(&b, diagram, events, layout, options)
|
||
writeLocalPlanetOccultationContacts(&b, events, layout, options)
|
||
writeLocalPlanetOccultationStages(&b, events, layout, options)
|
||
writeLocalPlanetOccultationFooter(&b, info, layout, options)
|
||
b.WriteString(`</svg>`)
|
||
return b.String()
|
||
}
|
||
|
||
func localPlanetOccultationSVGLayoutFor(
|
||
options LocalPlanetOccultationSVGOptions,
|
||
headerBottom float64,
|
||
) localStarOccultationSVGLayout {
|
||
width := float64(options.Width)
|
||
height := float64(options.Height)
|
||
margin := math.Max(34, math.Min(44, width*0.045))
|
||
gap := math.Max(18, math.Min(24, width*0.025))
|
||
panelWidth := math.Max(210, math.Min(258, width*0.25))
|
||
overviewLeft := margin
|
||
overviewRight := width - margin - panelWidth - gap
|
||
footerSpace := 88.0
|
||
stageHeight := math.Max(156, math.Min(190, height*0.25))
|
||
stageBottom := height - footerSpace
|
||
stageTop := stageBottom - stageHeight
|
||
overviewTop := headerBottom + 26
|
||
overviewBottom := stageTop - 20
|
||
return localStarOccultationSVGLayout{
|
||
width: width,
|
||
height: height,
|
||
margin: margin,
|
||
overviewLeft: overviewLeft,
|
||
overviewRight: overviewRight,
|
||
overviewTop: overviewTop,
|
||
overviewBottom: overviewBottom,
|
||
panelX: overviewRight + gap,
|
||
panelWidth: panelWidth,
|
||
stageTop: stageTop,
|
||
stageBottom: stageBottom,
|
||
footerY: height - 62,
|
||
headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight),
|
||
}
|
||
}
|
||
|
||
func writeLocalPlanetOccultationOverview(
|
||
b *strings.Builder,
|
||
diagram moon.PlanetOccultationDiagramResult,
|
||
events []localPlanetOccultationEventFrame,
|
||
layout localStarOccultationSVGLayout,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) {
|
||
overviewTitle := occultationTitleText(localPlanetOccultationSVGOverviewTitle(options), options.OverviewTitle != "",
|
||
layout.panelX-layout.overviewLeft-12, 14)
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
|
||
layout.overviewLeft, layout.overviewTop-10, html.EscapeString(overviewTitle))
|
||
cx := (layout.overviewLeft + layout.overviewRight) / 2
|
||
cy := (layout.overviewTop+layout.overviewBottom)/2 + 4
|
||
extent := localPlanetOccultationSVGExtent(diagram.Frames)
|
||
availableWidth := layout.overviewRight - layout.overviewLeft - 74
|
||
availableHeight := layout.overviewBottom - layout.overviewTop - 50
|
||
scale := math.Min(availableWidth/(2*extent), availableHeight/(2*extent))
|
||
if !starOccultationFinite(scale) || scale <= 0 {
|
||
scale = 1
|
||
}
|
||
mapX := func(value float64) float64 { return cx - value*scale }
|
||
mapY := func(value float64) float64 { return cy - value*scale }
|
||
moonRadius := localPlanetOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
|
||
|
||
table := &svgchart.LabelTable{}
|
||
table.ReserveText(layout.overviewLeft, layout.overviewTop-10, 14, overviewTitle, "start")
|
||
table.Reserve(layout.panelX-12, layout.overviewTop-16, layout.width-(layout.panelX-12), layout.height-layout.overviewTop)
|
||
table.Reserve(cx-moonRadius, cy-moonRadius, 2*moonRadius, 2*moonRadius)
|
||
for _, event := range events {
|
||
radius := math.Max(6, event.frame.PlanetRadiusArcsec*scale)
|
||
table.Reserve(mapX(event.frame.PlanetXArcsec)-radius, mapY(event.frame.PlanetYArcsec)-radius, 2*radius, 2*radius)
|
||
}
|
||
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language, table)
|
||
writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
|
||
writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY)
|
||
for _, event := range events {
|
||
writeLocalPlanetOccultationDisk(b,
|
||
mapX(event.frame.PlanetXArcsec), mapY(event.frame.PlanetYArcsec),
|
||
event.frame.PlanetRadiusArcsec*scale, false, "overview-planet-disk", event.label,
|
||
)
|
||
}
|
||
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
|
||
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
|
||
"local-planet-occultation-moon-overview")
|
||
overviewLabels := make([]localOccultationOverviewLabel, 0, len(events))
|
||
for _, event := range events {
|
||
x := mapX(event.frame.PlanetXArcsec)
|
||
y := mapY(event.frame.PlanetYArcsec)
|
||
if event.hidden {
|
||
writeLocalPlanetOccultationDisk(b, x, y, event.frame.PlanetRadiusArcsec*scale, true, "overview-planet-outline", event.label)
|
||
}
|
||
if event.label != "C2" && event.label != "C3" {
|
||
overviewLabels = append(overviewLabels, localOccultationOverviewLabel{text: event.name, x: x, y: y})
|
||
}
|
||
}
|
||
writeLocalOccultationOverviewLabels(b, overviewLabels, table, cx, cy, moonRadius, "#792d28")
|
||
}
|
||
|
||
func localPlanetOccultationSVGExtent(frames []moon.PlanetOccultationDiagramFrame) float64 {
|
||
extent := 1.0
|
||
for _, frame := range frames {
|
||
extent = math.Max(extent, frame.MoonRadiusArcsec)
|
||
extent = math.Max(extent, math.Abs(frame.PlanetXArcsec)+frame.PlanetRadiusArcsec)
|
||
extent = math.Max(extent, math.Abs(frame.PlanetYArcsec)+frame.PlanetRadiusArcsec)
|
||
}
|
||
return extent * 1.28
|
||
}
|
||
|
||
func localPlanetOccultationSVGMaximumMoonRadius(frames []moon.PlanetOccultationDiagramFrame) float64 {
|
||
radius := 1.0
|
||
for _, frame := range frames {
|
||
radius = math.Max(radius, frame.MoonRadiusArcsec)
|
||
}
|
||
return radius
|
||
}
|
||
|
||
func writeLocalPlanetOccultationTrack(
|
||
b *strings.Builder,
|
||
frames []moon.PlanetOccultationDiagramFrame,
|
||
mapX, mapY func(float64) float64,
|
||
) {
|
||
if len(frames) < 2 {
|
||
return
|
||
}
|
||
var path strings.Builder
|
||
for index, frame := range frames {
|
||
command := "L"
|
||
if index == 0 {
|
||
command = "M"
|
||
}
|
||
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.PlanetXArcsec), mapY(frame.PlanetYArcsec))
|
||
}
|
||
fmt.Fprintf(b, `<path class="local-planet-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-planet-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
|
||
}
|
||
|
||
func writeLocalPlanetOccultationLunarPath(
|
||
b *strings.Builder,
|
||
frames []moon.PlanetOccultationDiagramFrame,
|
||
mapX, mapY func(float64) float64,
|
||
extent float64,
|
||
language string,
|
||
) {
|
||
unitX, unitY, ok := localPlanetOccultationSVGLunarPathDirection(frames)
|
||
if !ok {
|
||
return
|
||
}
|
||
lineExtent := extent * 0.92
|
||
startX := mapX(-unitX * lineExtent)
|
||
startY := mapY(-unitY * lineExtent)
|
||
endX := mapX(unitX * lineExtent)
|
||
endY := mapY(unitY * lineExtent)
|
||
if !starOccultationFinite(startX) || !starOccultationFinite(startY) ||
|
||
!starOccultationFinite(endX) || !starOccultationFinite(endY) {
|
||
return
|
||
}
|
||
fmt.Fprintf(b, `<line class="lunar-path-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#506975" stroke-width="1.1" stroke-dasharray="7 4" opacity="0.86"/>`,
|
||
startX, startY, endX, endY)
|
||
labelX, labelY := startX, startY
|
||
if endX < startX {
|
||
labelX, labelY = endX, endY
|
||
}
|
||
fmt.Fprintf(b, `<text class="lunar-path-label" x="%.3f" y="%.3f" fill="#405966" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="end">%s</text>`,
|
||
labelX-6, labelY-6, html.EscapeString(localStarOccultationSVGLunarPathLabel(language)))
|
||
}
|
||
|
||
func localPlanetOccultationSVGLunarPathDirection(frames []moon.PlanetOccultationDiagramFrame) (float64, float64, bool) {
|
||
if len(frames) < 2 {
|
||
return 0, 0, false
|
||
}
|
||
greatest := -1
|
||
for index, frame := range frames {
|
||
if localPlanetOccultationSVGFrameHasLabel(frame, "Greatest") {
|
||
greatest = index
|
||
break
|
||
}
|
||
}
|
||
left, right := 0, len(frames)-1
|
||
if greatest > 0 && greatest+1 < len(frames) {
|
||
left, right = greatest-1, greatest+1
|
||
} else if greatest == 0 {
|
||
left, right = 0, 1
|
||
} else if greatest == len(frames)-1 {
|
||
left, right = len(frames)-2, len(frames)-1
|
||
}
|
||
dx := frames[right].PlanetXArcsec - frames[left].PlanetXArcsec
|
||
dy := frames[right].PlanetYArcsec - frames[left].PlanetYArcsec
|
||
length := math.Hypot(dx, dy)
|
||
if !starOccultationFinite(length) || length == 0 {
|
||
return 0, 0, false
|
||
}
|
||
return dx / length, dy / length, true
|
||
}
|
||
|
||
func writeLocalPlanetOccultationDisk(
|
||
b *strings.Builder,
|
||
x, y, radius float64,
|
||
hidden bool,
|
||
class, label string,
|
||
) {
|
||
fill, stroke, dash := "#d6ad69", "#704b2d", ""
|
||
if hidden {
|
||
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
|
||
}
|
||
fmt.Fprintf(b, `<circle class="%s" data-label="%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" stroke="%s" stroke-width="1.2"%s/>`,
|
||
html.EscapeString(class), html.EscapeString(label), x, y, math.Max(radius, 0.15), fill, stroke, dash)
|
||
}
|
||
func writeLocalPlanetOccultationContacts(
|
||
b *strings.Builder,
|
||
events []localPlanetOccultationEventFrame,
|
||
layout localStarOccultationSVGLayout,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) {
|
||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
|
||
layout.panelX-12, layout.overviewTop-12, layout.panelX-12, layout.overviewBottom)
|
||
contactsTitle := occultationTitleText(localPlanetOccultationSVGContactsTitle(options), options.ContactsTitle != "",
|
||
layout.width-layout.panelX-layout.margin, 14)
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
|
||
layout.panelX, layout.overviewTop-10, html.EscapeString(contactsTitle))
|
||
available := layout.overviewBottom - layout.overviewTop - 10
|
||
rowHeight := math.Min(66, available/math.Max(1, float64(len(events))))
|
||
for index, event := range events {
|
||
y := layout.overviewTop + 16 + float64(index)*rowHeight
|
||
if index > 0 {
|
||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e0e1df" stroke-width="0.8"/>`,
|
||
layout.panelX, y-12, layout.panelX+layout.panelWidth, y-12)
|
||
}
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" font-weight="700">%s</text>`,
|
||
layout.panelX, y, html.EscapeString(event.name))
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10" text-anchor="end">%s</text>`,
|
||
layout.panelX+layout.panelWidth, y, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#536063" font-family="Arial, sans-serif" font-size="9.5">PA %.1f° | %s %.1f° | %s %.1f°</text>`,
|
||
layout.panelX, y+16, event.frame.PositionAngleDeg,
|
||
localStarOccultationSVGAltitudeLabel(options.Language), event.frame.MoonAltitudeDeg,
|
||
localStarOccultationSVGAzimuthLabel(options.Language), event.frame.MoonAzimuthDeg)
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b7577" font-family="Arial, sans-serif" font-size="9.5">r %.2f″ | %s</text>`,
|
||
layout.panelX, y+31, event.frame.PlanetRadiusArcsec,
|
||
html.EscapeString(localStarOccultationSVGVisibilityText(event.frame.MoonAltitudeDeg >= 0, options.Language)))
|
||
}
|
||
}
|
||
|
||
func writeLocalPlanetOccultationStages(
|
||
b *strings.Builder,
|
||
events []localPlanetOccultationEventFrame,
|
||
layout localStarOccultationSVGLayout,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) {
|
||
phaseTitle := occultationTitleText(localPlanetOccultationSVGPhasePanelsTitle(options), options.PhasePanelsTitle != "",
|
||
layout.width-2*layout.margin, 14)
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
|
||
layout.margin, layout.stageTop+13, html.EscapeString(phaseTitle))
|
||
if len(events) == 0 {
|
||
return
|
||
}
|
||
usableWidth := layout.width - 2*layout.margin
|
||
columnWidth := usableWidth / float64(len(events))
|
||
maxMoonRadius := localPlanetOccultationSVGMaximumMoonRadiusFromEvents(events)
|
||
radius := math.Min(43, math.Max(31, (layout.stageBottom-layout.stageTop-59)/2))
|
||
scale := radius / maxMoonRadius
|
||
cy := layout.stageTop + 35 + radius
|
||
for index, event := range events {
|
||
cx := layout.margin + columnWidth*(float64(index)+0.5)
|
||
trueRadius := event.frame.PlanetRadiusArcsec * scale
|
||
displayRadius := math.Max(trueRadius, 5.5)
|
||
magnification := displayRadius / math.Max(trueRadius, 1e-9)
|
||
x, y := localPlanetOccultationSVGStageCenter(event, cx, cy, radius, displayRadius, scale)
|
||
fmt.Fprintf(b, `<circle class="stage-planet-disk" data-label="%s" data-true-radius="%.3f" data-magnification="%.3f" cx="%.3f" cy="%.3f" r="%.3f" fill="#d6ad69" stroke="#704b2d" stroke-width="1.1"/>`,
|
||
html.EscapeString(event.label), trueRadius, magnification, x, y, displayRadius)
|
||
writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon",
|
||
event.appearance, fmt.Sprintf("local-planet-occultation-moon-stage-%d", index))
|
||
if event.hidden {
|
||
fmt.Fprintf(b, `<circle class="stage-planet-hidden-outline" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#7d3430" stroke-width="1.1" stroke-dasharray="2 2"/>`,
|
||
x, y, displayRadius)
|
||
}
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10.5" font-weight="700" text-anchor="middle">%s</text>`,
|
||
cx, cy+radius+18, html.EscapeString(event.name))
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#667174" font-family="Arial, sans-serif" font-size="9.5" text-anchor="middle">%s</text>`,
|
||
cx, cy+radius+32, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
|
||
}
|
||
}
|
||
|
||
// 阶段面板会放大较小的行星盘面以便观察,并在接触时同步移动显示中心,使 C1/C4 仍外切、C2/C3 仍内切。这只影响展示;事件时刻和总览使用未修改的站心几何。
|
||
// The stage panels enlarge small planet disks for visibility and move the display center by the same amount at contacts so C1/C4 remain externally tangent and C2/C3 remain internally tangent. This only affects presentation; event times and the overview use the unmodified topocentric geometry.
|
||
func localPlanetOccultationSVGStageCenter(
|
||
event localPlanetOccultationEventFrame,
|
||
cx, cy, moonRadius, displayRadius, scale float64,
|
||
) (float64, float64) {
|
||
distance := event.frame.SeparationArcsec * scale
|
||
switch event.label {
|
||
case "C1", "C4":
|
||
distance = moonRadius + displayRadius
|
||
case "C2", "C3":
|
||
distance = math.Max(0, moonRadius-displayRadius)
|
||
}
|
||
angle := event.frame.PositionAngleDeg * math.Pi / 180
|
||
return cx - distance*math.Sin(angle), cy - distance*math.Cos(angle)
|
||
}
|
||
|
||
func localPlanetOccultationSVGMaximumMoonRadiusFromEvents(events []localPlanetOccultationEventFrame) float64 {
|
||
radius := 1.0
|
||
for _, event := range events {
|
||
radius = math.Max(radius, event.frame.MoonRadiusArcsec)
|
||
}
|
||
return radius
|
||
}
|
||
|
||
func writeLocalPlanetOccultationFooter(
|
||
b *strings.Builder,
|
||
info moon.PlanetOccultationInfo,
|
||
layout localStarOccultationSVGLayout,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) {
|
||
directionLines, footerLines := localOccultationFooterLines(
|
||
localPlanetOccultationSVGDirectionText(options),
|
||
localPlanetOccultationSVGFooterNote(info, options),
|
||
timenote.Scale(options.TimeScale, info.Greatest, options.Location, options.Language),
|
||
layout.footerY, layout.width, layout.height)
|
||
for index, line := range directionLines {
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
|
||
layout.margin, layout.footerY+float64(index)*14-8, html.EscapeString(line))
|
||
}
|
||
for index, line := range footerLines {
|
||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" font-family="Georgia, 'Times New Roman', serif" font-size="9">%s</text>`,
|
||
layout.margin, layout.footerY+float64(len(directionLines))*14+float64(index)*12-6, html.EscapeString(line))
|
||
}
|
||
}
|
||
|
||
func localPlanetOccultationSVGEventFrames(
|
||
info moon.PlanetOccultationInfo,
|
||
frames []moon.PlanetOccultationDiagramFrame,
|
||
language string,
|
||
) []localPlanetOccultationEventFrame {
|
||
times := map[string]time.Time{
|
||
"C1": info.ExternalImmersion,
|
||
"C2": info.InternalImmersion,
|
||
"Greatest": info.Greatest,
|
||
"C3": info.InternalEmersion,
|
||
"C4": info.ExternalEmersion,
|
||
}
|
||
labels := []string{"C1", "Greatest", "C4"}
|
||
if info.HasInternalContacts {
|
||
labels = []string{"C1", "C2", "Greatest", "C3", "C4"}
|
||
}
|
||
result := make([]localPlanetOccultationEventFrame, 0, len(labels))
|
||
for _, label := range labels {
|
||
for _, frame := range frames {
|
||
if localPlanetOccultationSVGFrameHasLabel(frame, label) {
|
||
hidden := frame.FullyOcculted || info.Type == moon.OccultationTotal &&
|
||
(label == "C2" || label == "Greatest" || label == "C3")
|
||
result = append(result, localPlanetOccultationEventFrame{
|
||
label: label,
|
||
name: localPlanetOccultationSVGEventName(label, language),
|
||
time: times[label],
|
||
hidden: hidden,
|
||
frame: frame,
|
||
appearance: localOccultationMoonAppearanceAt(times[label], info.Observer),
|
||
})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func localPlanetOccultationSVGFrameHasLabel(frame moon.PlanetOccultationDiagramFrame, label string) bool {
|
||
if frame.Label == label {
|
||
return true
|
||
}
|
||
for _, current := range frame.Labels {
|
||
if current == label {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func localPlanetOccultationSVGHeaderLines(
|
||
info moon.PlanetOccultationInfo,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) []string {
|
||
lines := make([]string, 0, 4)
|
||
for _, item := range []struct {
|
||
value string
|
||
custom bool
|
||
}{
|
||
{localPlanetOccultationSVGSummaryText(info, options), options.SummaryText != ""},
|
||
{localPlanetOccultationSVGGreatestText(info, options), options.GreatestText != ""},
|
||
} {
|
||
lines = append(lines, occultationWrappedTextLines(item.value, item.custom, float64(options.Width)-80, 13)...)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func localPlanetOccultationSVGTargetName(
|
||
info moon.PlanetOccultationInfo,
|
||
language string,
|
||
) string {
|
||
target := strings.TrimSpace(info.TargetID)
|
||
if language == starOccultationSVGLanguageChinese && (target == "" || target == info.Planet.String()) {
|
||
return planetOccultationChineseName(info.Planet)
|
||
}
|
||
if target != "" {
|
||
return target
|
||
}
|
||
if language == starOccultationSVGLanguageEnglish {
|
||
return info.Planet.String()
|
||
}
|
||
return "行星"
|
||
}
|
||
|
||
func localPlanetOccultationSVGTitle(
|
||
info moon.PlanetOccultationInfo,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) string {
|
||
if options.Title != "" {
|
||
return options.Title
|
||
}
|
||
date := info.Greatest.In(options.Location).Format("2006-01-02")
|
||
target := localPlanetOccultationSVGTargetName(info, options.Language)
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return fmt.Sprintf("%s Local Lunar Occultation of %s", date, target)
|
||
}
|
||
return fmt.Sprintf("%s 指定地点月掩%s", date, target)
|
||
}
|
||
|
||
func localPlanetOccultationSVGSummaryText(
|
||
info moon.PlanetOccultationInfo,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) string {
|
||
if options.SummaryText != "" {
|
||
return options.SummaryText
|
||
}
|
||
coordinates := starOccultationFormatCoordinates(info.Observer.Longitude, info.Observer.Latitude)
|
||
duration := localStarOccultationSVGFormatDuration(info.ExternalEmersion.Sub(info.ExternalImmersion), options.Language)
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return fmt.Sprintf("Site %s | elevation %.0f m | %s | C1-C4 duration %s", coordinates, info.Observer.Height,
|
||
localPlanetOccultationSVGTypeName(info.Type, options.Language), duration)
|
||
}
|
||
return fmt.Sprintf("观测点 %s | 椭球高 %.0f 米 | %s | C1-C4 历时 %s", coordinates, info.Observer.Height,
|
||
localPlanetOccultationSVGTypeName(info.Type, options.Language), duration)
|
||
}
|
||
|
||
func localPlanetOccultationSVGGreatestText(
|
||
info moon.PlanetOccultationInfo,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) string {
|
||
if options.GreatestText != "" {
|
||
return options.GreatestText
|
||
}
|
||
value := info.Greatest.In(options.Location)
|
||
zone := starOccultationLocationLabel(value, options.Location)
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return fmt.Sprintf("Greatest %s %s | separation %.2f arcsec | radii Moon %.2f / planet %.2f arcsec | Moon altitude %+.1f°",
|
||
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec,
|
||
info.MoonSemidiameterArcsec, info.PlanetSemidiameterArcsec, info.MoonAltitudeAtGreatest)
|
||
}
|
||
return fmt.Sprintf("掩甚 %s %s | 中心角距 %.2f 角秒 | 视半径 月 %.2f / 行星 %.2f 角秒 | 月球高度 %+.1f°",
|
||
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec,
|
||
info.MoonSemidiameterArcsec, info.PlanetSemidiameterArcsec, info.MoonAltitudeAtGreatest)
|
||
}
|
||
|
||
func localPlanetOccultationSVGOverviewTitle(options LocalPlanetOccultationSVGOptions) string {
|
||
if options.OverviewTitle != "" {
|
||
return options.OverviewTitle
|
||
}
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return "Topocentric planet track (true disk scale)"
|
||
}
|
||
return "站心行星轨迹(圆盘真实比例)"
|
||
}
|
||
|
||
func localPlanetOccultationSVGPhasePanelsTitle(options LocalPlanetOccultationSVGOptions) string {
|
||
if options.PhasePanelsTitle != "" {
|
||
return options.PhasePanelsTitle
|
||
}
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return "C1-C4 contact stages (planet disk enlarged)"
|
||
}
|
||
return "C1-C4 接触阶段(行星圆盘放大示意)"
|
||
}
|
||
|
||
func localPlanetOccultationSVGContactsTitle(options LocalPlanetOccultationSVGOptions) string {
|
||
if options.ContactsTitle != "" {
|
||
return options.ContactsTitle
|
||
}
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return "Local contacts"
|
||
}
|
||
return "本地接触时刻"
|
||
}
|
||
|
||
func localPlanetOccultationSVGDirectionText(options LocalPlanetOccultationSVGOptions) string {
|
||
if options.DirectionText != "" {
|
||
return options.DirectionText
|
||
}
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return "Moon fixed at center; east is left and north is up. Blue-gray dashed: local lunar path. Red dashed: planet-center track."
|
||
}
|
||
return "月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为行星中心相对月心轨迹。"
|
||
}
|
||
|
||
func localPlanetOccultationSVGFooterNote(
|
||
info moon.PlanetOccultationInfo,
|
||
options LocalPlanetOccultationSVGOptions,
|
||
) string {
|
||
if options.FooterNote != "" {
|
||
return options.FooterNote
|
||
}
|
||
ringNote := ""
|
||
if info.Planet == moon.OccultationSaturn {
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
ringNote = " Saturn's rings are excluded from contact calculation and drawing."
|
||
} else {
|
||
ringNote = "土星环不参与接触计算,也不作为圆盘边界绘制。"
|
||
}
|
||
}
|
||
if options.Language == starOccultationSVGLanguageEnglish {
|
||
return "C1/C4 are external contacts; C2/C3 are internal contacts. Stage disks are enlarged for visibility; contact times use true topocentric radii." + ringNote
|
||
}
|
||
return "C1/C4 为外切,C2/C3 为内切。阶段图中的行星圆盘为可见性放大,接触时刻按真实站心视半径求解。" + ringNote
|
||
}
|
||
|
||
func localPlanetOccultationSVGEventName(label, language string) string {
|
||
if language == starOccultationSVGLanguageEnglish {
|
||
switch label {
|
||
case "C1":
|
||
return "C1 external ingress"
|
||
case "C2":
|
||
return "C2 internal ingress"
|
||
case "Greatest":
|
||
return "Greatest"
|
||
case "C3":
|
||
return "C3 internal egress"
|
||
case "C4":
|
||
return "C4 external egress"
|
||
}
|
||
}
|
||
switch label {
|
||
case "C1":
|
||
return "C1 外切始"
|
||
case "C2":
|
||
return "C2 内切始"
|
||
case "Greatest":
|
||
return "掩甚"
|
||
case "C3":
|
||
return "C3 内切终"
|
||
case "C4":
|
||
return "C4 外切终"
|
||
default:
|
||
return label
|
||
}
|
||
}
|
||
|
||
func localPlanetOccultationSVGTypeName(eventType moon.OccultationType, language string) string {
|
||
if language == starOccultationSVGLanguageEnglish {
|
||
switch eventType {
|
||
case moon.OccultationTotal:
|
||
return "total occultation"
|
||
case moon.OccultationPartial:
|
||
return "partial occultation"
|
||
default:
|
||
return "grazing occultation"
|
||
}
|
||
}
|
||
switch eventType {
|
||
case moon.OccultationTotal:
|
||
return "行星圆面全掩"
|
||
case moon.OccultationPartial:
|
||
return "行星圆面部分掩"
|
||
default:
|
||
return "行星圆面擦边"
|
||
}
|
||
}
|