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
+91 -18
View File
@@ -1,12 +1,14 @@
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"
@@ -64,6 +66,11 @@ type LocalSolarEclipseSVGOptions 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),并在图注里声明尺度。
// 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
@@ -106,6 +113,12 @@ func localSolarEclipseSVG(
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),
@@ -117,7 +130,7 @@ func localSolarEclipseSVG(
if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 {
return "", false
}
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location)
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location, options.TimeScale)
if finder != nil {
coreInfo := finder(info.GreatestEclipse, lon, lat, height)
info.HasSaros = coreInfo.HasSaros
@@ -153,6 +166,14 @@ func renderLocalSolarEclipseSVG(
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
@@ -160,7 +181,8 @@ func renderLocalSolarEclipseSVG(
if diagramRight-diagramLeft < width*0.48 {
diagramRight = width - margin
}
footerHeight := 72.0
// 页脚与图区之间留一点空,页脚高度按实际行数算出来,说明文字再多也不会顶到画布底边。
footerHeight := footerOccupied + 10
stageHeight := math.Max(160, math.Min(210, height*0.27))
stageTop := height - stageHeight - footerHeight
if stageTop < headerBottom+160 {
@@ -250,22 +272,13 @@ func renderLocalSolarEclipseSVG(
}
writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight)
direction := localSolarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
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(direction))
// 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。
lines := []string{localSolarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
maxWidth := width - 80
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12,
svgchart.BaselineLineLimit(12, 15, height-34, height-4))
}
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, height-34+float64(index)*15, html.EscapeString(line))
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>`)
@@ -359,6 +372,10 @@ func localSolarEclipseSVGDirectionTextValue(options LocalSolarEclipseSVGOptions)
}
func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) string {
return localSolarEclipseSVGFooterNoteTextBase(options)
}
func localSolarEclipseSVGFooterNoteTextBase(options LocalSolarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -368,6 +385,62 @@ func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) str
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
}