astro/eclipse/svg/lunar.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

924 lines
31 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 (
lunarEclipseSVGDefaultWidth = 960
lunarEclipseSVGDefaultHeight = 620
lunarEclipseSVGDefaultZone = 8 * 60 * 60
lunarEclipseSVGLanguageChinese = "zh"
lunarEclipseSVGLanguageEnglish = "en"
)
// LunarEclipseSVGOptions 控制月食穿影 SVG 输出。
// LunarEclipseSVGOptions controls lunar eclipse shadow-path SVG output.
type LunarEclipseSVGOptions 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
// MaximumText 是标题下第二行食甚说明;为空时自动生成。
// MaximumText is the second line below the title for maximum-eclipse details; empty values use automatic text.
MaximumText string
// CoordinatesText 是标题下第三行坐标说明;为空时自动生成。
// CoordinatesText is the third line below the title for coordinates; empty values use automatic text.
CoordinatesText string
// DurationText 是标题下第四行历时说明;为空时自动生成。
// DurationText is the fourth line below the title for durations; empty values use automatic text.
DurationText string
// MetaText 是标题下第五行补充说明;为空时自动生成沙罗信息。
// MetaText is the fifth line below the title; empty values use automatic Saros text.
MetaText 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 lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
type lunarEclipseSVGFinder func(time.Time) LunarEclipseInfo
type lunarEclipseSVGLayout struct {
width float64
height float64
cx float64
cy float64
scale float64
diagramLeft float64
diagramRight float64
panelX float64
panelY float64
}
type lunarEclipseSVGPoint struct {
basic.LunarEclipseDiagramPoint
X float64
Y float64
}
type lunarEclipseSVGMaximumCoordinates struct {
RA float64
Dec float64
EclipticLongitude float64
EclipticLatitude float64
ConstellationCode string
ConstellationName string
}
// LunarEclipseSVG 生成月食穿影图 SVG默认使用 Danjon 影半径模型。
// LunarEclipseSVG generates an SVG lunar eclipse shadow-path chart, using the Danjon shadow model by default.
func LunarEclipseSVG(date time.Time, options LunarEclipseSVGOptions) (string, bool) {
return LunarEclipseSVGDanjon(date, options)
}
// LunarEclipseSVGDanjon 生成月食穿影图 SVG使用 Danjon 影半径模型。
// LunarEclipseSVGDanjon generates an SVG lunar eclipse shadow-path chart with the Danjon shadow model.
func LunarEclipseSVGDanjon(date time.Time, options LunarEclipseSVGOptions) (string, bool) {
return lunarEclipseSVG(date, options, basic.LunarEclipseDiagramDanjon, eclipsecore.ClosestLunarEclipseDanjon)
}
// LunarEclipseSVGChauvenet 生成月食穿影图 SVG使用 Chauvenet 影半径模型。
// LunarEclipseSVGChauvenet generates an SVG lunar eclipse shadow-path chart with the Chauvenet shadow model.
func LunarEclipseSVGChauvenet(date time.Time, options LunarEclipseSVGOptions) (string, bool) {
return lunarEclipseSVG(date, options, basic.LunarEclipseDiagramChauvenet, eclipsecore.ClosestLunarEclipseChauvenet)
}
func lunarEclipseSVG(
date time.Time,
options LunarEclipseSVGOptions,
calculator lunarEclipseSVGCalculator,
finder lunarEclipseSVGFinder,
) (string, bool) {
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
}
info := lunarEclipseInfoFromBasic(diagram.Eclipse, options.Location)
if finder != nil {
coreInfo := finder(info.Maximum)
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
return renderLunarEclipseSVG(info, diagram, options), true
}
func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions {
if options.Width <= 0 {
options.Width = lunarEclipseSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = lunarEclipseSVGDefaultHeight
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", lunarEclipseSVGDefaultZone)
}
if strings.EqualFold(options.Language, lunarEclipseSVGLanguageEnglish) {
options.Language = lunarEclipseSVGLanguageEnglish
} else {
options.Language = lunarEclipseSVGLanguageChinese
}
return options
}
func renderLunarEclipseSVG(
info LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
) string {
headerTexts := lunarEclipseSVGHeaderTexts(info, options)
layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts))
points := lunarEclipseSVGPoints(diagram.Points)
mapX := func(x float64) float64 { return layout.cx - x*layout.scale }
mapY := func(y float64) float64 { return layout.cy - y*layout.scale }
title := lunarEclipseSVGTitleText(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(lunarEclipseSVGMoonSymbol)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
layout.width-44, layout.height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="26" font-weight="700" text-anchor="middle">%s</text>`,
layout.width/2, html.EscapeString(title))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
writeLunarEclipseSummary(&b, headerTexts, options)
fmt.Fprintf(&b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="#e9e9e9" stroke="#d6d6d6" stroke-width="1.2"/>`,
layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale)
fmt.Fprintf(&b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="#b21f16" stroke="#83170f" stroke-width="1.3"/>`,
layout.cx, layout.cy, diagram.UmbraRadius*layout.scale)
writeLunarEclipseShadowLabels(&b, layout, diagram, options.Language)
writeLunarEclipseAxes(&b, layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale, options.Language)
writeLunarEclipseEclipticLine(&b, layout, diagram, mapX, mapY, options.Language)
if len(points) > 0 {
b.WriteString(`<path d="`)
for i, point := range points {
if i == 0 {
fmt.Fprintf(&b, `M %.3f %.3f`, mapX(point.X), mapY(point.Y))
continue
}
fmt.Fprintf(&b, ` L %.3f %.3f`, mapX(point.X), mapY(point.Y))
}
b.WriteString(`" fill="none" stroke="#333333" stroke-width="1.1" stroke-dasharray="5 4" stroke-linecap="round" stroke-linejoin="round"/>`)
}
eventPoints := lunarEclipseSVGEventPoints(points)
for _, point := range eventPoints {
if point.Label == "Greatest" {
continue
}
writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, false)
}
for _, point := range eventPoints {
if point.Label == "Greatest" {
writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, true)
break
}
}
for _, point := range eventPoints {
if point.Label == "" {
continue
}
x := mapX(point.X)
y := mapY(point.Y)
writeLunarEclipseEventLabel(&b, point.Label, x, y, layout.cx, diagram.MoonRadius*layout.scale, options.Language)
}
writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY)
writeLunarEclipseFooter(&b, info, options, layout)
b.WriteString(`</svg>`)
return b.String()
}
func lunarEclipseSVGLayoutFor(
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
headerBottom float64,
) lunarEclipseSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(32, math.Min(48, width*0.05))
panelWidth := math.Max(210, math.Min(260, width*0.24))
diagramLeft := margin
diagramRight := width - panelWidth - 34
if diagramRight-diagramLeft < width*0.48 {
diagramRight = width - margin
}
topReserved := math.Max(166, headerBottom+24)
bottomReserved := 82.0
extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72
scale := math.Min((diagramRight-diagramLeft)/(2*extent), (height-topReserved-bottomReserved)/(2*extent))
if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
scale = 1
}
cx := (diagramLeft + diagramRight) / 2
cy := topReserved + (height-topReserved-bottomReserved)/2 + 12
panelX := diagramRight + 22
if panelX+panelWidth > width-margin/2 {
panelX = width - panelWidth - margin/2
}
if panelX < diagramRight {
panelX = diagramRight
}
return lunarEclipseSVGLayout{
width: width,
height: height,
cx: cx,
cy: cy,
scale: scale,
diagramLeft: diagramLeft,
diagramRight: diagramRight,
panelX: panelX,
panelY: math.Max(148, cy-88),
}
}
func lunarEclipseSVGTitle(info LunarEclipseInfo, language string) string {
return fmt.Sprintf("%s %s", info.Maximum.Format("2006-01-02"), lunarEclipseSVGTypeName(info.Type, language))
}
func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.Title != "" {
return options.Title
}
return lunarEclipseSVGTitle(info, options.Language)
}
func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string {
lines := []string{
lunarEclipseSVGSummaryText(info, options),
lunarEclipseSVGMaximumTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(info, options),
lunarEclipseSVGDurationTextValue(info, options),
lunarEclipseSVGMetaTextValue(info, options),
}
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if line != "" {
filtered = append(filtered, line)
}
}
return filtered
}
func lunarEclipseSVGSummaryText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.SummaryText != "" {
return options.SummaryText
}
return lunarEclipseSVGSummary(info, options.Language)
}
func lunarEclipseSVGMaximumTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.MaximumText != "" {
return options.MaximumText
}
return lunarEclipseSVGMaximumText(info, options)
}
func lunarEclipseSVGCoordinatesTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.CoordinatesText != "" {
return options.CoordinatesText
}
coordinates := lunarEclipseSVGMaximumCoordinatesFor(info, options.Language)
return lunarEclipseSVGMaximumCoordinatesText(coordinates, options.Language)
}
func lunarEclipseSVGDurationTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.DurationText != "" {
return options.DurationText
}
return lunarEclipseSVGDurationSummary(info, options.Language)
}
func lunarEclipseSVGMetaTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
if options.MetaText != "" {
return options.MetaText
}
return lunarEclipseSVGMetaText(info, options.Language)
}
func lunarEclipseSVGContactsTitleText(options LunarEclipseSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == lunarEclipseSVGLanguageEnglish {
return "Contacts"
}
return "接触时刻"
}
func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string {
if options.DirectionText != "" {
return options.DirectionText
}
return lunarEclipseSVGDirectionText(options.Language)
}
func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
note := "图中月面大小和影半径均按实际相对角半径缩放。"
if options.Language == lunarEclipseSVGLanguageEnglish {
note = "Moon disks and shadow radii are drawn to the same relative angular-radius scale."
}
return note
}
func lunarEclipseSVGHeaderLineY(index int) float64 {
return 84 + float64(index)*22
}
func lunarEclipseSVGHeaderBottom(lines []string) float64 {
if len(lines) == 0 {
return 72
}
return lunarEclipseSVGHeaderLineY(len(lines)-1) + 14
}
func lunarEclipseSVGSummary(info LunarEclipseInfo, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
return fmt.Sprintf("type=%s penumbral=%.4f umbral=%.4f",
lunarEclipseSVGTypeName(info.Type, language), info.PenumbralMagnitude, info.UmbralMagnitude)
}
return fmt.Sprintf("食型=%s 半影食分=%.4f 本影食分=%.4f",
lunarEclipseSVGTypeName(info.Type, language), info.PenumbralMagnitude, info.UmbralMagnitude)
}
func writeLunarEclipseSummary(b *strings.Builder, lines []string, options LunarEclipseSVGOptions) {
for index, line := range lines {
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>`,
float64(options.Width)/2, lunarEclipseSVGHeaderLineY(index), fill, fontSize, html.EscapeString(line))
}
}
func lunarEclipseSVGDirectionText(language string) string {
if language == lunarEclipseSVGLanguageEnglish {
return "North is up and east is left; the ecliptic is projected near greatest eclipse."
}
return "上北下南,左东右西;黄道按食甚附近天球投影绘制。"
}
func lunarEclipseSVGMaximumText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string {
maximum := info.Maximum.In(options.Location).Format("2006-01-02 15:04:05 MST")
if options.Language == lunarEclipseSVGLanguageEnglish {
return "Maximum: " + maximum
}
return "食甚:" + maximum
}
func lunarEclipseSVGMaximumCoordinatesFor(
info LunarEclipseInfo,
language string,
) lunarEclipseSVGMaximumCoordinates {
jde := timeToTTJDE(info.Maximum)
ra, dec := basic.HMoonTrueRaDec(jde)
lon := basic.HMoonApparentLo(jde)
lat := basic.HMoonTrueBo(jde)
code := basic.ConstellationCode(ra, dec, jde)
name := basic.ConstellationNameByCodeZH(code)
if language == lunarEclipseSVGLanguageEnglish {
name = basic.ConstellationNameByCodeEN(code)
}
return lunarEclipseSVGMaximumCoordinates{
RA: ra,
Dec: dec,
EclipticLongitude: lon,
EclipticLatitude: lat,
ConstellationCode: code,
ConstellationName: name,
}
}
func lunarEclipseSVGMaximumCoordinatesText(
coordinates lunarEclipseSVGMaximumCoordinates,
language string,
) string {
if language == lunarEclipseSVGLanguageEnglish {
return fmt.Sprintf("Moon: RA %s Dec %s ecl.lon %.4f deg ecl.lat %.4f deg %s",
lunarEclipseSVGFormatRA(coordinates.RA),
lunarEclipseSVGFormatSignedAngle(coordinates.Dec),
coordinates.EclipticLongitude,
coordinates.EclipticLatitude,
coordinates.ConstellationName,
)
}
return fmt.Sprintf("月球:赤经 %s 赤纬 %s 黄经 %.4f° 黄纬 %.4f° %s",
lunarEclipseSVGFormatRA(coordinates.RA),
lunarEclipseSVGFormatSignedAngle(coordinates.Dec),
coordinates.EclipticLongitude,
coordinates.EclipticLatitude,
coordinates.ConstellationName,
)
}
func lunarEclipseSVGFormatRA(degree float64) string {
totalSeconds := int(math.Round(normalizeDegree360(degree) / 15 * 3600))
totalSeconds %= 24 * 3600
hours := totalSeconds / 3600
minutes := totalSeconds % 3600 / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02dh%02dm%02ds", hours, minutes, seconds)
}
func lunarEclipseSVGFormatSignedAngle(degree float64) string {
sign := "+"
if degree < 0 {
sign = "-"
degree = -degree
}
totalSeconds := int(math.Round(degree * 3600))
degrees := totalSeconds / 3600
minutes := totalSeconds % 3600 / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%s%02d°%02d%02d″", sign, degrees, minutes, seconds)
}
func lunarEclipseSVGDurationSummary(info LunarEclipseInfo, language string) string {
penumbral := lunarEclipseSVGFormatDuration(info.PenumbralEnd.Sub(info.PenumbralStart))
if language == lunarEclipseSVGLanguageEnglish {
parts := []string{"Penumbral duration " + penumbral}
if info.HasPartial {
parts = append(parts, "Umbral duration "+lunarEclipseSVGFormatDuration(info.PartialEnd.Sub(info.PartialStart)))
}
if info.HasTotal {
parts = append(parts, "Total duration "+lunarEclipseSVGFormatDuration(info.TotalEnd.Sub(info.TotalStart)))
}
return strings.Join(parts, " ")
}
parts := []string{"半影历时 " + penumbral}
if info.HasPartial {
parts = append(parts, "本影历时 "+lunarEclipseSVGFormatDuration(info.PartialEnd.Sub(info.PartialStart)))
}
if info.HasTotal {
parts = append(parts, "全食历时 "+lunarEclipseSVGFormatDuration(info.TotalEnd.Sub(info.TotalStart)))
}
return strings.Join(parts, " ")
}
func lunarEclipseSVGMetaText(info LunarEclipseInfo, language string) string {
if !info.HasSaros {
return ""
}
if language == lunarEclipseSVGLanguageEnglish {
return fmt.Sprintf("Lunar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)
}
return fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)
}
func lunarEclipseSVGFormatDuration(duration time.Duration) string {
if duration < 0 {
duration = -duration
}
totalSeconds := int(duration.Round(time.Second).Seconds())
hours := totalSeconds / 3600
minutes := totalSeconds % 3600 / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func lunarEclipseSVGTypeName(eclipseType LunarEclipseType, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
switch eclipseType {
case LunarEclipsePenumbral:
return "Penumbral Lunar Eclipse"
case LunarEclipsePartial:
return "Partial Lunar Eclipse"
case LunarEclipseTotal:
return "Total Lunar Eclipse"
default:
return "No Lunar Eclipse"
}
}
switch eclipseType {
case LunarEclipsePenumbral:
return "半影月食"
case LunarEclipsePartial:
return "月偏食"
case LunarEclipseTotal:
return "月全食"
default:
return "无月食"
}
}
func writeLunarEclipseAxes(b *strings.Builder, cx, cy, radius float64, language string) {
north, east, west, south := "北", "东", "西", "南"
if language == lunarEclipseSVGLanguageEnglish {
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-18, 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-22, 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+22, 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+28, html.EscapeString(south))
}
func lunarEclipseSVGPoints(points []basic.LunarEclipseDiagramPoint) []lunarEclipseSVGPoint {
result := make([]lunarEclipseSVGPoint, 0, len(points))
for _, point := range points {
distance := math.Hypot(point.X, point.Y)
positionAngleRad := lunarEclipseMoonCenterPositionAngle(point.JDE) * math.Pi / 180
result = append(result, lunarEclipseSVGPoint{
LunarEclipseDiagramPoint: point,
X: distance * math.Sin(positionAngleRad),
Y: distance * math.Cos(positionAngleRad),
})
}
return result
}
func writeLunarEclipseEclipticLine(
b *strings.Builder,
layout lunarEclipseSVGLayout,
diagram basic.LunarEclipseDiagramResult,
mapX, mapY func(float64) float64,
language string,
) {
unitX, unitY, ok := lunarEclipseSVGEclipticDirection(diagram.Eclipse.Maximum)
if !ok {
return
}
extent := (diagram.PenumbraRadius + diagram.MoonRadius) * 1.42
screenStartX := mapX(-unitX * extent)
screenStartY := mapY(-unitY * extent)
screenEndX := mapX(unitX * extent)
screenEndY := mapY(unitY * extent)
if math.IsNaN(screenStartX) || math.IsNaN(screenStartY) || math.IsNaN(screenEndX) || math.IsNaN(screenEndY) {
return
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#555" stroke-width="1" stroke-dasharray="4 3" opacity="0.82"/>`,
screenStartX, screenStartY, screenEndX, screenEndY)
labelX := screenStartX
labelY := screenStartY
anchor := "end"
if screenEndX < screenStartX {
labelX = screenEndX
labelY = screenEndY
}
labelX -= 8
if labelX < layout.diagramLeft+18 {
labelX += 16
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+4, anchor, html.EscapeString(lunarEclipseSVGLabelEcliptic(language)))
}
func lunarEclipseSVGEclipticDirection(ttJDE float64) (float64, float64, bool) {
originRA, originDec := lunarEclipseShadowCenterRaDec(ttJDE)
centerLongitude := normalizeDegree360(basic.HSunApparentLo(ttJDE) + 180)
ra1, dec1 := basic.LoBoToRaDec(ttJDE, centerLongitude-1, 0)
ra2, dec2 := basic.LoBoToRaDec(ttJDE, centerLongitude+1, 0)
x1, y1 := lunarEclipseSVGTangentOffset(originRA, originDec, ra1, dec1)
x2, y2 := lunarEclipseSVGTangentOffset(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 lunarEclipseSVGTangentOffset(originRA, originDec, targetRA, targetDec float64) (float64, float64) {
separation := lunarEclipseSVGAngularSeparation(originRA, originDec, targetRA, targetDec)
positionAngleRad := lunarEclipsePositionAngle(originRA, originDec, targetRA, targetDec) * math.Pi / 180
return separation * math.Sin(positionAngleRad), separation * math.Cos(positionAngleRad)
}
func lunarEclipseSVGAngularSeparation(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 lunarEclipseSVGLabelEcliptic(language string) string {
if language == lunarEclipseSVGLanguageEnglish {
return "Ecliptic"
}
return "黄道"
}
func writeLunarEclipseShadowLabels(
b *strings.Builder,
layout lunarEclipseSVGLayout,
diagram basic.LunarEclipseDiagramResult,
language string,
) {
penumbraLabel, umbraLabel := "地球半影", "地球本影"
if language == lunarEclipseSVGLanguageEnglish {
penumbraLabel, umbraLabel = "Earth's Penumbra", "Earth's Umbra"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700" text-anchor="middle">%s</text>`,
layout.cx, layout.cy-diagram.PenumbraRadius*layout.scale+28, html.EscapeString(penumbraLabel))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700" text-anchor="middle">%s</text>`,
layout.cx, layout.cy-diagram.UmbraRadius*layout.scale+22, html.EscapeString(umbraLabel))
}
func lunarEclipseSVGEventPoints(points []lunarEclipseSVGPoint) []lunarEclipseSVGPoint {
events := make([]lunarEclipseSVGPoint, 0, 7)
for _, point := range points {
for _, label := range lunarEclipseSVGPointLabels(point) {
event := point
event.Label = label
event.Labels = []string{label}
events = append(events, event)
}
}
return events
}
func lunarEclipseSVGPointLabels(point lunarEclipseSVGPoint) []string {
if len(point.Labels) > 0 {
return point.Labels
}
if point.Label == "" {
return nil
}
return []string{point.Label}
}
func writeLunarEclipseMoon(
b *strings.Builder,
point lunarEclipseSVGPoint,
diagram basic.LunarEclipseDiagramResult,
x, y, scale float64,
greatest bool,
) {
radius := diagram.MoonRadius * scale
opacity := 0.64
if greatest {
opacity = 0.9
}
fmt.Fprintf(b, `<g class="event-moon">`)
fmt.Fprintf(b, `<use href="#le-moon" x="%.3f" y="%.3f" width="%.3f" height="%.3f" opacity="%.2f"/>`,
x-radius, y-radius, radius*2, radius*2, opacity)
if lunarEclipseSVGDeepUmbra(point.LunarEclipseDiagramPoint, diagram) {
tintOpacity := 0.46
if greatest {
tintOpacity = 0.58
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="#d66f1f" opacity="%.2f"/>`,
x, y, radius, tintOpacity)
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#b9b9b9" stroke-width="0.8" opacity="0.9"/>`, x, y, radius)
b.WriteString(`</g>`)
}
func lunarEclipseSVGDeepUmbra(point basic.LunarEclipseDiagramPoint, diagram basic.LunarEclipseDiagramResult) bool {
return math.Hypot(point.X, point.Y) < diagram.UmbraRadius+diagram.MoonRadius*0.75
}
func writeLunarEclipseEventLabel(
b *strings.Builder,
label string,
x, y, cx, moonRadius float64,
language string,
) {
text := lunarEclipseSVGEventName(label, language)
dx := moonRadius*0.72 + 6
anchor := "start"
if x < cx {
dx = -dx
anchor = "end"
}
dy := -moonRadius*0.35 - 4
if label == "Greatest" {
dy = moonRadius + 15
anchor = "middle"
dx = 0
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#2554c7" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(text))
}
func lunarEclipseSVGEventName(label, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
switch label {
case "Greatest":
return "Greatest"
default:
return label
}
}
switch label {
case "P1":
return "P1 半影始"
case "U1":
return "U1 初亏"
case "U2":
return "U2 食既"
case "Greatest":
return "食甚"
case "U3":
return "U3 生光"
case "U4":
return "U4 复圆"
case "P4":
return "P4 半影终"
default:
return label
}
}
type lunarEclipseSVGContact struct {
label string
name string
time time.Time
angle float64
hasAngle bool
}
func writeLunarEclipseContacts(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
x, y float64,
) {
contacts := lunarEclipseSVGContacts(info, options.Language)
if len(contacts) == 0 {
return
}
title := lunarEclipseSVGContactsTitleText(options)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" 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 == lunarEclipseSVGLanguageEnglish {
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="#222" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
x, y+float64(index+1)*18, html.EscapeString(line))
}
}
func writeLunarEclipseFooter(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
layout lunarEclipseSVGLayout,
) {
_ = info
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-54, html.EscapeString(lunarEclipseSVGDirectionTextValue(options)))
note := lunarEclipseSVGFooterNoteText(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, layout.height-34, html.EscapeString(note))
}
func lunarEclipseSVGContacts(info LunarEclipseInfo, language string) []lunarEclipseSVGContact {
angles := lunarEclipseContactAngleMap(info.ContactPoints)
contacts := []lunarEclipseSVGContact{
lunarEclipseSVGContactFor("P1", lunarEclipseSVGContactName("P1", language), info.PenumbralStart, angles),
}
if info.HasPartial {
contacts = append(contacts, lunarEclipseSVGContactFor("U1", lunarEclipseSVGContactName("U1", language), info.PartialStart, angles))
}
if info.HasTotal {
contacts = append(contacts, lunarEclipseSVGContactFor("U2", lunarEclipseSVGContactName("U2", language), info.TotalStart, angles))
}
contacts = append(contacts, lunarEclipseSVGContact{label: "GE", name: lunarEclipseSVGContactName("Greatest", language), time: info.Maximum})
if info.HasTotal {
contacts = append(contacts, lunarEclipseSVGContactFor("U3", lunarEclipseSVGContactName("U3", language), info.TotalEnd, angles))
}
if info.HasPartial {
contacts = append(contacts, lunarEclipseSVGContactFor("U4", lunarEclipseSVGContactName("U4", language), info.PartialEnd, angles))
}
contacts = append(contacts, lunarEclipseSVGContactFor("P4", lunarEclipseSVGContactName("P4", language), info.PenumbralEnd, angles))
return contacts
}
func lunarEclipseSVGContactFor(
label, name string,
time time.Time,
angles map[string]float64,
) lunarEclipseSVGContact {
angle, ok := angles[label]
return lunarEclipseSVGContact{
label: label,
name: name,
time: time,
angle: angle,
hasAngle: ok,
}
}
func lunarEclipseContactAngleMap(points []LunarEclipseContactPoint) map[string]float64 {
angles := make(map[string]float64, len(points))
for _, point := range points {
angles[point.Label] = point.ContactPositionAngle
}
return angles
}
func lunarEclipseSVGContactName(label, language string) string {
if language == lunarEclipseSVGLanguageEnglish {
switch label {
case "P1":
return "Penumbral begins"
case "U1":
return "Partial begins"
case "U2":
return "Total begins"
case "Greatest":
return "Greatest"
case "U3":
return "Total ends"
case "U4":
return "Partial ends"
case "P4":
return "Penumbral ends"
default:
return label
}
}
switch label {
case "P1":
return "半影始"
case "U1":
return "初亏"
case "U2":
return "食既"
case "Greatest":
return "食甚"
case "U3":
return "生光"
case "U4":
return "复圆"
case "P4":
return "半影终"
default:
return label
}
}
func durationToDays(duration time.Duration) float64 {
if duration <= 0 {
return 0
}
return duration.Hours() / 24
}