feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
// Package svg 生成自包含的月掩星图。
|
||||
// Package svg renders self-contained lunar-occultation diagrams.
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/svgmap"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
const (
|
||||
starOccultationSVGDefaultWidth = 1200
|
||||
starOccultationSVGDefaultHeight = 800
|
||||
starOccultationSVGMinimumWidth = 480
|
||||
starOccultationSVGMinimumHeight = 360
|
||||
starOccultationSVGDefaultZone = 8 * 60 * 60
|
||||
|
||||
starOccultationSVGLanguageChinese = "zh"
|
||||
starOccultationSVGLanguageEnglish = "en"
|
||||
)
|
||||
|
||||
// ErrInvalidStarOccultationSVGOptions 表示画布选项无法容纳地图、事件面板和页脚而不发生重叠。
|
||||
// ErrInvalidStarOccultationSVGOptions reports canvas options that cannot hold the map, event panel, and footer without overlap.
|
||||
var ErrInvalidStarOccultationSVGOptions = errors.New("invalid stellar occultation SVG options")
|
||||
|
||||
// StarOccultationSVGOptions 控制恒星月掩全球掩带 SVG 输出。
|
||||
// StarOccultationSVGOptions controls a global stellar-occultation path SVG.
|
||||
type StarOccultationSVGOptions struct {
|
||||
// Width 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值。
|
||||
// Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default.
|
||||
Width int
|
||||
Height int
|
||||
// Title 及后续文本字段覆盖自动生成的标签。
|
||||
// Title and the following text fields override automatically generated labels.
|
||||
Title string
|
||||
SummaryText string
|
||||
GreatestText string
|
||||
MapTitle string
|
||||
ContactsTitle 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
|
||||
// Projection 选择全球地图投影;零值会为限于单半球的高纬事件自动使用极区地图。
|
||||
// Projection selects the global map projection. The zero value automatically uses a polar map for a high-latitude event confined to one hemisphere.
|
||||
Projection MapProjection
|
||||
// TimeLabelStep 控制可见中心线上的 HH:MM 标签;零值使用 30 分钟,负值禁用标签。
|
||||
// TimeLabelStep controls HH:MM labels along the visible center line. Zero uses 30 minutes; a negative value disables the labels.
|
||||
TimeLabelStep time.Duration
|
||||
}
|
||||
|
||||
// FindStarOccultationSVGs 搜索时间窗并渲染其中所有恒星月掩全球掩带。
|
||||
// 使用调用者提供的坐标,不加载内嵌星表;无错误的空切片表示没有事件。
|
||||
// FindStarOccultationSVGs searches a time window and renders every global path it finds. It uses the coordinate supplied by the caller and does not load the embedded star catalog. An empty slice without an error means no event.
|
||||
func FindStarOccultationSVGs(
|
||||
start, end time.Time,
|
||||
star moon.StarCoordinate,
|
||||
pathOptions moon.OccultationPathOptions,
|
||||
options StarOccultationSVGOptions,
|
||||
) ([]string, error) {
|
||||
paths, err := moon.FindStarOccultationPaths(start, end, star, pathOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diagrams := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
diagram, renderErr := StarOccultationPathSVG(path, options)
|
||||
if renderErr != nil {
|
||||
return nil, renderErr
|
||||
}
|
||||
diagrams = append(diagrams, diagram)
|
||||
}
|
||||
return diagrams, nil
|
||||
}
|
||||
|
||||
// StarOccultationPathSVG 将已计算的恒星月掩全球路径渲染为 SVG。
|
||||
// 不会运行事件搜索,也不会加载内嵌星表。
|
||||
// StarOccultationPathSVG renders an already computed global stellar-occultation path. It does not run the event search or load the embedded star catalog.
|
||||
func StarOccultationPathSVG(
|
||||
path moon.StarOccultationPath,
|
||||
options StarOccultationSVGOptions,
|
||||
) (string, error) {
|
||||
if err := validateStarOccultationPath(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateStarOccultationSVGOptions(options); err != nil {
|
||||
return "", err
|
||||
}
|
||||
options = normalizeStarOccultationSVGOptions(options)
|
||||
return renderStarOccultationPathSVG(path, options), nil
|
||||
}
|
||||
|
||||
func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error {
|
||||
if options.Width > 0 && options.Width < starOccultationSVGMinimumWidth {
|
||||
return fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumWidth)
|
||||
}
|
||||
if options.Height > 0 && options.Height < starOccultationSVGMinimumHeight {
|
||||
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumHeight)
|
||||
}
|
||||
switch options.Projection {
|
||||
case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar:
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported map projection %q", ErrInvalidStarOccultationSVGOptions, options.Projection)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeStarOccultationSVGOptions(options StarOccultationSVGOptions) StarOccultationSVGOptions {
|
||||
if options.Width <= 0 {
|
||||
options.Width = starOccultationSVGDefaultWidth
|
||||
}
|
||||
if options.Height <= 0 {
|
||||
options.Height = starOccultationSVGDefaultHeight
|
||||
}
|
||||
if options.Location == nil {
|
||||
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
|
||||
}
|
||||
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
|
||||
options.Language = starOccultationSVGLanguageEnglish
|
||||
} else {
|
||||
options.Language = starOccultationSVGLanguageChinese
|
||||
}
|
||||
if options.TimeLabelStep < 0 {
|
||||
options.TimeLabelStep = 0
|
||||
} else if options.TimeLabelStep == 0 {
|
||||
options.TimeLabelStep = 30 * time.Minute
|
||||
} else if options.TimeLabelStep < time.Minute {
|
||||
options.TimeLabelStep = time.Minute
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
|
||||
return renderOccultationPathSVG(path, nil, options)
|
||||
}
|
||||
|
||||
func renderOccultationPathSVG(
|
||||
path moon.StarOccultationPath,
|
||||
planetPath *moon.PlanetOccultationPath,
|
||||
options StarOccultationSVGOptions,
|
||||
) string {
|
||||
projection := resolveStarOccultationMapProjection(path, options.Projection)
|
||||
options.Projection = MapProjection(projection)
|
||||
title := starOccultationSVGTitle(path, options)
|
||||
headerLines := starOccultationSVGHeaderLines(path, options)
|
||||
headerBottom := 72.0 + float64(len(headerLines))*19
|
||||
layout := starOccultationSVGLayoutFor(options, headerBottom, projection)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMid meet" style="max-width:100%%;height:auto;display:block" role="img" aria-label="%s">`,
|
||||
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
|
||||
b.WriteString(`<defs>`)
|
||||
b.WriteString(layout.mapFrame().ClipDefinition("occultation-map-clip"))
|
||||
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)
|
||||
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, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
|
||||
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 := 13
|
||||
fill := "#3b4143"
|
||||
if index == 0 {
|
||||
fontSize = 14
|
||||
fill = "#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))
|
||||
}
|
||||
|
||||
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.mapX, layout.mapY-10, html.EscapeString(starOccultationSVGMapTitle(options)))
|
||||
if planetPath == nil {
|
||||
writeStarOccultationMap(&b, path, layout, options)
|
||||
writeStarOccultationEventsPanel(&b, path, layout, options)
|
||||
} else {
|
||||
writePlanetOccultationMap(&b, *planetPath, path, layout, options)
|
||||
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
|
||||
}
|
||||
writeStarOccultationFooter(&b, layout, options)
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeStarOccultationMap(
|
||||
b *strings.Builder,
|
||||
path moon.StarOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
options StarOccultationSVGOptions,
|
||||
) {
|
||||
layout.mapFrame().WriteOcean(b)
|
||||
writeStarOccultationGraticule(b, layout)
|
||||
writeStarOccultationLand(b, layout)
|
||||
writeStarOccultationBand(b, path, layout)
|
||||
writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.35, "")
|
||||
writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.35, "")
|
||||
writeStarOccultationGeoLine(b, path.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4")
|
||||
writeStarOccultationVisibleCenterLine(b, path.CenterLine, layout)
|
||||
writeOccultationTimeMarkers(b, path.CenterLine, layout, options,
|
||||
[]time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time)
|
||||
writeStarOccultationEventMarker(b, path.Start, layout, starOccultationEventLabel("start", options.Language), "start")
|
||||
writeStarOccultationEventMarker(b, path.Greatest, layout, starOccultationEventLabel("greatest", options.Language), "greatest")
|
||||
writeStarOccultationEventMarker(b, path.End, layout, starOccultationEventLabel("end", options.Language), "end")
|
||||
layout.mapFrame().WriteFrame(b)
|
||||
writeStarOccultationLegend(b, layout, options.Language)
|
||||
}
|
||||
|
||||
func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVGLayout) {
|
||||
layout.mapFrame().WriteGraticule(b, "occultation-map-clip")
|
||||
if layout.projection != svgmap.ProjectionEquirectangular {
|
||||
return
|
||||
}
|
||||
for _, longitude := range []float64{-120, -60, 0, 60, 120} {
|
||||
x, _ := layout.project(longitude, 0)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="middle">%.0f°</text>`,
|
||||
x, layout.mapY+layout.mapHeight+13, longitude)
|
||||
}
|
||||
for _, latitude := range []float64{-60, -30, 0, 30, 60} {
|
||||
_, y := layout.project(0, latitude)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="end">%.0f°</text>`,
|
||||
layout.mapX-5, y+3, latitude)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStarOccultationLand(b *strings.Builder, layout starOccultationSVGLayout) {
|
||||
layout.mapFrame().WriteLand(b, "occultation-map-clip")
|
||||
}
|
||||
|
||||
func writeStarOccultationBand(b *strings.Builder, path moon.StarOccultationPath, layout starOccultationSVGLayout) {
|
||||
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
|
||||
"occultation-band-layer", "occultation-band", "#e0ae43", 0.28)
|
||||
}
|
||||
|
||||
func writeOccultationBand(
|
||||
b *strings.Builder,
|
||||
northern, southern []moon.OccultationPathPoint,
|
||||
layout starOccultationSVGLayout,
|
||||
layerClass, pathClass, color string,
|
||||
opacity float64,
|
||||
) {
|
||||
segments := starOccultationBandFragments(northern, southern, layout.projection)
|
||||
if len(segments) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="none">`,
|
||||
layerClass, color, opacity)
|
||||
for _, segment := range segments {
|
||||
fmt.Fprintf(b, `<path class="%s" d="`, pathClass)
|
||||
for index, point := range segment {
|
||||
x, y := layout.project(point.longitude, point.latitude)
|
||||
command := "L"
|
||||
if index == 0 {
|
||||
command = "M"
|
||||
}
|
||||
fmt.Fprintf(b, `%s %.3f %.3f `, command, x, y)
|
||||
}
|
||||
b.WriteString(`Z"/>`)
|
||||
}
|
||||
b.WriteString(`</g>`)
|
||||
}
|
||||
|
||||
func writeStarOccultationGeoLine(
|
||||
b *strings.Builder,
|
||||
points []moon.OccultationPathPoint,
|
||||
layout starOccultationSVGLayout,
|
||||
className, color string,
|
||||
strokeWidth float64,
|
||||
dash string,
|
||||
) {
|
||||
for _, segment := range starOccultationPathSegmentsForProjection(points, layout.projection) {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(b, `<path class="%s" d="`, className)
|
||||
for index, point := range segment {
|
||||
x, y := layout.project(point.Longitude, point.Latitude)
|
||||
command := "L"
|
||||
if index == 0 {
|
||||
command = "M"
|
||||
}
|
||||
fmt.Fprintf(b, `%s %.3f %.3f `, command, x, y)
|
||||
}
|
||||
fmt.Fprintf(b, `" clip-path="url(#occultation-map-clip)" fill="none" stroke="%s" stroke-width="%.2f"`, color, strokeWidth)
|
||||
if dash != "" {
|
||||
fmt.Fprintf(b, ` stroke-dasharray="%s"`, dash)
|
||||
}
|
||||
b.WriteString(` stroke-linecap="round" stroke-linejoin="round"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStarOccultationVisibleCenterLine(
|
||||
b *strings.Builder,
|
||||
points []moon.OccultationPathPoint,
|
||||
layout starOccultationSVGLayout,
|
||||
) {
|
||||
visible := make([]moon.OccultationPathPoint, 0, len(points))
|
||||
flush := func() {
|
||||
writeStarOccultationGeoLine(b, visible, layout, "visible-center-line", "#087f8c", 2.8, "")
|
||||
visible = visible[:0]
|
||||
}
|
||||
for _, point := range points {
|
||||
if point.MoonAltitude <= 0 {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if len(visible) > 0 && math.Abs(point.Longitude-visible[len(visible)-1].Longitude) > 180 {
|
||||
flush()
|
||||
}
|
||||
visible = append(visible, point)
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
func writeStarOccultationEventMarker(
|
||||
b *strings.Builder,
|
||||
point moon.OccultationPathPoint,
|
||||
layout starOccultationSVGLayout,
|
||||
label, kind string,
|
||||
) {
|
||||
x, y, visible := layout.mapFrame().Project(point.Longitude, point.Latitude)
|
||||
if !visible {
|
||||
return
|
||||
}
|
||||
placement := starOccultationDefaultEventMarkerPlacement(x, y, kind, layout)
|
||||
writeStarOccultationProjectedEventMarker(b, x, y, label, kind, placement)
|
||||
}
|
||||
|
||||
type starOccultationEventMarkerPlacement struct {
|
||||
labelX, labelY float64
|
||||
anchor string
|
||||
leader bool
|
||||
}
|
||||
|
||||
func starOccultationDefaultEventMarkerPlacement(
|
||||
x, y float64,
|
||||
kind string,
|
||||
layout starOccultationSVGLayout,
|
||||
) starOccultationEventMarkerPlacement {
|
||||
placement := starOccultationEventMarkerPlacement{
|
||||
labelX: x - 7,
|
||||
labelY: y - 8,
|
||||
anchor: "end",
|
||||
}
|
||||
if kind == "greatest" {
|
||||
placement.anchor = "middle"
|
||||
placement.labelX = x
|
||||
placement.labelY = y - 10
|
||||
} else if kind == "end" || kind == "total-end" {
|
||||
placement.anchor = "start"
|
||||
placement.labelX = x + 7
|
||||
}
|
||||
placement.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, placement.labelY))
|
||||
return placement
|
||||
}
|
||||
|
||||
func writeStarOccultationProjectedEventMarker(
|
||||
b *strings.Builder,
|
||||
x, y float64,
|
||||
label, kind string,
|
||||
placement starOccultationEventMarkerPlacement,
|
||||
) {
|
||||
color := "#273d49"
|
||||
radius := 4.0
|
||||
strokeWidth := 1.4
|
||||
if kind == "greatest" {
|
||||
color = "#c44336"
|
||||
radius = 5.2
|
||||
} else if kind == "total-start" || kind == "total-end" {
|
||||
color = "#087f8c"
|
||||
radius = 3.2
|
||||
strokeWidth = 1.0
|
||||
}
|
||||
fmt.Fprintf(b, `<g class="event-marker event-%s">`, kind)
|
||||
if placement.leader {
|
||||
leaderX := placement.labelX
|
||||
switch placement.anchor {
|
||||
case "end":
|
||||
leaderX += 3
|
||||
case "start":
|
||||
leaderX -= 3
|
||||
}
|
||||
leaderY := placement.labelY + 3
|
||||
if placement.labelY > y {
|
||||
leaderY = placement.labelY - 12
|
||||
}
|
||||
fmt.Fprintf(b, `<line class="event-marker-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#52646b" stroke-width="0.9" vector-effect="non-scaling-stroke"/>`,
|
||||
x, y, leaderX, leaderY)
|
||||
}
|
||||
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="%s" stroke="#ffffff" stroke-width="%.1f"/>`,
|
||||
x, y, radius, color, strokeWidth)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#182124" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text></g>`,
|
||||
placement.labelX, placement.labelY, placement.anchor, html.EscapeString(label))
|
||||
}
|
||||
|
||||
func writeStarOccultationLegend(b *strings.Builder, layout starOccultationSVGLayout, language string) {
|
||||
y := layout.mapY + layout.mapHeight + 30
|
||||
labels := []string{"可见中心线", "几何中心线", "掩带边界"}
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
labels = []string{"Visible center line", "Geometric center line", "Occultation limits"}
|
||||
}
|
||||
available := layout.mapWidth / 3
|
||||
styles := []struct {
|
||||
color string
|
||||
dash string
|
||||
}{
|
||||
{"#087f8c", ""},
|
||||
{"#59676b", "5 4"},
|
||||
{"#a66f18", ""},
|
||||
}
|
||||
for index, label := range labels {
|
||||
x := layout.mapX + float64(index)*available
|
||||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="2"`, x, y-4, x+22, y-4, styles[index].color)
|
||||
if styles[index].dash != "" {
|
||||
fmt.Fprintf(b, ` stroke-dasharray="%s"`, styles[index].dash)
|
||||
}
|
||||
b.WriteString(`/>`)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
|
||||
x+28, y, html.EscapeString(label))
|
||||
}
|
||||
}
|
||||
|
||||
func writeStarOccultationEventsPanel(
|
||||
b *strings.Builder,
|
||||
path moon.StarOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
options StarOccultationSVGOptions,
|
||||
) {
|
||||
writeOccultationEventsPanel(b, starOccultationSVGEventRows(path, options.Language), layout, options)
|
||||
}
|
||||
|
||||
func writeOccultationEventsPanel(
|
||||
b *strings.Builder,
|
||||
rows []starOccultationEventRow,
|
||||
layout starOccultationSVGLayout,
|
||||
options StarOccultationSVGOptions,
|
||||
) {
|
||||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
|
||||
layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.mapHeight)
|
||||
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.panelY+13, html.EscapeString(starOccultationSVGContactsTitle(options)))
|
||||
rowTop := layout.panelY + 27
|
||||
rowHeight := math.Max(34, (layout.mapHeight-27)/float64(len(rows)))
|
||||
for index, row := range rows {
|
||||
y := rowTop + 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-5, layout.panelX+layout.panelWidth, y-5)
|
||||
}
|
||||
pointTime := row.point.Time.In(options.Location)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="12" font-weight="700">%s</text>`,
|
||||
layout.panelX, y+11, html.EscapeString(row.name))
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" text-anchor="end">%s</text>`,
|
||||
layout.panelX+layout.panelWidth, y+11, html.EscapeString(pointTime.Format("15:04:05.0")))
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4c585a" font-family="Arial, sans-serif" font-size="10">%s</text>`,
|
||||
layout.panelX, y+29, html.EscapeString(starOccultationFormatCoordinates(row.point.Longitude, row.point.Latitude)))
|
||||
if rowHeight >= 59 {
|
||||
altitudeLabel := "Moon alt."
|
||||
if options.Language != starOccultationSVGLanguageEnglish {
|
||||
altitudeLabel = "月球高度"
|
||||
}
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#697476" font-family="Arial, sans-serif" font-size="10">%s %s</text>`,
|
||||
layout.panelX, y+45, html.EscapeString(altitudeLabel), html.EscapeString(starOccultationFormatSignedDegree(row.point.MoonAltitude)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStarOccultationFooter(
|
||||
b *strings.Builder,
|
||||
layout starOccultationSVGLayout,
|
||||
options StarOccultationSVGOptions,
|
||||
) {
|
||||
lines := starOccultationWrapText(starOccultationSVGFooter(options), layout.width-80, 11)
|
||||
for index, line := range lines {
|
||||
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
|
||||
layout.footerY+float64(index)*15, html.EscapeString(line))
|
||||
}
|
||||
}
|
||||
|
||||
func starOccultationEventLabel(kind, language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
switch kind {
|
||||
case "start":
|
||||
return "Start"
|
||||
case "greatest":
|
||||
return "Greatest"
|
||||
default:
|
||||
return "End"
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case "start":
|
||||
return "掩始"
|
||||
case "greatest":
|
||||
return "掩甚"
|
||||
default:
|
||||
return "掩终"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/svgasset"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
const (
|
||||
localStarOccultationSVGDefaultWidth = 920
|
||||
localStarOccultationSVGDefaultHeight = 720
|
||||
localStarOccultationSVGMinimumWidth = 640
|
||||
localStarOccultationSVGMinimumHeight = 520
|
||||
localStarOccultationSVGDefaultStep = 2 * time.Minute
|
||||
)
|
||||
|
||||
// ErrInvalidLocalStarOccultationSVGOptions 表示本地图表选项无效。
|
||||
// ErrInvalidLocalStarOccultationSVGOptions reports invalid local chart options.
|
||||
var ErrInvalidLocalStarOccultationSVGOptions = errors.New("invalid local stellar occultation SVG options")
|
||||
|
||||
// ErrInvalidLocalStarOccultationInfo 表示固定地点事件数据格式错误。
|
||||
// ErrInvalidLocalStarOccultationInfo reports malformed fixed-site event data.
|
||||
var ErrInvalidLocalStarOccultationInfo = errors.New("invalid local stellar occultation info")
|
||||
|
||||
// LocalStarOccultationSVGOptions 控制固定地点月球与恒星盘面图。
|
||||
// LocalStarOccultationSVGOptions controls a fixed-site Moon-star disk chart.
|
||||
type LocalStarOccultationSVGOptions 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 stellar-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
|
||||
}
|
||||
|
||||
type localStarOccultationSVGLayout struct {
|
||||
width float64
|
||||
height float64
|
||||
margin float64
|
||||
overviewLeft float64
|
||||
overviewRight float64
|
||||
overviewTop float64
|
||||
overviewBottom float64
|
||||
panelX float64
|
||||
panelWidth float64
|
||||
stageTop float64
|
||||
stageBottom float64
|
||||
footerY float64
|
||||
}
|
||||
|
||||
type localStarOccultationEventFrame struct {
|
||||
label string
|
||||
name string
|
||||
time time.Time
|
||||
frame moon.StarOccultationDiagramFrame
|
||||
}
|
||||
|
||||
// FindLocalStarOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次点光源恒星月掩。不会加载内嵌星表;空切片表示没有本地事件。
|
||||
// FindLocalStarOccultationSVGs searches one fixed observing site and renders every point-source stellar occultation in the requested time window. It does not load the embedded star catalog; an empty slice means no local event.
|
||||
func FindLocalStarOccultationSVGs(
|
||||
start, end time.Time,
|
||||
star moon.StarCoordinate,
|
||||
longitude, latitude, height float64,
|
||||
searchOptions moon.OccultationSearchOptions,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) ([]string, error) {
|
||||
if err := validateLocalStarOccultationSVGOptions(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := moon.FindStarOccultations(start, end, star, longitude, latitude, height, searchOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diagrams := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
diagram, renderErr := LocalStarOccultationSVG(event, star, options)
|
||||
if renderErr != nil {
|
||||
return nil, renderErr
|
||||
}
|
||||
diagrams = append(diagrams, diagram)
|
||||
}
|
||||
return diagrams, nil
|
||||
}
|
||||
|
||||
// LocalStarOccultationSVG 渲染已求解的固定地点恒星月掩的月心轨迹及掩始、掩甚、掩终阶段面板;不会运行事件搜索或加载星表。
|
||||
// LocalStarOccultationSVG renders an already solved fixed-site stellar occultation as a Moon-centered track with immersion, greatest, and emersion stage panels. It does not run the event search or load the star catalog.
|
||||
func LocalStarOccultationSVG(
|
||||
info moon.StarOccultationInfo,
|
||||
star moon.StarCoordinate,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) (string, error) {
|
||||
if err := validateLocalStarOccultationInfo(info, star); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalStarOccultationSVGOptions(options); err != nil {
|
||||
return "", err
|
||||
}
|
||||
options = normalizeLocalStarOccultationSVGOptions(options)
|
||||
diagram := moon.StarOccultationDiagram(info, star, moon.StarOccultationDiagramOptions{
|
||||
StepDays: options.Step.Hours() / 24,
|
||||
})
|
||||
if len(diagram.Frames) == 0 {
|
||||
return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalStarOccultationInfo)
|
||||
}
|
||||
return renderLocalStarOccultationSVG(info, diagram, options), nil
|
||||
}
|
||||
|
||||
func validateLocalStarOccultationSVGOptions(options LocalStarOccultationSVGOptions) error {
|
||||
if options.Width > 0 && options.Width < localStarOccultationSVGMinimumWidth {
|
||||
return fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidLocalStarOccultationSVGOptions, localStarOccultationSVGMinimumWidth)
|
||||
}
|
||||
if options.Height > 0 && options.Height < localStarOccultationSVGMinimumHeight {
|
||||
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidLocalStarOccultationSVGOptions, localStarOccultationSVGMinimumHeight)
|
||||
}
|
||||
if options.Step < 0 {
|
||||
return fmt.Errorf("%w: step cannot be negative", ErrInvalidLocalStarOccultationSVGOptions)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLocalStarOccultationInfo(info moon.StarOccultationInfo, star moon.StarCoordinate) error {
|
||||
if err := star.Validate(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidLocalStarOccultationInfo, err)
|
||||
}
|
||||
if info.TargetID != "" && star.ID != "" && info.TargetID != star.ID {
|
||||
return fmt.Errorf("%w: event target %q does not match star %q", ErrInvalidLocalStarOccultationInfo, info.TargetID, star.ID)
|
||||
}
|
||||
if err := info.Observer.Validate(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidLocalStarOccultationInfo, err)
|
||||
}
|
||||
if !info.ContactsComplete || info.Immersion.IsZero() || info.Greatest.IsZero() || info.Emersion.IsZero() {
|
||||
return fmt.Errorf("%w: complete immersion, greatest, and emersion times are required", ErrInvalidLocalStarOccultationInfo)
|
||||
}
|
||||
if info.Greatest.Before(info.Immersion) || info.Emersion.Before(info.Greatest) {
|
||||
return fmt.Errorf("%w: event times must be ordered", ErrInvalidLocalStarOccultationInfo)
|
||||
}
|
||||
if info.Type != moon.OccultationTotal && info.Type != moon.OccultationGrazing {
|
||||
return fmt.Errorf("%w: unsupported point-source geometry %q", ErrInvalidLocalStarOccultationInfo, info.Type)
|
||||
}
|
||||
if !starOccultationFinite(info.MinimumSeparationArcsec) || info.MinimumSeparationArcsec < 0 ||
|
||||
!starOccultationFinite(info.MoonSemidiameterArcsec) || info.MoonSemidiameterArcsec <= 0 ||
|
||||
!starOccultationFinite(info.PositionAngleDeg) ||
|
||||
!starOccultationFinite(info.MoonAltitudeAtGreatest) ||
|
||||
!starOccultationFinite(info.MoonAzimuthAtGreatest) {
|
||||
return fmt.Errorf("%w: event geometry must be finite", ErrInvalidLocalStarOccultationInfo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLocalStarOccultationSVGOptions(options LocalStarOccultationSVGOptions) LocalStarOccultationSVGOptions {
|
||||
if options.Width <= 0 {
|
||||
options.Width = localStarOccultationSVGDefaultWidth
|
||||
}
|
||||
if options.Height <= 0 {
|
||||
options.Height = localStarOccultationSVGDefaultHeight
|
||||
}
|
||||
if options.Step <= 0 {
|
||||
options.Step = localStarOccultationSVGDefaultStep
|
||||
}
|
||||
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 renderLocalStarOccultationSVG(
|
||||
info moon.StarOccultationInfo,
|
||||
diagram moon.StarOccultationDiagramResult,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) string {
|
||||
title := localStarOccultationSVGTitle(info, options)
|
||||
headerLines := localStarOccultationSVGHeaderLines(info, options)
|
||||
headerBottom := 72.0 + float64(len(headerLines))*19
|
||||
layout := localStarOccultationSVGLayoutFor(options, headerBottom)
|
||||
events := localStarOccultationSVGEventFrames(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-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)
|
||||
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, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
|
||||
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))
|
||||
}
|
||||
|
||||
writeLocalStarOccultationOverview(&b, diagram, events, layout, options)
|
||||
writeLocalStarOccultationContacts(&b, events, layout, options)
|
||||
writeLocalStarOccultationStages(&b, events, layout, options)
|
||||
writeLocalStarOccultationFooter(&b, layout, options)
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, headerBottom float64) localStarOccultationSVGLayout {
|
||||
width := float64(options.Width)
|
||||
height := float64(options.Height)
|
||||
margin := math.Max(30, math.Min(44, width*0.05))
|
||||
gap := math.Max(18, math.Min(24, width*0.025))
|
||||
panelWidth := math.Max(188, math.Min(236, width*0.25))
|
||||
overviewLeft := margin
|
||||
overviewRight := width - margin - panelWidth - gap
|
||||
footerSpace := 64.0
|
||||
stageHeight := math.Max(132, math.Min(170, height*0.23))
|
||||
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 - 43,
|
||||
}
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationOverview(
|
||||
b *strings.Builder,
|
||||
diagram moon.StarOccultationDiagramResult,
|
||||
events []localStarOccultationEventFrame,
|
||||
layout localStarOccultationSVGLayout,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) {
|
||||
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(localStarOccultationSVGOverviewTitle(options)))
|
||||
cx := (layout.overviewLeft + layout.overviewRight) / 2
|
||||
cy := (layout.overviewTop+layout.overviewBottom)/2 + 4
|
||||
extent := localStarOccultationSVGExtent(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 := localStarOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
|
||||
|
||||
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
|
||||
writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon")
|
||||
writeLocalStarOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
|
||||
writeLocalStarOccultationTrack(b, diagram.Frames, mapX, mapY)
|
||||
for _, event := range events {
|
||||
x := mapX(event.frame.StarXArcsec)
|
||||
y := mapY(event.frame.StarYArcsec)
|
||||
writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "overview-star", event.label)
|
||||
writeLocalStarOccultationOverviewLabel(b, event, x, y, cx, cy, options.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func localStarOccultationSVGExtent(frames []moon.StarOccultationDiagramFrame) float64 {
|
||||
extent := 1.0
|
||||
for _, frame := range frames {
|
||||
extent = math.Max(extent, frame.MoonRadiusArcsec)
|
||||
extent = math.Max(extent, math.Abs(frame.StarXArcsec))
|
||||
extent = math.Max(extent, math.Abs(frame.StarYArcsec))
|
||||
}
|
||||
return extent * 1.28
|
||||
}
|
||||
|
||||
func localStarOccultationSVGMaximumMoonRadius(frames []moon.StarOccultationDiagramFrame) float64 {
|
||||
radius := 1.0
|
||||
for _, frame := range frames {
|
||||
radius = math.Max(radius, frame.MoonRadiusArcsec)
|
||||
}
|
||||
return radius
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationTrack(
|
||||
b *strings.Builder,
|
||||
frames []moon.StarOccultationDiagramFrame,
|
||||
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.StarXArcsec), mapY(frame.StarYArcsec))
|
||||
}
|
||||
fmt.Fprintf(b, `<path class="local-star-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationLunarPath(
|
||||
b *strings.Builder,
|
||||
frames []moon.StarOccultationDiagramFrame,
|
||||
mapX, mapY func(float64) float64,
|
||||
extent float64,
|
||||
language string,
|
||||
) {
|
||||
unitX, unitY, ok := localStarOccultationSVGLunarPathDirection(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, anchor := startX, startY, "end"
|
||||
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="%s">%s</text>`,
|
||||
labelX-6, labelY-6, anchor, html.EscapeString(localStarOccultationSVGLunarPathLabel(language)))
|
||||
}
|
||||
|
||||
// 恒星轨迹相对固定月心测量,因此其切线与月球站心视运动反向。参考线没有箭头,因此归一化轨迹切线就是图表使用的月掩路径轴。
|
||||
// The star track is measured relative to a fixed lunar center, so its tangent is antiparallel to the Moon's topocentric apparent motion. A reference line has no arrow, therefore the normalized track tangent is the same lunar-path axis used by the chart.
|
||||
func localStarOccultationSVGLunarPathDirection(frames []moon.StarOccultationDiagramFrame) (float64, float64, bool) {
|
||||
if len(frames) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
greatest := -1
|
||||
for index, frame := range frames {
|
||||
if localStarOccultationSVGFrameHasLabel(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].StarXArcsec - frames[left].StarXArcsec
|
||||
dy := frames[right].StarYArcsec - frames[left].StarYArcsec
|
||||
length := math.Hypot(dx, dy)
|
||||
if !starOccultationFinite(length) || length == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return dx / length, dy / length, true
|
||||
}
|
||||
|
||||
func localStarOccultationSVGLunarPathLabel(language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
return "Lunar path"
|
||||
}
|
||||
return "白道"
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationMoon(b *strings.Builder, cx, cy, radius float64, class string) {
|
||||
fmt.Fprintf(b, `<g class="%s">`, html.EscapeString(class))
|
||||
fmt.Fprintf(b, `<use href="#le-moon" x="%.3f" y="%.3f" width="%.3f" height="%.3f"/>`,
|
||||
cx-radius, cy-radius, radius*2, radius*2)
|
||||
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#4f5d60" stroke-width="1.1" opacity="0.9"/>`, cx, cy, radius)
|
||||
b.WriteString(`</g>`)
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string) {
|
||||
north, east, west, south := "北", "东", "西", "南"
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
north, east, west, south = "N", "E", "W", "S"
|
||||
}
|
||||
labels := []struct {
|
||||
x, y float64
|
||||
anchor string
|
||||
text string
|
||||
}{
|
||||
{cx, cy - radius - 15, "middle", north},
|
||||
{cx - radius - 18, cy + 4, "middle", east},
|
||||
{cx + radius + 18, cy + 4, "middle", west},
|
||||
{cx, cy + radius + 24, "middle", south},
|
||||
}
|
||||
for _, label := range labels {
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700" text-anchor="%s">%s</text>`,
|
||||
label.x, label.y, label.anchor, html.EscapeString(label.text))
|
||||
}
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationStar(b *strings.Builder, x, y float64, hidden bool, class, label string) {
|
||||
fill, stroke, dash := "#fff7d6", "#9b3028", ""
|
||||
if hidden {
|
||||
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
|
||||
}
|
||||
fmt.Fprintf(b, `<g class="%s" data-label="%s">`, html.EscapeString(class), html.EscapeString(label))
|
||||
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="4.2" fill="%s" stroke="%s" stroke-width="1.4"%s/>`, x, y, fill, stroke, dash)
|
||||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x-6, y, x+6, y, stroke)
|
||||
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x, y-6, x, y+6, stroke)
|
||||
b.WriteString(`</g>`)
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationOverviewLabel(
|
||||
b *strings.Builder,
|
||||
event localStarOccultationEventFrame,
|
||||
x, y, cx, cy float64,
|
||||
language string,
|
||||
) {
|
||||
dx, dy, anchor := 8.0, -9.0, "start"
|
||||
if x > cx {
|
||||
dx, anchor = -8, "end"
|
||||
}
|
||||
if y < cy-20 {
|
||||
dy = 15
|
||||
}
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#792d28" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
|
||||
x+dx, y+dy, anchor, html.EscapeString(localStarOccultationSVGEventName(event.label, language)))
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationContacts(
|
||||
b *strings.Builder,
|
||||
events []localStarOccultationEventFrame,
|
||||
layout localStarOccultationSVGLayout,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) {
|
||||
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)
|
||||
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(localStarOccultationSVGContactsTitle(options)))
|
||||
available := layout.overviewBottom - layout.overviewTop - 10
|
||||
rowHeight := math.Min(86, available/math.Max(1, float64(len(events))))
|
||||
for index, event := range events {
|
||||
y := layout.overviewTop + 17 + 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-13, layout.panelX+layout.panelWidth, y-13)
|
||||
}
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="12" 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="11" 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="10">PA %.1f° | %s %.1f°</text>`,
|
||||
layout.panelX, y+18, event.frame.PositionAngleDeg, localStarOccultationSVGAltitudeLabel(options.Language), event.frame.MoonAltitudeDeg)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b7577" font-family="Arial, sans-serif" font-size="10">%s %.1f° | %s</text>`,
|
||||
layout.panelX, y+34, localStarOccultationSVGAzimuthLabel(options.Language), event.frame.MoonAzimuthDeg,
|
||||
html.EscapeString(localStarOccultationSVGVisibilityText(event.frame.MoonAltitudeDeg >= 0, options.Language)))
|
||||
}
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationStages(
|
||||
b *strings.Builder,
|
||||
events []localStarOccultationEventFrame,
|
||||
layout localStarOccultationSVGLayout,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) {
|
||||
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(localStarOccultationSVGPhasePanelsTitle(options)))
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
usableWidth := layout.width - 2*layout.margin
|
||||
columnWidth := usableWidth / float64(len(events))
|
||||
maxMoonRadius := localStarOccultationSVGMaximumMoonRadiusFromEvents(events)
|
||||
radius := math.Min(46, math.Max(31, (layout.stageBottom-layout.stageTop-53)/2))
|
||||
scale := radius / maxMoonRadius
|
||||
cy := layout.stageTop + 34 + radius
|
||||
for index, event := range events {
|
||||
cx := layout.margin + columnWidth*(float64(index)+0.5)
|
||||
writeLocalStarOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon")
|
||||
x := cx - event.frame.StarXArcsec*scale
|
||||
y := cy - event.frame.StarYArcsec*scale
|
||||
writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "stage-star", event.label)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="middle">%s</text>`,
|
||||
cx, cy+radius+19, html.EscapeString(event.name))
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#667174" font-family="Arial, sans-serif" font-size="10" text-anchor="middle">%s</text>`,
|
||||
cx, cy+radius+34, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
|
||||
}
|
||||
}
|
||||
|
||||
func localStarOccultationSVGMaximumMoonRadiusFromEvents(events []localStarOccultationEventFrame) float64 {
|
||||
radius := 1.0
|
||||
for _, event := range events {
|
||||
radius = math.Max(radius, event.frame.MoonRadiusArcsec)
|
||||
}
|
||||
return radius
|
||||
}
|
||||
|
||||
func writeLocalStarOccultationFooter(
|
||||
b *strings.Builder,
|
||||
layout localStarOccultationSVGLayout,
|
||||
options LocalStarOccultationSVGOptions,
|
||||
) {
|
||||
directionLines := starOccultationWrapText(localStarOccultationSVGDirectionText(options), layout.width-80, 10)
|
||||
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))
|
||||
}
|
||||
footerLines := starOccultationWrapText(localStarOccultationSVGFooterNote(options), layout.width-80, 9)
|
||||
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 localStarOccultationSVGEventFrames(
|
||||
info moon.StarOccultationInfo,
|
||||
frames []moon.StarOccultationDiagramFrame,
|
||||
language string,
|
||||
) []localStarOccultationEventFrame {
|
||||
times := map[string]time.Time{
|
||||
"Immersion": info.Immersion,
|
||||
"Greatest": info.Greatest,
|
||||
"Emersion": info.Emersion,
|
||||
}
|
||||
labels := []string{"Immersion", "Greatest", "Emersion"}
|
||||
result := make([]localStarOccultationEventFrame, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
for _, frame := range frames {
|
||||
if localStarOccultationSVGFrameHasLabel(frame, label) {
|
||||
result = append(result, localStarOccultationEventFrame{
|
||||
label: label,
|
||||
name: localStarOccultationSVGEventName(label, language),
|
||||
time: times[label],
|
||||
frame: frame,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func localStarOccultationSVGFrameHasLabel(frame moon.StarOccultationDiagramFrame, label string) bool {
|
||||
if frame.Label == label {
|
||||
return true
|
||||
}
|
||||
for _, current := range frame.Labels {
|
||||
if current == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func localStarOccultationSVGHeaderLines(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) []string {
|
||||
lines := make([]string, 0, 4)
|
||||
for _, value := range []string{
|
||||
localStarOccultationSVGSummaryText(info, options),
|
||||
localStarOccultationSVGGreatestText(info, options),
|
||||
} {
|
||||
lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func localStarOccultationSVGTitle(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string {
|
||||
if options.Title != "" {
|
||||
return options.Title
|
||||
}
|
||||
target := info.TargetID
|
||||
if target == "" {
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
target = "star"
|
||||
} else {
|
||||
target = "恒星"
|
||||
}
|
||||
}
|
||||
date := info.Greatest.In(options.Location).Format("2006-01-02")
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("%s Local Lunar Occultation of %s", date, target)
|
||||
}
|
||||
return fmt.Sprintf("%s 指定地点月掩%s", date, target)
|
||||
}
|
||||
|
||||
func localStarOccultationSVGSummaryText(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string {
|
||||
if options.SummaryText != "" {
|
||||
return options.SummaryText
|
||||
}
|
||||
coordinates := starOccultationFormatCoordinates(info.Observer.Longitude, info.Observer.Latitude)
|
||||
duration := localStarOccultationSVGFormatDuration(info.Emersion.Sub(info.Immersion), options.Language)
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("Site %s | elevation %.0f m | %s | duration %s", coordinates, info.Observer.Height,
|
||||
localStarOccultationSVGTypeName(info.Type, options.Language), duration)
|
||||
}
|
||||
return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | 掩星历时 %s", coordinates, info.Observer.Height,
|
||||
localStarOccultationSVGTypeName(info.Type, options.Language), duration)
|
||||
}
|
||||
|
||||
func localStarOccultationSVGGreatestText(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) 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 | minimum separation %.2f arcsec | Moon altitude %+.1f° azimuth %.1f°",
|
||||
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec, info.MoonAltitudeAtGreatest, info.MoonAzimuthAtGreatest)
|
||||
}
|
||||
return fmt.Sprintf("掩甚 %s %s | 最小角距 %.2f 角秒 | 月球高度 %+.1f° 方位 %.1f°",
|
||||
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec, info.MoonAltitudeAtGreatest, info.MoonAzimuthAtGreatest)
|
||||
}
|
||||
|
||||
func localStarOccultationSVGOverviewTitle(options LocalStarOccultationSVGOptions) string {
|
||||
if options.OverviewTitle != "" {
|
||||
return options.OverviewTitle
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "Topocentric star track"
|
||||
}
|
||||
return "站心恒星轨迹"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGPhasePanelsTitle(options LocalStarOccultationSVGOptions) string {
|
||||
if options.PhasePanelsTitle != "" {
|
||||
return options.PhasePanelsTitle
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "Contact stages"
|
||||
}
|
||||
return "掩始、掩甚与掩终视圆"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGContactsTitle(options LocalStarOccultationSVGOptions) string {
|
||||
if options.ContactsTitle != "" {
|
||||
return options.ContactsTitle
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "Local contacts"
|
||||
}
|
||||
return "本地接触时刻"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGDirectionText(options LocalStarOccultationSVGOptions) string {
|
||||
if options.DirectionText != "" {
|
||||
return options.DirectionText
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track."
|
||||
}
|
||||
return "月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为恒星相对月心轨迹。"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGFooterNote(options LocalStarOccultationSVGOptions) string {
|
||||
if options.FooterNote != "" {
|
||||
return options.FooterNote
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "The star is a point source; immersion and emersion occur where it crosses the topocentric apparent lunar limb. Lunar texture is schematic."
|
||||
}
|
||||
return "恒星按点光源绘制;掩始和掩终是恒星穿越站心月球视圆外缘的时刻,月面纹理仅作方向辅助。"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGEventName(label, language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
switch label {
|
||||
case "Immersion":
|
||||
return "Immersion"
|
||||
case "Greatest":
|
||||
return "Greatest"
|
||||
case "Emersion":
|
||||
return "Emersion"
|
||||
}
|
||||
}
|
||||
switch label {
|
||||
case "Immersion":
|
||||
return "掩始"
|
||||
case "Greatest":
|
||||
return "掩甚"
|
||||
case "Emersion":
|
||||
return "掩终"
|
||||
default:
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
func localStarOccultationSVGTypeName(eventType moon.OccultationType, language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
if eventType == moon.OccultationGrazing {
|
||||
return "grazing"
|
||||
}
|
||||
return "total"
|
||||
}
|
||||
if eventType == moon.OccultationGrazing {
|
||||
return "擦边掩星"
|
||||
}
|
||||
return "全掩"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGFormatDuration(duration time.Duration, language string) string {
|
||||
if duration < 0 {
|
||||
duration = -duration
|
||||
}
|
||||
duration = duration.Round(100 * time.Millisecond)
|
||||
hours := int(duration / time.Hour)
|
||||
duration -= time.Duration(hours) * time.Hour
|
||||
minutes := int(duration / time.Minute)
|
||||
duration -= time.Duration(minutes) * time.Minute
|
||||
seconds := float64(duration) / float64(time.Second)
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %02dm %04.1fs", hours, minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%dm %04.1fs", minutes, seconds)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d时%02d分%04.1f秒", hours, minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%d分%04.1f秒", minutes, seconds)
|
||||
}
|
||||
|
||||
func localStarOccultationSVGAltitudeLabel(language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
return "alt"
|
||||
}
|
||||
return "高度"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGAzimuthLabel(language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
return "az"
|
||||
}
|
||||
return "方位"
|
||||
}
|
||||
|
||||
func localStarOccultationSVGVisibilityText(visible bool, language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
if visible {
|
||||
return "above horizon"
|
||||
}
|
||||
return "below horizon"
|
||||
}
|
||||
if visible {
|
||||
return "地平线上"
|
||||
}
|
||||
return "地平线下"
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestFindLocalStarOccultationSVGsHR4799(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*3600)
|
||||
diagrams, err := FindLocalStarOccultationSVGs(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
hr4799StarCoordinate(),
|
||||
121.56601, 6.80706, 0,
|
||||
moon.OccultationSearchOptions{},
|
||||
LocalStarOccultationSVGOptions{Width: 720, Height: 560, Location: location},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindLocalStarOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 1 {
|
||||
t.Fatalf("FindLocalStarOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
|
||||
}
|
||||
diagram := diagrams[0]
|
||||
for _, want := range []string{
|
||||
`<svg`, `width="720"`, `height="560"`, "2025-06-05", "HR 4799",
|
||||
"指定地点月掩", "观测点", "站心恒星轨迹", "掩始、掩甚与掩终视圆",
|
||||
"本地接触时刻", "掩始", "掩甚", "掩终", "左东右西", "UTC+8",
|
||||
`class="local-star-track"`, `class="overview-moon"`, `class="overview-star"`,
|
||||
`class="lunar-path-line"`, `class="lunar-path-label"`, "白道",
|
||||
`<symbol id="le-moon"`, `href="#le-moon"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("local SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(diagram, "local-occultation-moon") || strings.Contains(diagram, "<radialGradient") {
|
||||
t.Fatalf("local SVG still contains the temporary synthetic Moon rendering")
|
||||
}
|
||||
if strings.Contains(diagram, "全球掩带") || strings.Contains(diagram, "全球中心线") {
|
||||
t.Fatalf("local SVG unexpectedly contains global-path labels")
|
||||
}
|
||||
if got := strings.Count(diagram, `class="stage-moon"`); got != 3 {
|
||||
t.Fatalf("stage Moon count = %d, want 3", got)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="stage-star"`); got != 3 {
|
||||
t.Fatalf("stage star count = %d, want 3", got)
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated local SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStarOccultationSVGEnglishAndCustomText(t *testing.T) {
|
||||
info := localHR4799Occultation(t)
|
||||
diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
|
||||
Language: "en",
|
||||
Location: time.UTC,
|
||||
Title: "Custom local title",
|
||||
SummaryText: "Custom local summary",
|
||||
GreatestText: "Custom local greatest",
|
||||
OverviewTitle: "Custom local overview",
|
||||
PhasePanelsTitle: "Custom local stages",
|
||||
ContactsTitle: "Custom local contacts",
|
||||
DirectionText: "Custom local direction",
|
||||
FooterNote: "Custom local footer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LocalStarOccultationSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Custom local title", "Custom local summary", "Custom local greatest",
|
||||
"Custom local overview", "Custom local stages", "Custom local contacts",
|
||||
"Custom local direction", "Custom local footer", "Immersion", "Greatest", "Emersion", "Lunar path",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("English local SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStarOccultationSVGLunarPathUsesGreatestTangent(t *testing.T) {
|
||||
frames := []moon.StarOccultationDiagramFrame{
|
||||
{StarXArcsec: 100, StarYArcsec: 100},
|
||||
{StarXArcsec: -1, StarYArcsec: -2},
|
||||
{StarXArcsec: 0, StarYArcsec: 0, Label: "Greatest", Labels: []string{"Greatest"}},
|
||||
{StarXArcsec: 1, StarYArcsec: 2},
|
||||
{StarXArcsec: -100, StarYArcsec: 100},
|
||||
}
|
||||
unitX, unitY, ok := localStarOccultationSVGLunarPathDirection(frames)
|
||||
if !ok {
|
||||
t.Fatalf("localStarOccultationSVGLunarPathDirection() returned no direction")
|
||||
}
|
||||
if math.Abs(unitX-1/math.Sqrt(5)) > 1e-12 || math.Abs(unitY-2/math.Sqrt(5)) > 1e-12 {
|
||||
t.Fatalf("lunar path direction = %.12f %.12f, want greatest tangent", unitX, unitY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLocalStarOccultationSVGsNoEvent(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
star := hr4799StarCoordinate()
|
||||
star.Dec = 80
|
||||
diagrams, err := FindLocalStarOccultationSVGs(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
star, 121.56601, 6.80706, 0,
|
||||
moon.OccultationSearchOptions{}, LocalStarOccultationSVGOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindLocalStarOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 0 {
|
||||
t.Fatalf("FindLocalStarOccultationSVGs() returned %d diagrams, want none", len(diagrams))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStarOccultationSVGRejectsInvalidInput(t *testing.T) {
|
||||
info := localHR4799Occultation(t)
|
||||
_, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{Width: 1})
|
||||
if !errors.Is(err, ErrInvalidLocalStarOccultationSVGOptions) {
|
||||
t.Fatalf("invalid canvas error = %v, want ErrInvalidLocalStarOccultationSVGOptions", err)
|
||||
}
|
||||
info.ContactsComplete = false
|
||||
_, err = LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
|
||||
t.Fatalf("incomplete event error = %v, want ErrInvalidLocalStarOccultationInfo", err)
|
||||
}
|
||||
|
||||
info = localHR4799Occultation(t)
|
||||
otherStar := hr4799StarCoordinate()
|
||||
otherStar.ID = "another-star"
|
||||
_, err = LocalStarOccultationSVG(info, otherStar, LocalStarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
|
||||
t.Fatalf("mismatched target error = %v, want ErrInvalidLocalStarOccultationInfo", err)
|
||||
}
|
||||
|
||||
info = localHR4799Occultation(t)
|
||||
wrongCoordinate := hr4799StarCoordinate()
|
||||
wrongCoordinate.RA += 30
|
||||
_, err = LocalStarOccultationSVG(info, wrongCoordinate, LocalStarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
|
||||
t.Fatalf("same-ID wrong coordinate error = %v, want ErrInvalidLocalStarOccultationInfo", err)
|
||||
}
|
||||
|
||||
info.TargetID = ""
|
||||
wrongCoordinate.ID = ""
|
||||
_, err = LocalStarOccultationSVG(info, wrongCoordinate, LocalStarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
|
||||
t.Fatalf("empty-ID wrong coordinate error = %v, want ErrInvalidLocalStarOccultationInfo", err)
|
||||
}
|
||||
}
|
||||
|
||||
func localHR4799Occultation(t *testing.T) moon.StarOccultationInfo {
|
||||
t.Helper()
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
events, err := moon.FindStarOccultations(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
hr4799StarCoordinate(), 121.56601, 6.80706, 0, moon.OccultationSearchOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultations() error = %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("FindStarOccultations() returned %d events, want 1", len(events))
|
||||
}
|
||||
return events[0]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package svg
|
||||
|
||||
import "b612.me/astro/internal/svgmap"
|
||||
|
||||
// MapProjection 控制全球月掩地图使用的投影。
|
||||
// 零值根据事件几何自动选择投影。
|
||||
// MapProjection controls the projection used by a global occultation map.
|
||||
// The zero value selects a projection from the event geometry.
|
||||
type MapProjection string
|
||||
|
||||
const (
|
||||
// MapProjectionAuto 根据事件几何自动选择投影。
|
||||
// MapProjectionAuto selects a projection from event geometry.
|
||||
MapProjectionAuto MapProjection = ""
|
||||
// MapProjectionEquirectangular 使用等经纬投影。
|
||||
// MapProjectionEquirectangular uses the equirectangular projection.
|
||||
MapProjectionEquirectangular MapProjection = "equirectangular"
|
||||
// MapProjectionNorthPolar 使用北极方位等距投影。
|
||||
// MapProjectionNorthPolar uses the north-polar azimuthal equidistant projection.
|
||||
MapProjectionNorthPolar MapProjection = "north-polar"
|
||||
// MapProjectionSouthPolar 使用南极方位等距投影。
|
||||
// MapProjectionSouthPolar uses the south-polar azimuthal equidistant projection.
|
||||
MapProjectionSouthPolar MapProjection = "south-polar"
|
||||
)
|
||||
|
||||
func internalMapProjection(value MapProjection) svgmap.Projection {
|
||||
return svgmap.Projection(value)
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"b612.me/astro/internal/svgmap"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
// ErrInvalidStarOccultationPath 表示传入 SVG 渲染器的掩带数据无效。
|
||||
// ErrInvalidStarOccultationPath reports malformed path data passed to the SVG renderer.
|
||||
var ErrInvalidStarOccultationPath = errors.New("invalid stellar occultation path")
|
||||
|
||||
type starOccultationSVGLayout struct {
|
||||
width float64
|
||||
height float64
|
||||
margin float64
|
||||
mapX float64
|
||||
mapY float64
|
||||
mapWidth float64
|
||||
mapHeight float64
|
||||
panelX float64
|
||||
panelY float64
|
||||
panelWidth float64
|
||||
footerY float64
|
||||
projection svgmap.Projection
|
||||
}
|
||||
|
||||
type starOccultationGeoPoint struct {
|
||||
longitude float64
|
||||
latitude float64
|
||||
}
|
||||
|
||||
type starOccultationEventRow struct {
|
||||
name string
|
||||
point moon.OccultationPathPoint
|
||||
}
|
||||
|
||||
func validateStarOccultationPath(path moon.StarOccultationPath) error {
|
||||
if !path.Complete {
|
||||
return fmt.Errorf("%w: global path is incomplete", ErrInvalidStarOccultationPath)
|
||||
}
|
||||
if err := validateStarOccultationPathPoint("start", path.Start); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStarOccultationPathPoint("greatest", path.Greatest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStarOccultationPathPoint("end", path.End); err != nil {
|
||||
return err
|
||||
}
|
||||
if path.Greatest.Time.Before(path.Start.Time) || path.End.Time.Before(path.Greatest.Time) {
|
||||
return fmt.Errorf("%w: event times must be ordered start, greatest, end", ErrInvalidStarOccultationPath)
|
||||
}
|
||||
if err := (moon.OccultationPathOptions{Step: path.Step, TargetSpacingKM: path.TargetSpacingKM}).Validate(); err != nil {
|
||||
return fmt.Errorf("%w: invalid path sampling metadata: %v", ErrInvalidStarOccultationPath, err)
|
||||
}
|
||||
series := []struct {
|
||||
name string
|
||||
points []moon.OccultationPathPoint
|
||||
}{
|
||||
{"center line", path.CenterLine},
|
||||
{"northern limit", path.NorthernLimit},
|
||||
{"southern limit", path.SouthernLimit},
|
||||
}
|
||||
for _, current := range series {
|
||||
name, points := current.name, current.points
|
||||
for index, point := range points {
|
||||
if err := validateStarOccultationPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
|
||||
return err
|
||||
}
|
||||
if index > 0 && !point.Time.After(points[index-1].Time) {
|
||||
return fmt.Errorf("%w: %s times must be strictly increasing", ErrInvalidStarOccultationPath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(path.NorthernLimit) != len(path.SouthernLimit) {
|
||||
return fmt.Errorf("%w: northern and southern limits must have the same sample count", ErrInvalidStarOccultationPath)
|
||||
}
|
||||
if len(path.NorthernLimit) < 2 {
|
||||
return fmt.Errorf("%w: global limits must contain start and end", ErrInvalidStarOccultationPath)
|
||||
}
|
||||
for index := range path.NorthernLimit {
|
||||
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
|
||||
return fmt.Errorf("%w: northern and southern limit sample %d times must match", ErrInvalidStarOccultationPath, index)
|
||||
}
|
||||
}
|
||||
last := len(path.NorthernLimit) - 1
|
||||
if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
|
||||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
|
||||
return fmt.Errorf("%w: global limits must span start through end", ErrInvalidStarOccultationPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStarOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
|
||||
if point.Time.IsZero() {
|
||||
return fmt.Errorf("%w: %s time is required", ErrInvalidStarOccultationPath, name)
|
||||
}
|
||||
if !starOccultationFinite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 {
|
||||
return fmt.Errorf("%w: %s longitude must be in [-180, 180]", ErrInvalidStarOccultationPath, name)
|
||||
}
|
||||
if !starOccultationFinite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 {
|
||||
return fmt.Errorf("%w: %s latitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
|
||||
}
|
||||
if !starOccultationFinite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
|
||||
return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
|
||||
}
|
||||
if !starOccultationFinite(point.WidthKM) || point.WidthKM < 0 {
|
||||
return fmt.Errorf("%w: %s width must be finite and non-negative", ErrInvalidStarOccultationPath, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func starOccultationSVGLayoutFor(
|
||||
options StarOccultationSVGOptions,
|
||||
headerBottom float64,
|
||||
projection svgmap.Projection,
|
||||
) starOccultationSVGLayout {
|
||||
width := float64(options.Width)
|
||||
height := float64(options.Height)
|
||||
margin := math.Max(22, math.Min(42, width*0.04))
|
||||
gap := math.Max(16, math.Min(24, width*0.025))
|
||||
panelWidth := math.Max(148, math.Min(238, width*0.23))
|
||||
mapWidth := width - 2*margin - gap - panelWidth
|
||||
if mapWidth < 220 {
|
||||
panelWidth = math.Max(126, width*0.21)
|
||||
mapWidth = width - 2*margin - gap - panelWidth
|
||||
}
|
||||
contentTop := headerBottom + 28
|
||||
footerSpace := 82.0
|
||||
if projection != svgmap.ProjectionEquirectangular {
|
||||
footerSpace = 116
|
||||
}
|
||||
availableHeight := math.Max(110, height-contentTop-footerSpace)
|
||||
mapHeight := math.Min(mapWidth/2, availableHeight)
|
||||
if projection != svgmap.ProjectionEquirectangular {
|
||||
mapHeight = math.Min(mapWidth, availableHeight)
|
||||
mapWidth = mapHeight
|
||||
}
|
||||
if mapHeight < 110 {
|
||||
mapHeight = 110
|
||||
mapWidth = math.Min(mapWidth, 2*mapHeight)
|
||||
}
|
||||
mapY := contentTop + math.Max(0, (availableHeight-mapHeight)/2)
|
||||
return starOccultationSVGLayout{
|
||||
width: width,
|
||||
height: height,
|
||||
margin: margin,
|
||||
mapX: margin,
|
||||
mapY: mapY,
|
||||
mapWidth: mapWidth,
|
||||
mapHeight: mapHeight,
|
||||
panelX: margin + mapWidth + gap,
|
||||
panelY: mapY,
|
||||
panelWidth: panelWidth,
|
||||
footerY: height - 54,
|
||||
projection: projection,
|
||||
}
|
||||
}
|
||||
|
||||
func (layout starOccultationSVGLayout) project(longitude, latitude float64) (float64, float64) {
|
||||
x, y, _ := layout.mapFrame().Project(longitude, latitude)
|
||||
return x, y
|
||||
}
|
||||
|
||||
func (layout starOccultationSVGLayout) mapFrame() svgmap.Frame {
|
||||
return svgmap.Frame{
|
||||
X: layout.mapX,
|
||||
Y: layout.mapY,
|
||||
Width: layout.mapWidth,
|
||||
Height: layout.mapHeight,
|
||||
Projection: layout.projection,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStarOccultationMapProjection(path moon.StarOccultationPath, requested MapProjection) svgmap.Projection {
|
||||
minimumLatitude := path.Greatest.Latitude
|
||||
maximumLatitude := path.Greatest.Latitude
|
||||
for _, series := range [][]moon.OccultationPathPoint{path.CenterLine, path.NorthernLimit, path.SouthernLimit} {
|
||||
for _, point := range series {
|
||||
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
|
||||
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
|
||||
}
|
||||
}
|
||||
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
|
||||
}
|
||||
|
||||
func starOccultationPathSegments(points []moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
segments := make([][]moon.OccultationPathPoint, 0, 2)
|
||||
current := []moon.OccultationPathPoint{points[0]}
|
||||
for index := 1; index < len(points); index++ {
|
||||
if math.Abs(points[index].Longitude-points[index-1].Longitude) <= 180 {
|
||||
current = append(current, points[index])
|
||||
continue
|
||||
}
|
||||
boundary, fraction, ok := starOccultationAntimeridianCrossing(points[index-1], points[index])
|
||||
if !ok {
|
||||
current = append(current, points[index])
|
||||
continue
|
||||
}
|
||||
crossing := starOccultationInterpolatePathPoint(points[index-1], points[index], fraction, boundary)
|
||||
current = append(current, crossing)
|
||||
if len(current) >= 2 {
|
||||
segments = append(segments, current)
|
||||
}
|
||||
wrapped := crossing
|
||||
wrapped.Longitude = -boundary
|
||||
current = []moon.OccultationPathPoint{wrapped, points[index]}
|
||||
}
|
||||
if len(current) >= 2 {
|
||||
segments = append(segments, current)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func starOccultationPathSegmentsForProjection(
|
||||
points []moon.OccultationPathPoint,
|
||||
projection svgmap.Projection,
|
||||
) [][]moon.OccultationPathPoint {
|
||||
if projection == svgmap.ProjectionEquirectangular {
|
||||
return starOccultationPathSegments(points)
|
||||
}
|
||||
geographic := make([]svgmap.GeoPoint, len(points))
|
||||
for index, point := range points {
|
||||
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
clipped := svgmap.PolylineSegments(geographic, projection)
|
||||
result := make([][]moon.OccultationPathPoint, 0, len(clipped))
|
||||
for _, segment := range clipped {
|
||||
converted := make([]moon.OccultationPathPoint, len(segment))
|
||||
for index, point := range segment {
|
||||
converted[index] = moon.OccultationPathPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||||
}
|
||||
result = append(result, converted)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func starOccultationAntimeridianCrossing(a, b moon.OccultationPathPoint) (float64, float64, bool) {
|
||||
if math.Abs(b.Longitude-a.Longitude) <= 180 {
|
||||
return 0, 0, false
|
||||
}
|
||||
boundary := 180.0
|
||||
adjustedB := b.Longitude
|
||||
if a.Longitude < 0 {
|
||||
boundary = -180
|
||||
adjustedB -= 360
|
||||
} else {
|
||||
adjustedB += 360
|
||||
}
|
||||
denominator := adjustedB - a.Longitude
|
||||
if math.Abs(denominator) < 1e-12 {
|
||||
return 0, 0, false
|
||||
}
|
||||
fraction := (boundary - a.Longitude) / denominator
|
||||
if fraction <= 0 || fraction >= 1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return boundary, fraction, true
|
||||
}
|
||||
|
||||
func starOccultationInterpolatePathPoint(a, b moon.OccultationPathPoint, fraction, longitude float64) moon.OccultationPathPoint {
|
||||
if fraction < 0 {
|
||||
fraction = 0
|
||||
}
|
||||
if fraction > 1 {
|
||||
fraction = 1
|
||||
}
|
||||
duration := b.Time.Sub(a.Time)
|
||||
return moon.OccultationPathPoint{
|
||||
Time: a.Time.Add(time.Duration(float64(duration) * fraction)),
|
||||
Longitude: longitude,
|
||||
Latitude: a.Latitude + (b.Latitude-a.Latitude)*fraction,
|
||||
MoonAltitude: a.MoonAltitude + (b.MoonAltitude-a.MoonAltitude)*fraction,
|
||||
WidthKM: a.WidthKM + (b.WidthKM-a.WidthKM)*fraction,
|
||||
}
|
||||
}
|
||||
|
||||
func starOccultationBandSegments(
|
||||
northern, southern []moon.OccultationPathPoint,
|
||||
) [][]starOccultationGeoPoint {
|
||||
return starOccultationBandFragments(northern, southern, svgmap.ProjectionEquirectangular)
|
||||
}
|
||||
|
||||
func starOccultationBandFragments(
|
||||
northern, southern []moon.OccultationPathPoint,
|
||||
projection svgmap.Projection,
|
||||
) [][]starOccultationGeoPoint {
|
||||
count := len(northern)
|
||||
if len(southern) < count {
|
||||
count = len(southern)
|
||||
}
|
||||
if count < 2 {
|
||||
return nil
|
||||
}
|
||||
polygon := make([]starOccultationGeoPoint, 0, 2*count)
|
||||
for _, point := range northern[:count] {
|
||||
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
|
||||
}
|
||||
for index := count - 1; index >= 0; index-- {
|
||||
point := southern[index]
|
||||
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
|
||||
}
|
||||
geographic := make([]svgmap.GeoPoint, len(polygon))
|
||||
for index, point := range polygon {
|
||||
geographic[index] = svgmap.GeoPoint{Longitude: point.longitude, Latitude: point.latitude}
|
||||
}
|
||||
fragments := svgmap.PolygonFragments(geographic, projection)
|
||||
segments := make([][]starOccultationGeoPoint, 0, len(fragments))
|
||||
for _, fragment := range fragments {
|
||||
converted := make([]starOccultationGeoPoint, len(fragment))
|
||||
for index, point := range fragment {
|
||||
converted[index] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude}
|
||||
}
|
||||
if math.Abs(starOccultationPolygonArea(converted)) > 1e-9 {
|
||||
segments = append(segments, converted)
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func legacyStarOccultationBandSegments(polygon []starOccultationGeoPoint) [][]starOccultationGeoPoint {
|
||||
polygon = starOccultationUnwrapPolygon(polygon)
|
||||
minimumLongitude, maximumLongitude := polygon[0].longitude, polygon[0].longitude
|
||||
for _, point := range polygon[1:] {
|
||||
minimumLongitude = math.Min(minimumLongitude, point.longitude)
|
||||
maximumLongitude = math.Max(maximumLongitude, point.longitude)
|
||||
}
|
||||
firstWorld := int(math.Floor((minimumLongitude + 180) / 360))
|
||||
lastWorld := int(math.Floor((maximumLongitude + 180) / 360))
|
||||
segments := make([][]starOccultationGeoPoint, 0, lastWorld-firstWorld+1)
|
||||
for world := firstWorld; world <= lastWorld; world++ {
|
||||
left := -180.0 + 360*float64(world)
|
||||
right := 180.0 + 360*float64(world)
|
||||
clipped := starOccultationClipPolygonLongitude(polygon, left, true)
|
||||
clipped = starOccultationClipPolygonLongitude(clipped, right, false)
|
||||
if len(clipped) < 3 {
|
||||
continue
|
||||
}
|
||||
for index := range clipped {
|
||||
clipped[index].longitude -= 360 * float64(world)
|
||||
}
|
||||
if math.Abs(starOccultationPolygonArea(clipped)) > 1e-9 {
|
||||
segments = append(segments, clipped)
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func starOccultationUnwrapPolygon(points []starOccultationGeoPoint) []starOccultationGeoPoint {
|
||||
if len(points) < 2 {
|
||||
return points
|
||||
}
|
||||
unwrapped := make([]starOccultationGeoPoint, len(points))
|
||||
unwrapped[0] = points[0]
|
||||
for index := 1; index < len(points); index++ {
|
||||
point := points[index]
|
||||
previous := unwrapped[index-1].longitude
|
||||
for point.longitude-previous > 180 {
|
||||
point.longitude -= 360
|
||||
}
|
||||
for point.longitude-previous < -180 {
|
||||
point.longitude += 360
|
||||
}
|
||||
unwrapped[index] = point
|
||||
}
|
||||
return unwrapped
|
||||
}
|
||||
|
||||
func starOccultationClipPolygonLongitude(
|
||||
points []starOccultationGeoPoint,
|
||||
boundary float64,
|
||||
keepGreater bool,
|
||||
) []starOccultationGeoPoint {
|
||||
if len(points) == 0 {
|
||||
return nil
|
||||
}
|
||||
inside := func(point starOccultationGeoPoint) bool {
|
||||
if keepGreater {
|
||||
return point.longitude >= boundary
|
||||
}
|
||||
return point.longitude <= boundary
|
||||
}
|
||||
intersect := func(a, b starOccultationGeoPoint) starOccultationGeoPoint {
|
||||
fraction := (boundary - a.longitude) / (b.longitude - a.longitude)
|
||||
return starOccultationGeoPoint{
|
||||
longitude: boundary,
|
||||
latitude: a.latitude + (b.latitude-a.latitude)*fraction,
|
||||
}
|
||||
}
|
||||
clipped := make([]starOccultationGeoPoint, 0, len(points)+2)
|
||||
previous := points[len(points)-1]
|
||||
previousInside := inside(previous)
|
||||
for _, current := range points {
|
||||
currentInside := inside(current)
|
||||
if currentInside != previousInside {
|
||||
clipped = append(clipped, intersect(previous, current))
|
||||
}
|
||||
if currentInside {
|
||||
clipped = append(clipped, current)
|
||||
}
|
||||
previous = current
|
||||
previousInside = currentInside
|
||||
}
|
||||
return clipped
|
||||
}
|
||||
|
||||
func starOccultationPolygonArea(points []starOccultationGeoPoint) float64 {
|
||||
area := 0.0
|
||||
for index, point := range points {
|
||||
next := points[(index+1)%len(points)]
|
||||
area += point.longitude*next.latitude - next.longitude*point.latitude
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
func starOccultationSVGHeaderLines(path moon.StarOccultationPath, options StarOccultationSVGOptions) []string {
|
||||
lines := make([]string, 0, 4)
|
||||
for _, item := range []struct {
|
||||
text string
|
||||
fontSize float64
|
||||
}{
|
||||
{starOccultationSVGSummaryText(path, options), 14},
|
||||
{starOccultationSVGGreatestText(path, options), 13},
|
||||
} {
|
||||
lines = append(lines, starOccultationWrapText(item.text, float64(options.Width)-80, item.fontSize)...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func starOccultationSVGTitle(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
|
||||
if options.Title != "" {
|
||||
return options.Title
|
||||
}
|
||||
target := path.TargetID
|
||||
if target == "" {
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
target = "star"
|
||||
} else {
|
||||
target = "恒星"
|
||||
}
|
||||
}
|
||||
date := path.Greatest.Time.In(options.Location).Format("2006-01-02")
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("%s Lunar Occultation of %s", date, target)
|
||||
}
|
||||
return fmt.Sprintf("%s 月掩%s全球掩带", date, target)
|
||||
}
|
||||
|
||||
func starOccultationSVGSummaryText(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
|
||||
if options.SummaryText != "" {
|
||||
return options.SummaryText
|
||||
}
|
||||
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 options.Language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("Start %s | Greatest %s | End %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)
|
||||
}
|
||||
|
||||
func starOccultationSVGGreatestText(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
|
||||
if options.GreatestText != "" {
|
||||
return options.GreatestText
|
||||
}
|
||||
coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude)
|
||||
altitude := starOccultationFormatSignedDegree(path.Greatest.MoonAltitude)
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, path.Greatest.WidthKM, altitude)
|
||||
}
|
||||
return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, path.Greatest.WidthKM, altitude)
|
||||
}
|
||||
|
||||
func starOccultationSVGMapTitle(options StarOccultationSVGOptions) string {
|
||||
if options.MapTitle != "" {
|
||||
return options.MapTitle
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "Global center line and occultation limits"
|
||||
}
|
||||
return "全球中心线与掩带边界"
|
||||
}
|
||||
|
||||
func starOccultationSVGContactsTitle(options StarOccultationSVGOptions) string {
|
||||
if options.ContactsTitle != "" {
|
||||
return options.ContactsTitle
|
||||
}
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return "Global events"
|
||||
}
|
||||
return "全球事件"
|
||||
}
|
||||
|
||||
func starOccultationSVGFooter(options StarOccultationSVGOptions) string {
|
||||
if options.FooterNote != "" {
|
||||
return options.FooterNote
|
||||
}
|
||||
projection := starOccultationProjectionLabel(options.Projection, options.Language)
|
||||
if options.Language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("%s with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid.", projection)
|
||||
}
|
||||
return fmt.Sprintf("%s;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。", projection)
|
||||
}
|
||||
|
||||
func starOccultationProjectionLabel(projection MapProjection, language string) string {
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
switch projection {
|
||||
case MapProjectionNorthPolar:
|
||||
return "North-polar azimuthal equidistant projection"
|
||||
case MapProjectionSouthPolar:
|
||||
return "South-polar azimuthal equidistant projection"
|
||||
default:
|
||||
return "Equirectangular projection"
|
||||
}
|
||||
}
|
||||
switch projection {
|
||||
case MapProjectionNorthPolar:
|
||||
return "北极方位等距投影"
|
||||
case MapProjectionSouthPolar:
|
||||
return "南极方位等距投影"
|
||||
default:
|
||||
return "等经纬投影"
|
||||
}
|
||||
}
|
||||
|
||||
func starOccultationSVGEventRows(path moon.StarOccultationPath, language string) []starOccultationEventRow {
|
||||
names := []string{"掩始", "掩甚", "掩终"}
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
names = []string{"Start", "Greatest", "End"}
|
||||
}
|
||||
return []starOccultationEventRow{
|
||||
{name: names[0], point: path.Start},
|
||||
{name: names[1], point: path.Greatest},
|
||||
{name: names[2], point: path.End},
|
||||
}
|
||||
}
|
||||
|
||||
func starOccultationFormatEventTime(value time.Time, withDate bool) string {
|
||||
layout := "15:04:05.0"
|
||||
if withDate {
|
||||
layout = "2006-01-02 15:04:05.0"
|
||||
}
|
||||
return value.Format(layout)
|
||||
}
|
||||
|
||||
func starOccultationFormatCoordinates(longitude, latitude float64) string {
|
||||
longitudeSuffix := "E"
|
||||
if longitude < 0 {
|
||||
longitudeSuffix = "W"
|
||||
}
|
||||
latitudeSuffix := "N"
|
||||
if latitude < 0 {
|
||||
latitudeSuffix = "S"
|
||||
}
|
||||
return fmt.Sprintf("%.4f°%s, %.4f°%s", math.Abs(longitude), longitudeSuffix, math.Abs(latitude), latitudeSuffix)
|
||||
}
|
||||
|
||||
func starOccultationFormatSignedDegree(value float64) string {
|
||||
return fmt.Sprintf("%+.1f°", value)
|
||||
}
|
||||
|
||||
func starOccultationLocationLabel(value time.Time, location *time.Location) string {
|
||||
if location == time.UTC {
|
||||
return "UTC"
|
||||
}
|
||||
name, offset := value.Zone()
|
||||
if name != "" && name != "Local" {
|
||||
return name
|
||||
}
|
||||
hours := float64(offset) / 3600
|
||||
return fmt.Sprintf("UTC%+.1f", hours)
|
||||
}
|
||||
|
||||
func starOccultationSameDate(first, second time.Time) bool {
|
||||
y1, m1, d1 := first.Date()
|
||||
y2, m2, d2 := second.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
}
|
||||
|
||||
func starOccultationWrapText(value string, maxWidth, fontSize float64) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if starOccultationTextWidth(value, fontSize) <= maxWidth {
|
||||
return []string{value}
|
||||
}
|
||||
runes := []rune(value)
|
||||
lines := make([]string, 0, 2)
|
||||
for len(runes) > 0 {
|
||||
width := 0.0
|
||||
end := 0
|
||||
lastSpace := -1
|
||||
for end < len(runes) {
|
||||
nextWidth := width + starOccultationRuneWidth(runes[end], fontSize)
|
||||
if nextWidth > maxWidth && end > 0 {
|
||||
break
|
||||
}
|
||||
width = nextWidth
|
||||
if unicode.IsSpace(runes[end]) {
|
||||
lastSpace = end
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end < len(runes) && lastSpace > 0 {
|
||||
end = lastSpace
|
||||
}
|
||||
if end == 0 {
|
||||
end = 1
|
||||
}
|
||||
line := strings.TrimSpace(string(runes[:end]))
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
runes = runes[end:]
|
||||
for len(runes) > 0 && unicode.IsSpace(runes[0]) {
|
||||
runes = runes[1:]
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func starOccultationTitleFontSize(title string, width float64) int {
|
||||
for size := 26; size >= 16; size-- {
|
||||
if starOccultationTextWidth(title, float64(size)) <= width-80 {
|
||||
return size
|
||||
}
|
||||
}
|
||||
return 16
|
||||
}
|
||||
|
||||
func starOccultationTextWidth(value string, fontSize float64) float64 {
|
||||
width := 0.0
|
||||
for _, current := range value {
|
||||
width += starOccultationRuneWidth(current, fontSize)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func starOccultationRuneWidth(value rune, fontSize float64) float64 {
|
||||
if unicode.Is(unicode.Han, value) || value > utf8.RuneSelf {
|
||||
return fontSize
|
||||
}
|
||||
if unicode.IsSpace(value) {
|
||||
return fontSize * 0.34
|
||||
}
|
||||
return fontSize * 0.58
|
||||
}
|
||||
|
||||
func starOccultationFinite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 = planetOccultationSVGDefaults(path, options)
|
||||
return renderOccultationPathSVG(starShape, &path, options), 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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
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 !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 {
|
||||
return fmt.Errorf("%w: total-band fields require HasTotalBand", ErrInvalidPlanetOccultationPath)
|
||||
}
|
||||
return validatePlanetOccultationFootprints("partial", path.PartialFootprints, 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 := validatePlanetOccultationFootprints("partial", path.PartialFootprints, 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
|
||||
}
|
||||
|
||||
func validatePlanetOccultationFootprints(
|
||||
name string,
|
||||
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 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)
|
||||
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)
|
||||
}
|
||||
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km | 全掩带宽 %.1f km",
|
||||
coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM)
|
||||
}
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, path.Greatest.WidthKM)
|
||||
}
|
||||
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, path.Greatest.WidthKM)
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 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, "")
|
||||
}
|
||||
writeStarOccultationGeoLine(b, starShape.CenterLine, layout, "center-line", "#59676b", 1.6, "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)
|
||||
layout.mapFrame().WriteFrame(b)
|
||||
writePlanetOccultationLegend(b, layout, options.Language, path.HasTotalBand)
|
||||
}
|
||||
|
||||
func writePlanetOccultationFootprintSweep(
|
||||
b *strings.Builder,
|
||||
footprints []moon.PlanetOccultationFootprint,
|
||||
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}
|
||||
}
|
||||
for _, fragment := range svgmap.PolygonFragments(geographic, layout.projection) {
|
||||
if len(fragment) < 3 {
|
||||
continue
|
||||
}
|
||||
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 ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if geometry.Len() == 0 {
|
||||
return
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func writePlanetOccultationEventMarkers(
|
||||
b *strings.Builder,
|
||||
path moon.PlanetOccultationPath,
|
||||
layout starOccultationSVGLayout,
|
||||
language string,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
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,
|
||||
) {
|
||||
labels := []string{"部分掩带", "可见中心线", "几何中心线"}
|
||||
if hasTotal {
|
||||
labels = []string{"部分掩带", "全掩带", "可见中心线", "几何中心线"}
|
||||
}
|
||||
if language == starOccultationSVGLanguageEnglish {
|
||||
labels = []string{"Partial band", "Visible center", "Geometric center"}
|
||||
if hasTotal {
|
||||
labels = []string{"Partial band", "Total band", "Visible center", "Geometric center"}
|
||||
}
|
||||
}
|
||||
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)
|
||||
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(`/>`)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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 "行星"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/svgasset"
|
||||
"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
|
||||
}
|
||||
|
||||
type localPlanetOccultationEventFrame struct {
|
||||
label string
|
||||
name string
|
||||
time time.Time
|
||||
hidden bool
|
||||
frame moon.PlanetOccultationDiagramFrame
|
||||
}
|
||||
|
||||
// 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 err := validateLocalPlanetOccultationInfo(info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalPlanetOccultationSVGOptions(options); err != nil {
|
||||
return "", err
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func writeLocalPlanetOccultationOverview(
|
||||
b *strings.Builder,
|
||||
diagram moon.PlanetOccultationDiagramResult,
|
||||
events []localPlanetOccultationEventFrame,
|
||||
layout localStarOccultationSVGLayout,
|
||||
options LocalPlanetOccultationSVGOptions,
|
||||
) {
|
||||
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(localPlanetOccultationSVGOverviewTitle(options)))
|
||||
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
|
||||
|
||||
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
|
||||
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,
|
||||
)
|
||||
}
|
||||
writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon")
|
||||
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" {
|
||||
writeLocalPlanetOccultationOverviewLabel(b, event, x, y, cx, cy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 writeLocalPlanetOccultationOverviewLabel(
|
||||
b *strings.Builder,
|
||||
event localPlanetOccultationEventFrame,
|
||||
x, y, cx, cy float64,
|
||||
) {
|
||||
dx, dy, anchor := 8.0, -9.0, "start"
|
||||
if x > cx {
|
||||
dx, anchor = -8, "end"
|
||||
}
|
||||
if y < cy-20 {
|
||||
dy = 15
|
||||
}
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#792d28" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
|
||||
x+dx, y+dy, anchor, html.EscapeString(event.name))
|
||||
}
|
||||
|
||||
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)
|
||||
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(localPlanetOccultationSVGContactsTitle(options)))
|
||||
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,
|
||||
) {
|
||||
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(localPlanetOccultationSVGPhasePanelsTitle(options)))
|
||||
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)
|
||||
writeLocalStarOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon")
|
||||
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 := starOccultationWrapText(localPlanetOccultationSVGDirectionText(options), layout.width-80, 10)
|
||||
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))
|
||||
}
|
||||
footerLines := starOccultationWrapText(localPlanetOccultationSVGFooterNote(info, options), layout.width-80, 9)
|
||||
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,
|
||||
})
|
||||
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 _, value := range []string{
|
||||
localPlanetOccultationSVGSummaryText(info, options),
|
||||
localPlanetOccultationSVGGreatestText(info, options),
|
||||
} {
|
||||
lines = append(lines, starOccultationWrapText(value, 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 "行星圆面擦边"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestFindLocalPlanetOccultationSVGsSaturnFiveContacts(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*3600)
|
||||
diagrams, err := FindLocalPlanetOccultationSVGs(
|
||||
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
|
||||
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
|
||||
moon.OccultationSaturn,
|
||||
104.52219613, 55.25401991, 0,
|
||||
moon.OccultationSearchOptions{},
|
||||
LocalPlanetOccultationSVGOptions{Width: 920, Height: 700, Location: location},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindLocalPlanetOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 1 {
|
||||
t.Fatalf("FindLocalPlanetOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
|
||||
}
|
||||
diagram := diagrams[0]
|
||||
for _, want := range []string{
|
||||
`<svg`, `width="920"`, `height="700"`, "2025-02-01", "指定地点月掩土星",
|
||||
"站心行星轨迹", "圆盘真实比例", "C1 外切始", "C2 内切始", "掩甚", "C3 内切终", "C4 外切终",
|
||||
"C1-C4 接触阶段", "行星圆盘放大示意", "本地接触时刻", "白道", "土星环不参与接触计算",
|
||||
`class="local-planet-track"`, `class="lunar-path-line"`, `class="overview-moon"`,
|
||||
`class="overview-planet-disk"`, `<symbol id="le-moon"`, `href="#le-moon"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("local planetary SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(diagram, "全球掩带") || strings.Contains(diagram, "全球中心线") {
|
||||
t.Fatal("local planetary SVG unexpectedly contains global-path labels")
|
||||
}
|
||||
if got := strings.Count(diagram, `class="stage-moon"`); got != 5 {
|
||||
t.Fatalf("stage Moon count = %d, want 5", got)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="stage-planet-disk"`); got != 5 {
|
||||
t.Fatalf("stage planet count = %d, want 5", got)
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated local planetary SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPlanetOccultationSVGStageCentersPreserveDisplayedTangency(t *testing.T) {
|
||||
event := localPlanetOccultationEventFrame{
|
||||
label: "C2",
|
||||
frame: moon.PlanetOccultationDiagramFrame{
|
||||
SeparationArcsec: 970,
|
||||
PositionAngleDeg: 90,
|
||||
},
|
||||
}
|
||||
x, y := localPlanetOccultationSVGStageCenter(event, 100, 100, 40, 6, 0.04)
|
||||
if math.Abs(math.Hypot(x-100, y-100)-(40-6)) > 1e-12 {
|
||||
t.Fatalf("C2 display distance = %.12f, want %.12f", math.Hypot(x-100, y-100), float64(40-6))
|
||||
}
|
||||
event.label = "C4"
|
||||
x, y = localPlanetOccultationSVGStageCenter(event, 100, 100, 40, 6, 0.04)
|
||||
if math.Abs(math.Hypot(x-100, y-100)-(40+6)) > 1e-12 {
|
||||
t.Fatalf("C4 display distance = %.12f, want %.12f", math.Hypot(x-100, y-100), float64(40+6))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPlanetOccultationSVGEnglishAndCustomText(t *testing.T) {
|
||||
info := localSaturnOccultation(t)
|
||||
diagram, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{
|
||||
Language: "en",
|
||||
Location: time.UTC,
|
||||
Title: "Custom local planet title",
|
||||
SummaryText: "Custom local planet summary",
|
||||
GreatestText: "Custom local planet greatest",
|
||||
OverviewTitle: "Custom local planet overview",
|
||||
PhasePanelsTitle: "Custom local planet stages",
|
||||
ContactsTitle: "Custom local planet contacts",
|
||||
DirectionText: "Custom local planet direction",
|
||||
FooterNote: "Custom local planet footer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Custom local planet title", "Custom local planet summary", "Custom local planet greatest",
|
||||
"Custom local planet overview", "Custom local planet stages", "Custom local planet contacts",
|
||||
"Custom local planet direction", "Custom local planet footer",
|
||||
"C1 external ingress", "C2 internal ingress", "Greatest", "C3 internal egress", "C4 external egress",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("English local planetary SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("English local planetary SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPlanetOccultationSVGRejectsInvalidInput(t *testing.T) {
|
||||
info := localSaturnOccultation(t)
|
||||
_, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{Width: 1})
|
||||
if !errors.Is(err, ErrInvalidLocalPlanetOccultationSVGOptions) {
|
||||
t.Fatalf("invalid canvas error = %v, want ErrInvalidLocalPlanetOccultationSVGOptions", err)
|
||||
}
|
||||
info.ContactsComplete = false
|
||||
_, err = LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidLocalPlanetOccultationInfo) {
|
||||
t.Fatalf("incomplete event error = %v, want ErrInvalidLocalPlanetOccultationInfo", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPlanetOccultationSVGRejectsTargetGeometryMismatch(t *testing.T) {
|
||||
info := localSaturnOccultation(t)
|
||||
info.Planet = moon.OccultationVenus
|
||||
info.TargetID = "Venus"
|
||||
_, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidLocalPlanetOccultationInfo) {
|
||||
t.Fatalf("mismatched planet error = %v, want ErrInvalidLocalPlanetOccultationInfo", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPlanetOccultationSVGPartialEventUsesThreeStages(t *testing.T) {
|
||||
events, err := moon.FindPlanetOccultations(
|
||||
time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC),
|
||||
time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC),
|
||||
moon.OccultationSaturn, -30.072, -6.5, 0, moon.OccultationSearchOptions{},
|
||||
)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
|
||||
}
|
||||
diagram, err := LocalPlanetOccultationSVG(events[0], LocalPlanetOccultationSVGOptions{Language: "en"})
|
||||
if err != nil {
|
||||
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="stage-planet-disk"`); got != 3 {
|
||||
t.Fatalf("partial event stage count = %d, want 3", got)
|
||||
}
|
||||
if strings.Contains(diagram, "C2 internal ingress") || strings.Contains(diagram, "C3 internal egress") {
|
||||
t.Fatal("partial event SVG contains internal contacts")
|
||||
}
|
||||
}
|
||||
|
||||
func localSaturnOccultation(t *testing.T) moon.PlanetOccultationInfo {
|
||||
t.Helper()
|
||||
location := time.FixedZone("UTC+8", 8*3600)
|
||||
events, err := moon.FindPlanetOccultations(
|
||||
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
|
||||
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
|
||||
moon.OccultationSaturn, 104.52219613, 55.25401991, 0, moon.OccultationSearchOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPlanetOccultations() error = %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(events))
|
||||
}
|
||||
return events[0]
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) {
|
||||
diagrams, err := FindPlanetOccultationSVGs(
|
||||
time.Date(2024, time.August, 20, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2024, time.August, 22, 0, 0, 0, 0, time.UTC),
|
||||
moon.OccultationSaturn,
|
||||
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||
PlanetOccultationSVGOptions{Width: 720, Height: 520},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPlanetOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
|
||||
}
|
||||
diagram := diagrams[0]
|
||||
for _, want := range []string{
|
||||
`<svg`, `width="720"`, `height="520"`, "2024-08-21", "土星",
|
||||
"全球掩带", "外掩始", "全掩始", "掩甚", "全掩终", "外掩终", "外切锥面", "内切锥面",
|
||||
"部分掩带", "全掩带", `class="occultation-band"`, `class="total-occultation-band"`,
|
||||
`fill-rule="nonzero"`, `class="center-line"`,
|
||||
`class="occultation-time-marker"`,
|
||||
`class="event-marker event-total-start"`, `class="event-marker event-greatest"`,
|
||||
`class="event-marker event-total-end"`, `class="event-marker-leader"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("planetary SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, obsolete := range []string{
|
||||
`class="northern-limit"`, `class="southern-limit"`,
|
||||
`class="northern-total-limit"`, `class="southern-total-limit"`,
|
||||
} {
|
||||
if strings.Contains(diagram, obsolete) {
|
||||
t.Fatalf("planetary footprint sweep retains obsolete approximate line %q", obsolete)
|
||||
}
|
||||
}
|
||||
if strings.Contains(diagram, "月掩Saturn") {
|
||||
t.Fatal("Chinese planetary SVG title contains the English default target name")
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated planetary SVG is not valid XML: %v", err)
|
||||
}
|
||||
for _, pair := range [][2]string{{"外掩始", "全掩始"}, {"全掩终", "外掩终"}} {
|
||||
_, firstY := planetOccultationSVGTextPosition(t, diagram, pair[0])
|
||||
_, secondY := planetOccultationSVGTextPosition(t, diagram, pair[1])
|
||||
if math.Abs(firstY-secondY) < 13 {
|
||||
t.Fatalf("planetary SVG labels %q and %q overlap vertically at y %.3f / %.3f", pair[0], pair[1], firstY, secondY)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diagram, `class="event-marker-leader"`); got != 4 {
|
||||
t.Fatalf("planetary SVG close-contact leader count = %d, want 4", got)
|
||||
}
|
||||
if got := strings.Count(diagram, `fill="#087f8c" stroke="#ffffff" stroke-width="1.0"`); got < 2 {
|
||||
t.Fatalf("planetary SVG total-contact marker count = %d, want at least 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *testing.T) {
|
||||
path := samplePlanetOccultationPath()
|
||||
options := normalizeStarOccultationSVGOptions(PlanetOccultationSVGOptions{
|
||||
Width: 1100,
|
||||
SummaryText: strings.Repeat("A", 130),
|
||||
})
|
||||
options = planetOccultationSVGDefaults(path, options)
|
||||
lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options)
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("planetary SVG header lines = %d, want wrapped summary plus greatest line", len(lines))
|
||||
}
|
||||
if width := starOccultationTextWidth(lines[0], 14); width > float64(options.Width)-80 {
|
||||
t.Fatalf("planetary SVG first header line width = %.1f, canvas allowance %.1f", width, float64(options.Width)-80)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGUsesLargeGlobalMapDefault(t *testing.T) {
|
||||
diagram, err := PlanetOccultationPathSVG(samplePlanetOccultationPath(), PlanetOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`width="1200" height="800"`,
|
||||
`preserveAspectRatio="xMidYMid meet"`,
|
||||
`style="max-width:100%;height:auto;display:block"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("planetary SVG missing large responsive canvas marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planetOccultationSVGTextPosition(t *testing.T, diagram, label string) (float64, float64) {
|
||||
t.Helper()
|
||||
decoder := xml.NewDecoder(strings.NewReader(diagram))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("decode planetary SVG: %v", err)
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "text" {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if err := decoder.DecodeElement(&text, &start); err != nil {
|
||||
t.Fatalf("decode planetary SVG text: %v", err)
|
||||
}
|
||||
if text != label {
|
||||
continue
|
||||
}
|
||||
values := map[string]float64{}
|
||||
for _, attribute := range start.Attr {
|
||||
if attribute.Name.Local != "x" && attribute.Name.Local != "y" {
|
||||
continue
|
||||
}
|
||||
value, parseErr := strconv.ParseFloat(attribute.Value, 64)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("parse %s coordinate %q: %v", attribute.Name.Local, attribute.Value, parseErr)
|
||||
}
|
||||
values[attribute.Name.Local] = value
|
||||
}
|
||||
return values["x"], values["y"]
|
||||
}
|
||||
t.Fatalf("planetary SVG label %q not found", label)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGRejectsInvalidCanvasWithPlanetError(t *testing.T) {
|
||||
path := samplePlanetOccultationPath()
|
||||
_, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1})
|
||||
if !errors.Is(err, ErrInvalidPlanetOccultationSVGOptions) {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationSVGOptions", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGEnglishAndCustomText(t *testing.T) {
|
||||
diagram, err := PlanetOccultationPathSVG(samplePlanetOccultationPath(), PlanetOccultationSVGOptions{
|
||||
Language: "en",
|
||||
Location: time.UTC,
|
||||
Title: "Custom planetary occultation",
|
||||
SummaryText: "Custom summary",
|
||||
GreatestText: "Custom greatest",
|
||||
FooterNote: "Custom footer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Custom planetary occultation", "Custom summary", "Custom greatest", "Custom footer",
|
||||
"Partial begins", "Total begins", "Greatest", "Total ends", "Partial ends",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("English planetary SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("English planetary SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGRejectsInvalidPath(t *testing.T) {
|
||||
_, err := PlanetOccultationPathSVG(moon.PlanetOccultationPath{Planet: moon.OccultationSaturn}, PlanetOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err)
|
||||
}
|
||||
path := samplePlanetOccultationPath()
|
||||
path.TotalComplete = false
|
||||
_, err = PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
|
||||
t.Fatalf("incomplete total band error = %v, want ErrInvalidPlanetOccultationPath", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGSupportsPartialOnlyGlobalBand(t *testing.T) {
|
||||
path := samplePlanetOccultationPath()
|
||||
path.HasTotalBand = false
|
||||
path.TotalStart = moon.OccultationPathPoint{}
|
||||
path.TotalEnd = moon.OccultationPathPoint{}
|
||||
path.TotalComplete = false
|
||||
path.NorthernTotalLimit = nil
|
||||
path.SouthernTotalLimit = nil
|
||||
path.GreatestTotalWidthKM = 0
|
||||
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationPathSVG() partial-only error = %v", err)
|
||||
}
|
||||
if !strings.Contains(diagram, "部分掩带") || strings.Contains(diagram, `class="total-occultation-band"`) ||
|
||||
strings.Contains(diagram, "全掩始") || strings.Contains(diagram, "全掩终") {
|
||||
t.Fatal("partial-only planetary SVG contains inconsistent total-band content")
|
||||
}
|
||||
if got := strings.Count(diagram, `class="event-marker `); got != 3 {
|
||||
t.Fatalf("partial-only global marker count = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func samplePlanetOccultationPath() moon.PlanetOccultationPath {
|
||||
starPath := sampleStarOccultationPath()
|
||||
totalStart := starOccultationInterpolatePathPoint(
|
||||
starPath.CenterLine[0], starPath.CenterLine[1], 0.5, 167.5,
|
||||
)
|
||||
totalEnd := starOccultationInterpolatePathPoint(
|
||||
starPath.CenterLine[2], starPath.CenterLine[3], 0.5, -167.5,
|
||||
)
|
||||
northernTotal := []moon.OccultationPathPoint{totalStart}
|
||||
southernTotal := []moon.OccultationPathPoint{totalStart}
|
||||
for _, point := range starPath.CenterLine[1:3] {
|
||||
north, south := point, point
|
||||
north.Latitude += 10
|
||||
south.Latitude -= 10
|
||||
north.WidthKM = 0
|
||||
south.WidthKM = 0
|
||||
northernTotal = append(northernTotal, north)
|
||||
southernTotal = append(southernTotal, south)
|
||||
}
|
||||
northernTotal = append(northernTotal, totalEnd)
|
||||
southernTotal = append(southernTotal, totalEnd)
|
||||
return moon.PlanetOccultationPath{
|
||||
Planet: moon.OccultationSaturn,
|
||||
TargetID: "Saturn",
|
||||
Start: starPath.Start,
|
||||
Greatest: starPath.Greatest,
|
||||
End: starPath.End,
|
||||
Complete: true,
|
||||
CenterLine: starPath.CenterLine,
|
||||
NorthernLimit: starPath.NorthernLimit,
|
||||
SouthernLimit: starPath.SouthernLimit,
|
||||
HasTotalBand: true,
|
||||
TotalStart: totalStart,
|
||||
TotalEnd: totalEnd,
|
||||
TotalComplete: true,
|
||||
NorthernTotalLimit: northernTotal,
|
||||
SouthernTotalLimit: southernTotal,
|
||||
GreatestTotalWidthKM: 3100,
|
||||
Step: starPath.Step,
|
||||
TargetSpacingKM: starPath.TargetSpacingKM,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/svgmap"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestFindStarOccultationSVGsHR4799(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
diagrams, err := FindStarOccultationSVGs(
|
||||
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
|
||||
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
|
||||
hr4799StarCoordinate(),
|
||||
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||
StarOccultationSVGOptions{Width: 720, Height: 520},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 1 {
|
||||
t.Fatalf("FindStarOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
|
||||
}
|
||||
diagram := diagrams[0]
|
||||
for _, want := range []string{
|
||||
`<svg`, `width="720"`, `height="520"`, "2025-06-05", "HR 4799",
|
||||
"全球掩带", "掩始", "掩甚", "掩终", "掩带宽", "UTC+8",
|
||||
`class="occultation-band"`, `class="center-line"`, `class="northern-limit"`,
|
||||
`class="southern-limit"`, `class="event-marker event-greatest"`, `class="land"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diagram, `class="occultation-band"`); got != 2 {
|
||||
t.Fatalf("HR 4799 occultation-band segment count = %d, want two antimeridian-clipped fragments", got)
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) {
|
||||
layout := starOccultationSVGLayoutFor(
|
||||
StarOccultationSVGOptions{Width: 900, Height: 760},
|
||||
110,
|
||||
svgmap.ProjectionNorthPolar,
|
||||
)
|
||||
legendY := layout.mapY + layout.mapHeight + 30
|
||||
if gap := layout.footerY - legendY; gap < 24 {
|
||||
t.Fatalf("polar legend/footer gap = %.1f px, want at least 24 px", gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGEnglishAndCustomText(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.TargetID = "Alpha < Beta & Gamma"
|
||||
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{
|
||||
Language: "en",
|
||||
Location: time.UTC,
|
||||
Title: "Custom <occultation> & title",
|
||||
SummaryText: "Custom summary",
|
||||
GreatestText: "Custom greatest",
|
||||
MapTitle: "Custom map",
|
||||
ContactsTitle: "Custom events",
|
||||
FooterNote: "Custom footer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Custom <occultation> & title",
|
||||
"Custom summary",
|
||||
"Custom greatest",
|
||||
"Custom map",
|
||||
"Custom events",
|
||||
"Custom footer",
|
||||
"Start",
|
||||
"Greatest",
|
||||
"End",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(diagram, "Custom <occultation>") {
|
||||
t.Fatal("custom title was not XML escaped")
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGIncludesAlignedTimeLabels(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{
|
||||
Location: time.UTC, TimeLabelStep: 30 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`class="occultation-time-marker"`, `>10:30</text>`, `>12:30</text>`} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("stellar occultation SVG missing time marker %q", want)
|
||||
}
|
||||
}
|
||||
markers := occultationTimeMarkerPoints(
|
||||
sampleStarOccultationPath().CenterLine,
|
||||
30*time.Minute,
|
||||
time.UTC,
|
||||
[]time.Time{sampleStarOccultationPath().Start.Time, sampleStarOccultationPath().End.Time},
|
||||
)
|
||||
foundGreatestTime := false
|
||||
for _, marker := range markers {
|
||||
if marker.Time.Equal(sampleStarOccultationPath().Greatest.Time) {
|
||||
foundGreatestTime = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundGreatestTime {
|
||||
t.Fatal("aligned time at greatest was discarded instead of being placed below the event label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGCanDisableTimeLabels(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{TimeLabelStep: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
if strings.Contains(diagram, `class="occultation-time-marker"`) {
|
||||
t.Fatal("disabled occultation time labels were rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGSplitsAntimeridian(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="center-line"`); got != 2 {
|
||||
t.Fatalf("center-line segment count = %d, want 2", got)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="occultation-band"`); got != 2 {
|
||||
t.Fatalf("occultation-band segment count = %d, want 2", got)
|
||||
}
|
||||
segments := starOccultationPathSegments(sampleStarOccultationPath().CenterLine)
|
||||
if len(segments) != 2 {
|
||||
t.Fatalf("path segment count = %d, want 2", len(segments))
|
||||
}
|
||||
if segments[0][len(segments[0])-1].Longitude != 180 || segments[1][0].Longitude != -180 {
|
||||
t.Fatalf("antimeridian interpolation = %.3f / %.3f, want +180 / -180",
|
||||
segments[0][len(segments[0])-1].Longitude, segments[1][0].Longitude)
|
||||
}
|
||||
if !segments[0][len(segments[0])-1].Time.Equal(segments[1][0].Time) {
|
||||
t.Fatal("antimeridian split points do not share the interpolated time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGUsesDetailedPhysicalLand(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`class="land-layer"`,
|
||||
`class="land"`,
|
||||
`fill="#d8d9d2"`,
|
||||
`stroke="#a6aaa4"`,
|
||||
`fill-rule="evenodd"`,
|
||||
`vector-effect="non-scaling-stroke"`,
|
||||
"Natural Earth 1:50m",
|
||||
"不含行政边界",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("SVG missing detailed-land marker %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diagram, `class="land"`); got != 1 {
|
||||
t.Fatalf("land path count = %d, want one compact path", got)
|
||||
}
|
||||
if strings.Contains(diagram, `fill="#000`) || strings.Contains(diagram, `fill="black"`) {
|
||||
t.Fatal("land path uses a black fill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGUsesNorthPolarProjection(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.Greatest.Latitude = 72
|
||||
for index := range path.CenterLine {
|
||||
path.CenterLine[index].Latitude = 62 + float64(index)*4
|
||||
}
|
||||
for index := range path.NorthernLimit {
|
||||
path.NorthernLimit[index].Latitude = 68 + float64(index)*3
|
||||
path.SouthernLimit[index].Latitude = 58 + float64(index)*3
|
||||
}
|
||||
path.Start.Latitude = path.NorthernLimit[0].Latitude
|
||||
path.End.Latitude = path.NorthernLimit[len(path.NorthernLimit)-1].Latitude
|
||||
|
||||
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "北极方位等距投影"} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("north-polar SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("north-polar SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationBandSegmentsPreserveAsymmetricAntimeridianCrossing(t *testing.T) {
|
||||
start := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
|
||||
northern := []moon.OccultationPathPoint{
|
||||
{Time: start, Longitude: 170, Latitude: 20},
|
||||
{Time: start.Add(time.Hour), Longitude: -170, Latitude: 10},
|
||||
}
|
||||
southern := []moon.OccultationPathPoint{
|
||||
{Time: start, Longitude: 150, Latitude: 0},
|
||||
{Time: start.Add(time.Hour), Longitude: 160, Latitude: -10},
|
||||
}
|
||||
segments := starOccultationBandSegments(northern, southern)
|
||||
if len(segments) != 2 {
|
||||
t.Fatalf("asymmetric antimeridian band segment count = %d, want 2", len(segments))
|
||||
}
|
||||
hasWest, hasEast := false, false
|
||||
for _, segment := range segments {
|
||||
for _, point := range segment {
|
||||
hasWest = hasWest || point.longitude < -179
|
||||
hasEast = hasEast || point.longitude > 179
|
||||
}
|
||||
}
|
||||
if !hasWest || !hasEast {
|
||||
t.Fatalf("split band does not cover both map edges: west=%v east=%v", hasWest, hasEast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStarOccultationSVGsNoEvent(t *testing.T) {
|
||||
diagrams, err := FindStarOccultationSVGs(
|
||||
time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC),
|
||||
moon.StarCoordinate{
|
||||
ID: "polar-star",
|
||||
RA: 0,
|
||||
Dec: 89,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameICRS,
|
||||
},
|
||||
moon.OccultationPathOptions{},
|
||||
StarOccultationSVGOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 0 {
|
||||
t.Fatalf("FindStarOccultationSVGs() returned %d diagrams, want none", len(diagrams))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGRejectsInvalidPath(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.Greatest.Longitude = math.NaN()
|
||||
_, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidStarOccultationPath) {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGRejectsMisalignedLimits(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
|
||||
_, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidStarOccultationPath) {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGRejectsCanvasTooSmall(t *testing.T) {
|
||||
_, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{Width: 1, Height: 1})
|
||||
if !errors.Is(err, ErrInvalidStarOccultationSVGOptions) {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationSVGOptions", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hr4799StarCoordinate() moon.StarCoordinate {
|
||||
return moon.StarCoordinate{
|
||||
ID: "HR 4799",
|
||||
RA: 189.1975,
|
||||
Dec: -5.831944444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -28,
|
||||
ProperMotionDecMasPerYear: -18,
|
||||
}
|
||||
}
|
||||
|
||||
func sampleStarOccultationPath() moon.StarOccultationPath {
|
||||
start := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
|
||||
center := []moon.OccultationPathPoint{
|
||||
{Time: start, Longitude: 160, Latitude: 18, MoonAltitude: 5, WidthKM: 3200},
|
||||
{Time: start.Add(time.Hour), Longitude: 175, Latitude: 10, MoonAltitude: 35, WidthKM: 3300},
|
||||
{Time: start.Add(2 * time.Hour), Longitude: -175, Latitude: 1, MoonAltitude: 50, WidthKM: 3400},
|
||||
{Time: start.Add(3 * time.Hour), Longitude: -160, Latitude: -8, MoonAltitude: 12, WidthKM: 3300},
|
||||
}
|
||||
northern := make([]moon.OccultationPathPoint, len(center))
|
||||
southern := make([]moon.OccultationPathPoint, len(center))
|
||||
for index, point := range center {
|
||||
northern[index] = point
|
||||
northern[index].Latitude += 12
|
||||
northern[index].WidthKM = 0
|
||||
southern[index] = point
|
||||
southern[index].Latitude -= 12
|
||||
southern[index].WidthKM = 0
|
||||
}
|
||||
return moon.StarOccultationPath{
|
||||
TargetID: "synthetic-star",
|
||||
Start: center[0],
|
||||
Greatest: center[1],
|
||||
End: center[3],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: northern,
|
||||
SouthernLimit: southern,
|
||||
Step: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func validateXML(value string) error {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
for {
|
||||
if _, err := decoder.Token(); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func writeOccultationTimeMarkers(
|
||||
b *strings.Builder,
|
||||
points []moon.OccultationPathPoint,
|
||||
layout starOccultationSVGLayout,
|
||||
options StarOccultationSVGOptions,
|
||||
excluded []time.Time,
|
||||
greatest time.Time,
|
||||
) {
|
||||
if options.TimeLabelStep <= 0 || len(points) < 2 {
|
||||
return
|
||||
}
|
||||
markers := occultationTimeMarkerPoints(points, options.TimeLabelStep, options.Location, excluded)
|
||||
frame := layout.mapFrame()
|
||||
projected := make([][2]float64, 0, len(markers))
|
||||
for _, marker := range markers {
|
||||
if marker.MoonAltitude < 0 {
|
||||
continue
|
||||
}
|
||||
x, y, visible := frame.Project(marker.Longitude, marker.Latitude)
|
||||
if !visible || x < frame.X+22 || x > frame.X+frame.Width-22 || y < frame.Y+12 || y > frame.Y+frame.Height-12 {
|
||||
continue
|
||||
}
|
||||
tooClose := false
|
||||
for _, previous := range projected {
|
||||
if math.Hypot(x-previous[0], y-previous[1]) < 44 {
|
||||
tooClose = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if tooClose {
|
||||
continue
|
||||
}
|
||||
projected = append(projected, [2]float64{x, y})
|
||||
labelY := y - 8
|
||||
if occultationTimesNear(marker.Time, greatest, occultationGreatestTimeLabelWindow(options.TimeLabelStep)) {
|
||||
labelY = y + 15
|
||||
} else if labelY < frame.Y+10 {
|
||||
labelY = y + 15
|
||||
}
|
||||
fmt.Fprintf(b, `<g class="occultation-time-marker"><circle cx="%.3f" cy="%.3f" r="2.3" fill="#087f8c" stroke="#ffffff" stroke-width="1"/><text x="%.3f" y="%.3f" fill="#075f69" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text></g>`,
|
||||
x, y, x, labelY, html.EscapeString(marker.Time.In(options.Location).Format("15:04")))
|
||||
}
|
||||
}
|
||||
|
||||
func occultationGreatestTimeLabelWindow(step time.Duration) time.Duration {
|
||||
window := step / 3
|
||||
if window < 10*time.Minute {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
func occultationTimeMarkerPoints(
|
||||
points []moon.OccultationPathPoint,
|
||||
step time.Duration,
|
||||
location *time.Location,
|
||||
excluded []time.Time,
|
||||
) []moon.OccultationPathPoint {
|
||||
if len(points) < 2 || step <= 0 {
|
||||
return nil
|
||||
}
|
||||
start := points[0].Time
|
||||
end := points[len(points)-1].Time
|
||||
current := firstOccultationTimeLabelAfter(start, step, location)
|
||||
window := step / 4
|
||||
if window > 5*time.Minute {
|
||||
window = 5 * time.Minute
|
||||
}
|
||||
if window < 30*time.Second {
|
||||
window = 30 * time.Second
|
||||
}
|
||||
result := make([]moon.OccultationPathPoint, 0)
|
||||
segment := 1
|
||||
for current.Before(end) {
|
||||
for segment < len(points) && points[segment].Time.Before(current) {
|
||||
segment++
|
||||
}
|
||||
if segment >= len(points) {
|
||||
break
|
||||
}
|
||||
if !occultationTimeNearAny(current, excluded, window) {
|
||||
a, next := points[segment-1], points[segment]
|
||||
span := next.Time.Sub(a.Time)
|
||||
if span > 0 {
|
||||
fraction := float64(current.Sub(a.Time)) / float64(span)
|
||||
result = append(result, interpolateOccultationTimeMarker(a, next, fraction, current))
|
||||
}
|
||||
}
|
||||
current = current.Add(step)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func interpolateOccultationTimeMarker(
|
||||
a, b moon.OccultationPathPoint,
|
||||
fraction float64,
|
||||
value time.Time,
|
||||
) moon.OccultationPathPoint {
|
||||
deltaLongitude := b.Longitude - a.Longitude
|
||||
if deltaLongitude > 180 {
|
||||
deltaLongitude -= 360
|
||||
} else if deltaLongitude < -180 {
|
||||
deltaLongitude += 360
|
||||
}
|
||||
longitude := a.Longitude + fraction*deltaLongitude
|
||||
if longitude > 180 {
|
||||
longitude -= 360
|
||||
} else if longitude < -180 {
|
||||
longitude += 360
|
||||
}
|
||||
point := starOccultationInterpolatePathPoint(a, b, fraction, longitude)
|
||||
point.Time = value
|
||||
return point
|
||||
}
|
||||
|
||||
func firstOccultationTimeLabelAfter(value time.Time, step time.Duration, location *time.Location) time.Time {
|
||||
local := value.In(location)
|
||||
dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||
elapsed := local.Sub(dayStart)
|
||||
return dayStart.Add((elapsed/step + 1) * step)
|
||||
}
|
||||
|
||||
func occultationTimeNearAny(value time.Time, excluded []time.Time, window time.Duration) bool {
|
||||
for _, candidate := range excluded {
|
||||
if candidate.IsZero() {
|
||||
continue
|
||||
}
|
||||
delta := value.Sub(candidate)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta <= window {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func occultationTimesNear(a, b time.Time, window time.Duration) bool {
|
||||
delta := a.Sub(b)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
return delta <= window
|
||||
}
|
||||
Reference in New Issue
Block a user