astro/eclipse/svg/solar.go
starainrt 3ffdbe0034
feat: 扩展天文计算能力
- 新增日食、月食、本地可见性、中心线、半影区域、SVG 图示与沙罗周期信息
- 新增行星冲合、留、方照、物理星历、视直径、相位、亮肢角、轨道节点等计算
- 新增木星伽利略卫星位置、现象与接触事件计算
- 新增恒星星表、星座判定、自行修正与观测辅助能力
- 新增 coord、formula、orbit、sundial、lite/sun、lite/moon 等扩展包
- 完善农历年号、月相英文别名、视差角、大气质量、折射、日晷与双星计算
- 增加 NASA、JPL Horizons、IMCCE 等回归测试数据与基线测试
- 重构基础算法文件组织,补充大量公开 API 注释和语义回归测试
- 更新中文和英文 README,补充示例、精度说明、SVG 配图
2026-05-01 22:38:44 +08:00

1121 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
const (
localSolarEclipseSVGDefaultWidth = 920
localSolarEclipseSVGDefaultHeight = 720
localSolarEclipseSVGDefaultZone = 8 * 60 * 60
localSolarEclipseSVGLanguageChinese = "zh"
localSolarEclipseSVGLanguageEnglish = "en"
)
// LocalSolarEclipseSVGOptions 控制站心日食视圆 SVG 输出。
// LocalSolarEclipseSVGOptions controls local solar eclipse disk SVG output.
type LocalSolarEclipseSVGOptions struct {
// Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
// Width/Height are SVG canvas size; values <= 0 use defaults.
Width int
Height int
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
// Title 是图题;为空时自动生成。
// Title is the chart title; empty values use an automatic title.
Title string
// SummaryText 是标题下第一行摘要;为空时自动生成。
// SummaryText is the first summary line below the title; empty values use an automatic summary.
SummaryText string
// GreatestText 是标题下第二行食甚说明;为空时自动生成。
// GreatestText is the second line below the title for greatest-eclipse details; empty values use automatic text.
GreatestText string
// MetaText 是标题下第三行补充说明;为空时自动生成沙罗/中心食历时等信息。
// MetaText is the third line below the title; empty values use automatic Saros and central-duration text.
MetaText string
// OverviewTitle 是上方总览区标题;为空时自动生成。
// OverviewTitle is the overview-panel title; empty values use an automatic title.
OverviewTitle string
// PhasePanelsTitle 是下方阶段视圆区标题;为空时自动生成。
// PhasePanelsTitle is the phase-panels title; empty values use an automatic title.
PhasePanelsTitle string
// ContactsTitle 是右侧接触时刻区标题;为空时自动生成。
// ContactsTitle is the contacts-panel title; empty values use an automatic title.
ContactsTitle string
// DirectionText 是底部方向说明;为空时自动生成。
// DirectionText is the footer direction note; empty values use an automatic note.
DirectionText string
// FooterNote 是底部补充说明;为空时自动生成。
// FooterNote is the footer explanatory note; empty values use an automatic note.
FooterNote string
// Language 是标签语言;"en" 使用英文,其他值或空值使用中文。
// Language controls label language; "en" uses English, other values or empty values use Chinese.
Language string
// Location 是图中显示时刻的时区nil 时使用 UTC+8。
// Location is the display timezone for chart times; nil uses UTC+8.
Location *time.Location
}
type localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult
type localSolarEclipseSVGFinder func(time.Time, float64, float64, float64) LocalSolarEclipseInfo
// LocalSolarEclipseSVG 生成给定地点的站心日食日月视圆 SVG默认使用 NASA bulletin Split-K 模型。
// LocalSolarEclipseSVG generates an SVG local solar eclipse Sun-Moon disk chart, using NASA bulletin Split-K by default.
func LocalSolarEclipseSVG(
date time.Time,
lon, lat, height float64,
options LocalSolarEclipseSVGOptions,
) (string, bool) {
return LocalSolarEclipseSVGNASABulletinSplitK(date, lon, lat, height, options)
}
// LocalSolarEclipseSVGNASABulletinSplitK 生成站心日食 SVG使用 NASA bulletin Split-K 模型。
// LocalSolarEclipseSVGNASABulletinSplitK generates a local solar eclipse SVG with the NASA bulletin Split-K model.
func LocalSolarEclipseSVGNASABulletinSplitK(
date time.Time,
lon, lat, height float64,
options LocalSolarEclipseSVGOptions,
) (string, bool) {
return localSolarEclipseSVG(date, lon, lat, height, options, basic.LocalSolarEclipseDiagramNASABulletinSplitK, eclipsecore.ClosestLocalSolarEclipseNASABulletinSplitK)
}
// LocalSolarEclipseSVGIAUSingleK 生成站心日食 SVG使用 IAU Single-K 模型。
// LocalSolarEclipseSVGIAUSingleK generates a local solar eclipse SVG with the IAU Single-K model.
func LocalSolarEclipseSVGIAUSingleK(
date time.Time,
lon, lat, height float64,
options LocalSolarEclipseSVGOptions,
) (string, bool) {
return localSolarEclipseSVG(date, lon, lat, height, options, basic.LocalSolarEclipseDiagramIAUSingleK, eclipsecore.ClosestLocalSolarEclipseIAUSingleK)
}
func localSolarEclipseSVG(
date time.Time,
lon, lat, height float64,
options LocalSolarEclipseSVGOptions,
calculator localSolarEclipseSVGCalculator,
finder localSolarEclipseSVGFinder,
) (string, bool) {
options = normalizeLocalSolarEclipseSVGOptions(options)
diagram := calculator(
solarEclipseTimeToTTJDE(date),
lon,
lat,
height,
basic.LocalSolarEclipseDiagramOptions{StepDays: solarEclipseDurationToDays(options.Step)},
)
if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 {
return "", false
}
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location)
if finder != nil {
coreInfo := finder(info.GreatestEclipse, lon, lat, height)
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
return renderLocalSolarEclipseSVG(info, diagram, options), true
}
func normalizeLocalSolarEclipseSVGOptions(options LocalSolarEclipseSVGOptions) LocalSolarEclipseSVGOptions {
if options.Width <= 0 {
options.Width = localSolarEclipseSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = localSolarEclipseSVGDefaultHeight
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", localSolarEclipseSVGDefaultZone)
}
if strings.EqualFold(options.Language, localSolarEclipseSVGLanguageEnglish) {
options.Language = localSolarEclipseSVGLanguageEnglish
} else {
options.Language = localSolarEclipseSVGLanguageChinese
}
return options
}
func renderLocalSolarEclipseSVG(
info LocalSolarEclipseInfo,
diagram basic.LocalSolarEclipseDiagramResult,
options LocalSolarEclipseSVGOptions,
) string {
headerTexts := localSolarEclipseSVGHeaderTexts(info, options)
headerBottom := localSolarEclipseSVGHeaderBottom(headerTexts)
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(30, math.Min(46, width*0.05))
panelWidth := math.Max(230, math.Min(280, width*0.28))
diagramLeft := margin
diagramRight := width - panelWidth - margin - 24
if diagramRight-diagramLeft < width*0.48 {
diagramRight = width - margin
}
footerHeight := 72.0
stageHeight := math.Max(160, math.Min(210, height*0.27))
stageTop := height - stageHeight - footerHeight
if stageTop < headerBottom+160 {
stageTop = headerBottom + 160
}
cx := (diagramLeft + diagramRight) / 2
cy := headerBottom + (stageTop-headerBottom)/2 + 4
extent := localSolarEclipseDiagramExtent(diagram)
scale := math.Min((diagramRight-diagramLeft)/(2*extent), (stageTop-headerBottom-18)/(2*extent))
if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
scale = 1
}
panelX := diagramRight + 24
if panelX+panelWidth > width-margin/2 {
panelX = width - panelWidth - margin/2
}
mapX := func(x float64) float64 { return cx - x*scale }
mapY := func(y float64) float64 { return cy - y*scale }
title := localSolarEclipseSVGTitleText(info, options)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, options.Width, options.Height, options.Width, options.Height)
b.WriteString(`<defs>`)
b.WriteString(`<radialGradient id="se-sun" cx="40%" cy="34%" r="64%"><stop offset="0%" stop-color="#fff8bb"/><stop offset="58%" stop-color="#ffd55f"/><stop offset="100%" stop-color="#f5a623"/></radialGradient>`)
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"/>`,
width-44, height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="26" font-weight="700" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(title))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, width/2-78, width/2+78)
for index, line := range headerTexts {
fontSize := 13
fill := "#333"
if index == 0 {
fontSize = 14
fill = "#222"
}
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>`,
width/2, localSolarEclipseSVGHeaderLineY(index), fill, fontSize, html.EscapeString(line))
}
eventFrames := localSolarEclipseSVGEventFrames(diagram.Frames)
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
diagramLeft, headerBottom+10, html.EscapeString(localSolarEclipseSVGOverviewTitleText(options)))
fmt.Fprintf(&b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="url(#se-sun)" stroke="#c78211" stroke-width="1.4"/>`,
cx, cy, scale)
fmt.Fprintf(&b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#ffec92" stroke-opacity="0.58" stroke-width="7"/>`,
cx, cy, scale+2)
writeLocalSolarEclipseAxes(&b, cx, cy, scale, options.Language)
writeLocalSolarEclipseEclipticLine(&b, diagram, mapX, mapY, extent, options.Language)
if len(diagram.Frames) > 0 {
b.WriteString(`<path d="`)
for i, frame := range diagram.Frames {
if i == 0 {
fmt.Fprintf(&b, `M %.3f %.3f`, mapX(frame.MoonX), mapY(frame.MoonY))
continue
}
fmt.Fprintf(&b, ` L %.3f %.3f`, mapX(frame.MoonX), mapY(frame.MoonY))
}
b.WriteString(`" fill="none" stroke="#555555" stroke-width="1.2" stroke-dasharray="5 4" stroke-linecap="round" stroke-linejoin="round"/>`)
}
for _, frame := range eventFrames {
x := mapX(frame.MoonX)
y := mapY(frame.MoonY)
if localSolarEclipseSVGDrawOverviewMoon(frame.Label) {
writeLocalSolarEclipseMoonOutline(&b, frame, x, y, scale)
continue
}
writeLocalSolarEclipseEventPoint(&b, frame.Label, x, y, "#24518a")
}
writeLocalSolarEclipseContactMarkers(&b, info, cx, cy, scale, options.Language)
for _, frame := range eventFrames {
if frame.Label == "" {
continue
}
x := mapX(frame.MoonX)
y := mapY(frame.MoonY)
writeLocalSolarEclipseEventLabel(&b, info.Type, frame.Label, x, y, cx, frame.MoonRadius*scale, options.Language)
}
writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight)
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, height-54,
html.EscapeString(localSolarEclipseSVGDirectionTextValue(options)))
note := localSolarEclipseSVGFooterNoteText(options)
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, height-34, html.EscapeString(note))
writeLocalSolarEclipseContacts(&b, info, options, panelX, math.Max(154, cy-92))
b.WriteString(`</svg>`)
return b.String()
}
func localSolarEclipseSVGTitleText(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string {
if options.Title != "" {
return options.Title
}
return localSolarEclipseSVGTitle(info, options.Language)
}
func localSolarEclipseSVGHeaderTexts(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) []string {
lines := []string{
localSolarEclipseSVGSummaryText(info, options),
localSolarEclipseSVGGreatestTextValue(info, options),
localSolarEclipseSVGMetaTextValue(info, options),
}
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if line != "" {
filtered = append(filtered, line)
}
}
return filtered
}
func localSolarEclipseSVGSummaryText(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string {
if options.SummaryText != "" {
return options.SummaryText
}
return localSolarEclipseSVGSummary(info, options.Language)
}
func localSolarEclipseSVGGreatestTextValue(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string {
if options.GreatestText != "" {
return options.GreatestText
}
return localSolarEclipseSVGGreatestText(info, options)
}
func localSolarEclipseSVGMetaTextValue(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string {
if options.MetaText != "" {
return options.MetaText
}
return localSolarEclipseSVGMetaText(info, options.Language)
}
func localSolarEclipseSVGOverviewTitleText(options LocalSolarEclipseSVGOptions) string {
if options.OverviewTitle != "" {
return options.OverviewTitle
}
return localSolarEclipseSVGOverviewTitle(options.Language)
}
func localSolarEclipseSVGPhasePanelsTitleText(options LocalSolarEclipseSVGOptions) string {
if options.PhasePanelsTitle != "" {
return options.PhasePanelsTitle
}
if options.Language == localSolarEclipseSVGLanguageEnglish {
return "Phase disk panels"
}
return "阶段视圆图"
}
func localSolarEclipseSVGContactsTitleText(options LocalSolarEclipseSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == localSolarEclipseSVGLanguageEnglish {
return "Contacts"
}
return "接触时刻"
}
func localSolarEclipseSVGDirectionTextValue(options LocalSolarEclipseSVGOptions) string {
if options.DirectionText != "" {
return options.DirectionText
}
return localSolarEclipseSVGDirectionText(options.Language)
}
func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
if options.Language == localSolarEclipseSVGLanguageEnglish {
return "Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east."
}
return "上方为全局路径C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。"
}
func localSolarEclipseSVGHeaderLineY(index int) float64 {
return 86 + float64(index)*23
}
func localSolarEclipseSVGHeaderBottom(lines []string) float64 {
if len(lines) == 0 {
return 72
}
return localSolarEclipseSVGHeaderLineY(len(lines)-1) + 14
}
func localSolarEclipseSVGTitle(info LocalSolarEclipseInfo, language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
return fmt.Sprintf("%s Local Solar Eclipse", info.GreatestEclipse.Format("2006-01-02"))
}
return fmt.Sprintf("%s 站心%s", info.GreatestEclipse.Format("2006-01-02"), localSolarEclipseSVGTypeName(info.Type, language))
}
func localSolarEclipseSVGSummary(info LocalSolarEclipseInfo, language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
return fmt.Sprintf("lon=%.4f lat=%.4f type=%s magnitude=%.4f obscuration=%.4f",
info.Longitude, info.Latitude, localSolarEclipseSVGTypeName(info.Type, language), info.Magnitude, info.Obscuration)
}
return fmt.Sprintf("经度=%.4f 纬度=%.4f 食型=%s 食分=%.4f 掩食比=%.4f",
info.Longitude, info.Latitude, localSolarEclipseSVGTypeName(info.Type, language), info.Magnitude, info.Obscuration)
}
func localSolarEclipseSVGMetaText(info LocalSolarEclipseInfo, language string) string {
parts := make([]string, 0, 2)
if info.HasSaros {
if language == localSolarEclipseSVGLanguageEnglish {
parts = append(parts, fmt.Sprintf("Solar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count))
} else {
parts = append(parts, fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count))
}
}
if duration := localSolarEclipseSVGCentralDurationText(info, language); duration != "" {
parts = append(parts, duration)
}
return strings.Join(parts, " ")
}
func localSolarEclipseSVGDirectionText(language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
return "Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up."
}
return "太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。"
}
func localSolarEclipseSVGGreatestText(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string {
greatest := info.GreatestEclipse.In(options.Location).Format("2006-01-02 15:04:05 MST")
constellation := localSolarEclipseSVGConstellationName(info, options.Language)
if options.Language == localSolarEclipseSVGLanguageEnglish {
return fmt.Sprintf("Greatest: %s Sun altitude %.2f deg Sun in %s", greatest, info.SunAltitude, constellation)
}
return fmt.Sprintf("食甚:%s 太阳高度 %.2f 度 太阳位于%s", greatest, info.SunAltitude, constellation)
}
func localSolarEclipseSVGCentralDurationText(info LocalSolarEclipseInfo, language string) string {
if !info.HasCentral || info.CentralStart.IsZero() || info.CentralEnd.IsZero() {
return ""
}
duration := lunarEclipseSVGFormatDuration(info.CentralEnd.Sub(info.CentralStart))
if language == localSolarEclipseSVGLanguageEnglish {
switch info.Type {
case SolarEclipseTotal:
return "Totality " + duration
case SolarEclipseAnnular:
return "Annularity " + duration
default:
return "Central phase " + duration
}
}
switch info.Type {
case SolarEclipseTotal:
return "全食历时 " + duration
case SolarEclipseAnnular:
return "环食历时 " + duration
default:
return "中心食历时 " + duration
}
}
func localSolarEclipseSVGOverviewTitle(language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
return "Overview path"
}
return "全局路径"
}
func localSolarEclipseSVGOverviewEventName(label, language string) string {
if label == "Greatest" {
if language == localSolarEclipseSVGLanguageEnglish {
return "GE"
}
return "食甚"
}
return label
}
func localSolarEclipseSVGTypeName(eclipseType SolarEclipseType, language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
switch eclipseType {
case SolarEclipsePartial:
return "partial"
case SolarEclipseAnnular:
return "annular"
case SolarEclipseTotal:
return "total"
case SolarEclipseHybrid:
return "hybrid"
default:
return "none"
}
}
switch eclipseType {
case SolarEclipsePartial:
return "日偏食"
case SolarEclipseAnnular:
return "日环食"
case SolarEclipseTotal:
return "日全食"
case SolarEclipseHybrid:
return "全环食"
default:
return "无日食"
}
}
func localSolarEclipseSVGEventFrames(frames []basic.LocalSolarEclipseDiagramFrame) []basic.LocalSolarEclipseDiagramFrame {
events := make([]basic.LocalSolarEclipseDiagramFrame, 0, 5)
for _, frame := range frames {
for _, label := range localSolarEclipseSVGFrameLabels(frame) {
event := frame
event.Label = label
event.Labels = []string{label}
events = append(events, event)
}
}
return events
}
func localSolarEclipseSVGFrameLabels(frame basic.LocalSolarEclipseDiagramFrame) []string {
if len(frame.Labels) > 0 {
return frame.Labels
}
if frame.Label == "" {
return nil
}
return []string{frame.Label}
}
func writeLocalSolarEclipseAxes(b *strings.Builder, cx, cy, radius float64, language string) {
north, east, west, south := "北", "东", "西", "南"
if language == localSolarEclipseSVGLanguageEnglish {
north, east, west, south = "N", "E", "W", "S"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy-radius-17, html.EscapeString(north))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx-radius-20, cy+4, html.EscapeString(east))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx+radius+20, cy+4, html.EscapeString(west))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy+radius+27, html.EscapeString(south))
}
func writeLocalSolarEclipseMoon(
b *strings.Builder,
frame basic.LocalSolarEclipseDiagramFrame,
x, y, scale float64,
) {
radius := frame.MoonRadius * scale
fmt.Fprintf(b, `<circle class="stage-moon" cx="%.3f" cy="%.3f" r="%.3f" fill="#050505" stroke="#111111" stroke-width="1.1"/>`,
x, y, radius)
}
func writeLocalSolarEclipseMoonOutline(
b *strings.Builder,
frame basic.LocalSolarEclipseDiagramFrame,
x, y, scale float64,
) {
radius := frame.MoonRadius * scale
stroke := "#24518a"
if frame.Label == "Greatest" {
stroke = "#111111"
}
fmt.Fprintf(b, `<circle class="event-moon" data-label="%s" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="%s" stroke-width="1.3" stroke-dasharray="5 4"/>`,
html.EscapeString(frame.Label), x, y, radius, stroke)
writeLocalSolarEclipseEventPoint(b, frame.Label, x, y, stroke)
}
func localSolarEclipseSVGDrawOverviewMoon(label string) bool {
return label != "C2" && label != "C3"
}
func writeLocalSolarEclipseEventPoint(b *strings.Builder, label string, x, y float64, stroke string) {
fmt.Fprintf(b, `<circle class="event-center" data-label="%s" cx="%.3f" cy="%.3f" r="2.6" fill="#ffffff" stroke="%s" stroke-width="1.2"/>`,
html.EscapeString(label), x, y, stroke)
}
func writeLocalSolarEclipseContactMarkers(
b *strings.Builder,
info LocalSolarEclipseInfo,
cx, cy, radius float64,
language string,
) {
for _, point := range info.ContactPoints {
angle := point.ContactPositionAngle * math.Pi / 180
unitX := math.Sin(angle)
unitY := math.Cos(angle)
x := cx - radius*unitX
y := cy - radius*unitY
labelDistance := radius + localSolarEclipseSVGContactLabelDistance(radius)
labelDistance += localSolarEclipseSVGContactLabelExtraDistance(unitX, unitY)
labelX := cx - labelDistance*unitX
labelY := cy - labelDistance*unitY + 4
sideShift := localSolarEclipseSVGContactLabelSideShift(unitX, unitY)
labelX += unitY * sideShift
labelY += unitX * sideShift
anchor := "middle"
if unitX > 0.28 {
anchor = "end"
} else if unitX < -0.28 {
anchor = "start"
}
fmt.Fprintf(b, `<line class="contact-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#c96b6b" stroke-width="0.8" opacity="0.58"/>`,
x, y, labelX, labelY-4)
fmt.Fprintf(b, `<circle class="contact-point" cx="%.3f" cy="%.3f" r="3.2" fill="#b51616" stroke="#ffffff" stroke-width="0.8"/>`,
x, y)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#b51616" font-family="Georgia, 'Times New Roman', serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
labelX, labelY, anchor, html.EscapeString(localSolarEclipseSVGContactLabel(point, language)))
}
}
func localSolarEclipseSVGContactLabel(point LocalSolarEclipseContactPoint, language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
return fmt.Sprintf("%s %.0f°", point.Label, point.ContactPositionAngle)
}
return fmt.Sprintf("%s %.0f°", point.Label, point.ContactPositionAngle)
}
func writeLocalSolarEclipseEclipticLine(
b *strings.Builder,
diagram basic.LocalSolarEclipseDiagramResult,
mapX, mapY func(float64) float64,
extent float64,
language string,
) {
unitX, unitY, ok := localSolarEclipseSVGEclipticDirection(diagram.Eclipse.GreatestEclipse)
if !ok {
return
}
lineExtent := extent * 0.92
startX := mapX(-unitX * lineExtent)
startY := mapY(-unitY * lineExtent)
endX := mapX(unitX * lineExtent)
endY := mapY(unitY * lineExtent)
if math.IsNaN(startX) || math.IsNaN(startY) || math.IsNaN(endX) || math.IsNaN(endY) {
return
}
fmt.Fprintf(b, `<line class="ecliptic-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#666" stroke-width="1" stroke-dasharray="4 3" opacity="0.82"/>`,
startX, startY, endX, endY)
labelX := startX
labelY := startY
anchor := "end"
if endX < startX {
labelX = endX
labelY = endY
}
if labelX < endX {
anchor = "start"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#444" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="%s">%s</text>`,
labelX, labelY-6, anchor, html.EscapeString(localSolarEclipseSVGLabelEcliptic(language)))
}
func localSolarEclipseSVGEclipticDirection(ttJDE float64) (float64, float64, bool) {
originRA, originDec := basic.HSunApparentRaDec(ttJDE)
centerLongitude := normalizeSolarEclipseDegree360(basic.HSunApparentLo(ttJDE))
ra1, dec1 := basic.LoBoToRaDec(ttJDE, centerLongitude-1, 0)
ra2, dec2 := basic.LoBoToRaDec(ttJDE, centerLongitude+1, 0)
x1, y1 := localSolarEclipseSVGTangentOffset(originRA, originDec, ra1, dec1)
x2, y2 := localSolarEclipseSVGTangentOffset(originRA, originDec, ra2, dec2)
dx := x2 - x1
dy := y2 - y1
length := math.Hypot(dx, dy)
if length == 0 || math.IsNaN(length) || math.IsInf(length, 0) {
return 0, 0, false
}
return dx / length, dy / length, true
}
func localSolarEclipseSVGTangentOffset(originRA, originDec, targetRA, targetDec float64) (float64, float64) {
separation := localSolarEclipseSVGAngularSeparation(originRA, originDec, targetRA, targetDec)
positionAngleRad := localSolarEclipseSVGPositionAngle(originRA, originDec, targetRA, targetDec) * math.Pi / 180
return separation * math.Sin(positionAngleRad), separation * math.Cos(positionAngleRad)
}
func localSolarEclipseSVGAngularSeparation(ra1, dec1, ra2, dec2 float64) float64 {
ra1Rad := ra1 * math.Pi / 180
dec1Rad := dec1 * math.Pi / 180
ra2Rad := ra2 * math.Pi / 180
dec2Rad := dec2 * math.Pi / 180
cosDistance := math.Sin(dec1Rad)*math.Sin(dec2Rad) +
math.Cos(dec1Rad)*math.Cos(dec2Rad)*math.Cos(ra2Rad-ra1Rad)
if cosDistance > 1 {
cosDistance = 1
}
if cosDistance < -1 {
cosDistance = -1
}
return math.Acos(cosDistance) * 180 / math.Pi
}
func localSolarEclipseSVGPositionAngle(fromRA, fromDec, toRA, toDec float64) float64 {
dRA := (toRA - fromRA) * math.Pi / 180
fromDecRad := fromDec * math.Pi / 180
toDecRad := toDec * math.Pi / 180
angle := math.Atan2(
math.Sin(dRA),
math.Cos(fromDecRad)*math.Tan(toDecRad)-math.Sin(fromDecRad)*math.Cos(dRA),
) * 180 / math.Pi
return normalizeSolarEclipseDegree360(angle)
}
func localSolarEclipseSVGLabelEcliptic(language string) string {
if language == localSolarEclipseSVGLanguageEnglish {
return "Ecliptic"
}
return "黄道"
}
func writeLocalSolarEclipseEventLabel(
b *strings.Builder,
_ SolarEclipseType,
label string,
x, y, cx, moonRadius float64,
language string,
) {
text := localSolarEclipseSVGOverviewEventName(label, language)
dx, dy, anchor := localSolarEclipseSVGEventLabelLayout(label, x, cx, moonRadius)
lineX := x + dx*0.8
lineY := y + dy*0.8 - 2
fmt.Fprintf(b, `<line class="event-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#24518a" stroke-width="0.8" stroke-dasharray="3 3" opacity="0.72"/>`,
x, y, lineX, lineY)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#174f8a" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(text))
}
func localSolarEclipseSVGEventLabelLayout(label string, x, cx, moonRadius float64) (float64, float64, string) {
dx := moonRadius + 18
anchor := "start"
if x > cx {
dx = -dx
anchor = "end"
}
dy := -moonRadius*0.62 - 10
switch label {
case "C1":
dx = -(moonRadius*1.12 + 26)
dy = -(moonRadius*0.84 + 16)
anchor = "end"
case "C2":
dx = -(moonRadius*0.96 + 18)
dy = -(moonRadius*0.56 + 14)
anchor = "end"
case "C3":
dx = moonRadius*1.04 + 24
dy = moonRadius*0.42 + 16
anchor = "start"
case "C4":
dx = moonRadius*0.94 + 20
dy = moonRadius*0.58 + 18
anchor = "start"
case "Greatest":
dx = -(moonRadius*0.42 + 16)
dy = moonRadius + 24
anchor = "end"
}
return dx, dy, anchor
}
func localSolarEclipseSVGConstellationName(info LocalSolarEclipseInfo, language string) string {
jde := solarEclipseTimeToTTJDE(info.GreatestEclipse)
ra, dec := basic.HSunApparentRaDec(jde)
code := basic.ConstellationCode(ra, dec, jde)
if language == localSolarEclipseSVGLanguageEnglish {
return basic.ConstellationNameByCodeEN(code)
}
return basic.ConstellationNameByCodeZH(code)
}
func localSolarEclipseSVGContactLabelDistance(radius float64) float64 {
return math.Max(40, math.Min(58, radius*0.86))
}
func localSolarEclipseSVGContactLabelExtraDistance(unitX, unitY float64) float64 {
extra := 8.0
if unitY > 0.72 {
extra += 10
}
if math.Abs(unitX) < 0.18 {
extra += 8
}
if math.Abs(unitX) > 0.82 {
extra += 4
}
return extra
}
func localSolarEclipseSVGContactLabelSideShift(unitX, unitY float64) float64 {
if unitY > 0.72 {
return 14
}
if unitX > 0.82 {
return 18
}
if unitX < -0.82 {
return 12
}
if unitY < -0.72 {
return 6
}
return 0
}
func localSolarEclipseSVGEventName(label, language string, eclipseType SolarEclipseType) string {
if language == localSolarEclipseSVGLanguageEnglish {
switch label {
case "C2":
switch eclipseType {
case SolarEclipseTotal:
return "C2 Total begins"
case SolarEclipseAnnular:
return "C2 Annularity begins"
default:
return "C2 Central begins"
}
case "C3":
switch eclipseType {
case SolarEclipseTotal:
return "C3 Total ends"
case SolarEclipseAnnular:
return "C3 Annularity ends"
default:
return "C3 Central ends"
}
}
return label
}
switch label {
case "C1":
return "C1 初亏"
case "C2":
switch eclipseType {
case SolarEclipseTotal:
return "C2 食既"
case SolarEclipseAnnular:
return "C2 环食始"
default:
return "C2 中心食始"
}
case "Greatest":
return "食甚"
case "C3":
switch eclipseType {
case SolarEclipseTotal:
return "C3 生光"
case SolarEclipseAnnular:
return "C3 环食终"
default:
return "C3 中心食终"
}
case "C4":
return "C4 复圆"
default:
return label
}
}
func writeLocalSolarEclipseStagePanels(
b *strings.Builder,
info LocalSolarEclipseInfo,
frames []basic.LocalSolarEclipseDiagramFrame,
options LocalSolarEclipseSVGOptions,
x, y, width, height float64,
) {
if len(frames) == 0 {
return
}
title := localSolarEclipseSVGPhasePanelsTitleText(options)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
x, y+14, html.EscapeString(title))
gap := 10.0
panelCount := float64(len(frames))
panelWidth := (width - gap*(panelCount-1)) / panelCount
if panelWidth < 74 {
gap = 6
panelWidth = (width - gap*(panelCount-1)) / panelCount
}
panelTop := y + 24
panelHeight := height - 30
panelScale := localSolarEclipseStageScaleForFrames(frames, panelWidth, panelHeight)
contactPoints := localSolarEclipseContactPointMap(info.ContactPoints)
for index, frame := range frames {
panelX := x + float64(index)*(panelWidth+gap)
writeLocalSolarEclipseStagePanel(b, info, frame, options, contactPoints, panelX, panelTop, panelWidth, panelHeight, panelScale)
}
}
func writeLocalSolarEclipseStagePanel(
b *strings.Builder,
info LocalSolarEclipseInfo,
frame basic.LocalSolarEclipseDiagramFrame,
options LocalSolarEclipseSVGOptions,
contactPoints map[string]LocalSolarEclipseContactPoint,
x, y, width, height, scale float64,
) {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#fbfbf8" stroke="#d8d2c4" stroke-width="1"/>`,
x, y, width, height)
label := localSolarEclipseSVGEventName(frame.Label, options.Language, info.Type)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700" text-anchor="middle">%s</text>`,
x+width/2, y+18, html.EscapeString(label))
centerX := x + width/2
centerY := y + 30 + (height-62)/2
moonX := centerX - frame.MoonX*scale
moonY := centerY - frame.MoonY*scale
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="url(#se-sun)" stroke="#c78211" stroke-width="1"/>`,
centerX, centerY, scale)
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#ffec92" stroke-opacity="0.55" stroke-width="3"/>`,
centerX, centerY, scale+1.2)
writeLocalSolarEclipseMoon(b, frame, moonX, moonY, scale)
if point, ok := contactPoints[frame.Label]; ok {
writeLocalSolarEclipseStageContactMarker(b, point, centerX, centerY, scale)
}
if eventTime, ok := localSolarEclipseSVGEventTime(info, frame.Label); ok {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#444" font-family="Georgia, 'Times New Roman', serif" font-size="11" text-anchor="middle">%s</text>`,
x+width/2, y+height-12, html.EscapeString(eventTime.In(options.Location).Format("15:04:05")))
}
}
func localSolarEclipseStageScaleForFrames(frames []basic.LocalSolarEclipseDiagramFrame, width, height float64) float64 {
availableX := width/2 - 12
availableY := (height - 62) / 2
if availableX < 12 {
availableX = 12
}
if availableY < 12 {
availableY = 12
}
extentX := 1.15
extentY := 1.15
for _, frame := range frames {
candidateX := math.Abs(frame.MoonX) + frame.MoonRadius + 0.14
candidateY := math.Abs(frame.MoonY) + frame.MoonRadius + 0.14
if candidateX > extentX {
extentX = candidateX
}
if candidateY > extentY {
extentY = candidateY
}
}
scale := math.Min(48, math.Min(availableX/extentX, availableY/extentY))
if scale < 13 {
scale = 13
}
return scale
}
func writeLocalSolarEclipseStageContactMarker(
b *strings.Builder,
point LocalSolarEclipseContactPoint,
cx, cy, radius float64,
) {
angle := point.ContactPositionAngle * math.Pi / 180
x := cx - radius*math.Sin(angle)
y := cy - radius*math.Cos(angle)
fmt.Fprintf(b, `<circle class="stage-contact-point" cx="%.3f" cy="%.3f" r="2.5" fill="#b51616" stroke="#ffffff" stroke-width="0.8"/>`,
x, y)
}
func localSolarEclipseSVGEventTime(info LocalSolarEclipseInfo, label string) (time.Time, bool) {
switch label {
case "C1":
return info.PartialStart, !info.PartialStart.IsZero()
case "C2":
return info.CentralStart, !info.CentralStart.IsZero()
case "Greatest":
return info.GreatestEclipse, !info.GreatestEclipse.IsZero()
case "C3":
return info.CentralEnd, !info.CentralEnd.IsZero()
case "C4":
return info.PartialEnd, !info.PartialEnd.IsZero()
default:
return time.Time{}, false
}
}
type localSolarEclipseSVGContact struct {
label string
name string
time time.Time
angle float64
hasAngle bool
}
func writeLocalSolarEclipseContacts(
b *strings.Builder,
info LocalSolarEclipseInfo,
options LocalSolarEclipseSVGOptions,
x, y float64,
) {
contacts := localSolarEclipseSVGContacts(info, options.Language)
if len(contacts) == 0 {
return
}
title := localSolarEclipseSVGContactsTitleText(options)
boxWidth := float64(options.Width) - x - 34
if boxWidth < 210 {
boxWidth = 210
}
if boxWidth > 260 {
boxWidth = 260
}
boxHeight := 27 + float64(len(contacts))*18
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#fbfbf8" stroke="#d8d2c4" stroke-width="1"/>`,
x-12, y-20, boxWidth, boxHeight)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700">%s (%s)</text>`,
x, y, html.EscapeString(title), html.EscapeString(options.Location.String()))
for index, contact := range contacts {
line := fmt.Sprintf("%s %s %s", contact.label, contact.name, contact.time.In(options.Location).Format("15:04:05"))
if contact.hasAngle {
if options.Language == localSolarEclipseSVGLanguageEnglish {
line = fmt.Sprintf("%s PA %.1f°", line, contact.angle)
} else {
line = fmt.Sprintf("%s 方位 %.1f°", line, contact.angle)
}
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333333" font-family="Georgia, 'Times New Roman', serif" font-size="11.5">%s</text>`,
x, y+float64(index+1)*17.5, html.EscapeString(line))
}
}
func localSolarEclipseSVGContacts(info LocalSolarEclipseInfo, language string) []localSolarEclipseSVGContact {
angles := localSolarEclipseContactAngleMap(info.ContactPoints)
contacts := []localSolarEclipseSVGContact{
localSolarEclipseSVGContactFor("C1", localSolarEclipseSVGContactName("C1", language, info.Type), info.PartialStart, angles),
}
if info.HasCentral {
contacts = append(contacts, localSolarEclipseSVGContactFor("C2", localSolarEclipseSVGContactName("C2", language, info.Type), info.CentralStart, angles))
}
contacts = append(contacts, localSolarEclipseSVGContact{label: "GE", name: localSolarEclipseSVGContactName("Greatest", language, info.Type), time: info.GreatestEclipse})
if info.HasCentral {
contacts = append(contacts, localSolarEclipseSVGContactFor("C3", localSolarEclipseSVGContactName("C3", language, info.Type), info.CentralEnd, angles))
}
contacts = append(contacts, localSolarEclipseSVGContactFor("C4", localSolarEclipseSVGContactName("C4", language, info.Type), info.PartialEnd, angles))
return contacts
}
func localSolarEclipseSVGContactFor(
label, name string,
time time.Time,
angles map[string]float64,
) localSolarEclipseSVGContact {
angle, ok := angles[label]
return localSolarEclipseSVGContact{
label: label,
name: name,
time: time,
angle: angle,
hasAngle: ok,
}
}
func localSolarEclipseContactAngleMap(points []LocalSolarEclipseContactPoint) map[string]float64 {
angles := make(map[string]float64, len(points))
for _, point := range points {
angles[point.Label] = point.ContactPositionAngle
}
return angles
}
func localSolarEclipseContactPointMap(points []LocalSolarEclipseContactPoint) map[string]LocalSolarEclipseContactPoint {
contacts := make(map[string]LocalSolarEclipseContactPoint, len(points))
for _, point := range points {
contacts[point.Label] = point
}
return contacts
}
func localSolarEclipseSVGContactName(label, language string, eclipseType SolarEclipseType) string {
if language == localSolarEclipseSVGLanguageEnglish {
switch label {
case "C1":
return "First contact"
case "C2":
switch eclipseType {
case SolarEclipseTotal:
return "Total begins"
case SolarEclipseAnnular:
return "Annularity begins"
default:
return "Central begins"
}
case "Greatest":
return "Greatest"
case "C3":
switch eclipseType {
case SolarEclipseTotal:
return "Total ends"
case SolarEclipseAnnular:
return "Annularity ends"
default:
return "Central ends"
}
case "C4":
return "Last contact"
default:
return label
}
}
switch label {
case "C1":
return "初亏"
case "C2":
switch eclipseType {
case SolarEclipseTotal:
return "食既"
case SolarEclipseAnnular:
return "环食始"
default:
return "中心食始"
}
case "Greatest":
return "食甚"
case "C3":
switch eclipseType {
case SolarEclipseTotal:
return "生光"
case SolarEclipseAnnular:
return "环食终"
default:
return "中心食终"
}
case "C4":
return "复圆"
default:
return label
}
}
func localSolarEclipseDiagramExtent(diagram basic.LocalSolarEclipseDiagramResult) float64 {
extent := 1.45
for _, frame := range diagram.Frames {
candidate := math.Hypot(frame.MoonX, frame.MoonY) + frame.MoonRadius + 0.28
if candidate > extent {
extent = candidate
}
}
return extent
}
func solarEclipseDurationToDays(duration time.Duration) float64 {
if duration <= 0 {
return 0
}
return duration.Hours() / 24
}