Files
astro/eclipse/svg/solar.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

1227 lines
43 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 (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgchart"
)
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
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
}
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) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
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, options.TimeScale)
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)
direction := localSolarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
scaleNote := timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language)
footerLines := localSolarEclipseSVGFooterLines(direction, localSolarEclipseSVGFooterNoteText(options), scaleNote, width, height, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), height, 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
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 := footerOccupied + 10
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)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, width-52, 26)
}
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(titleText))
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)
for index, line := range footerLines {
fill := "#555"
if index == 0 {
fill = "#333"
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, footerBaselines[index], fill, html.EscapeString(line))
}
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 {
values := []struct {
text string
custom bool
}{
{localSolarEclipseSVGSummaryText(info, options), options.SummaryText != ""},
{localSolarEclipseSVGGreatestTextValue(info, options), options.GreatestText != ""},
{localSolarEclipseSVGMetaTextValue(info, options), options.MetaText != ""},
}
filtered := make([]string, 0, len(values))
for _, value := range values {
if value.text == "" {
continue
}
// 摘要行各占一个固定槽位,调用方长文本只能截断,不能折行挤压下一行。
if value.custom {
filtered = append(filtered, svgchart.EllipsizeText(value.text, float64(options.Width)-80, 14))
continue
}
filtered = append(filtered, value.text)
}
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 {
return localSolarEclipseSVGFooterNoteTextBase(options)
}
func localSolarEclipseSVGFooterNoteTextBase(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 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。"
}
// localSolarEclipseSVGFooterLines 折行页脚说明并按图区下限与画布高度截断行数,返回方向行在前的页脚各行。
// localDiagramFooterBottomPadding 是站心图页脚末行基线距画布底边的留白:图框在画布内缩 18px,
// 留白取 18px 框线 + 4px 间隙 + 12px 字的字下伸部,末行因此落在框线之内而不是压在框线上。
const localDiagramFooterBottomPadding = 26.0
func localSolarEclipseSVGFooterLines(direction, noteText, scaleNote string, width, height, headerBottom float64) []string {
const (
fontSize = 12.0
stageFloor = 160.0
stageGap = 10.0
)
lineHeight := svgchart.FooterLineHeight(fontSize)
padding := localDiagramFooterBottomPadding
// 先扣掉底边留白、方向行与图区下限,剩下的才是说明行能用的高度;再按画布高度两成封顶。
maxLines := svgchart.TextLineLimit(fontSize, lineHeight,
height-headerBottom-stageFloor-stageGap-padding-lineHeight)
if cap := int(height * 0.20 / lineHeight); maxLines > cap {
maxLines = cap
}
if maxLines < 1 {
maxLines = 1
}
maxWidth := width - 80
// 默认把时标声明并进说明一起均衡折行(行宽最接近方向说明),在放得下的行数里挑最均衡的一种。
mergedText := strings.TrimSpace(noteText + " " + scaleNote)
lines := svgchart.WrapTextBalanced(mergedText, maxWidth, fontSize)
for slots := 2; slots <= maxLines; slots++ {
candidate := svgchart.WrapTextBalancedLines(mergedText, maxWidth, fontSize, slots)
if len(candidate) > maxLines {
break
}
if solarEclipseLocalFooterSpread(direction, candidate) < solarEclipseLocalFooterSpread(direction, lines) {
lines = candidate
}
}
if len(lines) > maxLines {
// 说明太长折不下:截断说明,把时标声明单独留成最后一行。
notesOnly := svgchart.WrapTextBalanced(noteText, maxWidth, fontSize)
lines = svgchart.FooterLinesWithScale(notesOnly, scaleNote, maxWidth, fontSize, maxLines)
}
return append([]string{direction}, lines...)
}
// solarEclipseLocalFooterSpread 返回页脚各行估算宽度的最大最小比,用于在两种折行方案里取更整齐的一种。
func solarEclipseLocalFooterSpread(direction string, lines []string) float64 {
minimum, maximum := svgchart.EstimatedTextWidth(direction, 12), svgchart.EstimatedTextWidth(direction, 12)
for _, line := range lines {
width := svgchart.EstimatedTextWidth(line, 12)
minimum, maximum = math.Min(minimum, width), math.Max(maximum, width)
}
if minimum <= 0 {
return math.Inf(1)
}
return maximum / minimum
}
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 {
label := fmt.Sprintf("Solar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)
if !info.Saros.Verified {
label += " (provisional)"
}
parts = append(parts, label)
} else {
label := fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)
if !info.Saros.Verified {
label += "(推算)"
}
parts = append(parts, label)
}
}
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
}