astro/eclipse/svg/solar.go

1121 lines
39 KiB
Go
Raw Normal View History

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
}