feat: 完善时标与天象几何计算并扩展输出接口

- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
This commit is contained in:
2026-09-23 18:55:12 +08:00
parent 1f31a9b5b5
commit 16c62a97d5
503 changed files with 33290 additions and 9471 deletions
+68 -26
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
@@ -66,6 +68,11 @@ type LunarEclipseSVGOptions struct {
// 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)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
}
type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
@@ -149,6 +156,12 @@ func lunarEclipseSVG(
calculator lunarEclipseSVGCalculator,
finder lunarEclipseSVGFinder,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
@@ -162,7 +175,7 @@ func lunarEclipseSVG(
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
return renderLunarEclipseSVG(info, diagram, options), true
return renderLunarEclipseSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, diagram, options), true
}
func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions {
@@ -319,11 +332,21 @@ func writeLunarEclipseEventLabels(
func renderLunarEclipseSVG(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
) string {
headerTexts := lunarEclipseSVGHeaderTexts(info, options)
layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts))
headerTexts := lunarEclipseSVGHeaderTexts(info, geometry, options)
headerBottom := lunarEclipseSVGHeaderBottom(headerTexts)
direction := lunarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, float64(options.Width)-80, 12)
}
footerLines := lunarEclipseSVGFooterLines(direction, info, options, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), float64(options.Height), 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
// 页脚高度按实际行数算,再留一点与图区的空,说明文字不会顶到画布底边。
layout := lunarEclipseSVGLayoutFor(diagram, options, headerBottom, footerOccupied+10)
title := lunarEclipseSVGTitleText(info, options)
var b strings.Builder
@@ -351,7 +374,7 @@ func renderLunarEclipseSVG(
writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels)
writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY)
writeLunarEclipseFooter(&b, info, options, layout)
writeLunarEclipseFooter(&b, footerLines, footerBaselines)
b.WriteString(`</svg>`)
return b.String()
@@ -360,7 +383,7 @@ func renderLunarEclipseSVG(
func lunarEclipseSVGLayoutFor(
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
headerBottom float64,
headerBottom, footerHeight float64,
) lunarEclipseSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
@@ -372,7 +395,7 @@ func lunarEclipseSVGLayoutFor(
diagramRight = width - margin
}
topReserved := math.Max(166, headerBottom+24)
bottomReserved := 82.0
bottomReserved := footerHeight
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) {
@@ -413,11 +436,15 @@ func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOpti
return lunarEclipseSVGTitle(info, options.Language)
}
func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string {
func lunarEclipseSVGHeaderTexts(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
options LunarEclipseSVGOptions,
) []string {
lines := []string{
lunarEclipseSVGSummaryText(info, options),
lunarEclipseSVGMaximumTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(geometry, options),
lunarEclipseSVGDurationTextValue(info, options),
lunarEclipseSVGMetaTextValue(info, options),
}
@@ -484,6 +511,10 @@ func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string {
}
func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string {
return lunarEclipseSVGFooterNoteTextBase(options)
}
func lunarEclipseSVGFooterNoteTextBase(options LunarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -997,29 +1028,40 @@ func writeLunarEclipseContacts(
}
}
func writeLunarEclipseFooter(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
layout lunarEclipseSVGLayout,
) {
_ = info
direction := lunarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, layout.width-80, 12)
// lunarEclipseSVGFooterLines 返回方向行在前的页脚各行:行数上限由图区下限与画布高度决定。
func lunarEclipseSVGFooterLines(direction string, info LunarEclipseInfo, options LunarEclipseSVGOptions, headerBottom float64) []string {
const (
fontSize = 12.0
diagramFloor = 160.0
stageGap = 10.0
)
height := float64(options.Height)
lineHeight := svgchart.FooterLineHeight(fontSize)
padding := localDiagramFooterBottomPadding
maxLines := svgchart.TextLineLimit(fontSize, lineHeight, height-headerBottom-diagramFloor-stageGap-padding-lineHeight)
if cap := int(height * 0.20 / lineHeight); maxLines > cap {
maxLines = cap
}
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(direction))
// 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。
if maxLines < 1 {
maxLines = 1
}
note := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
lines := []string{lunarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
maxWidth := layout.width - 80
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12,
svgchart.BaselineLineLimit(12, 15, layout.height-34, layout.height-4))
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-80, fontSize)
}
lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-80, fontSize, maxLines)
return append([]string{direction}, lines...)
}
func writeLunarEclipseFooter(b *strings.Builder, lines []string, baselines []float64) {
for index, line := range lines {
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+float64(index)*15, html.EscapeString(line))
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, baselines[index], fill, html.EscapeString(line))
}
}