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
@@ -0,0 +1,176 @@
package svg
import (
"encoding/xml"
"io"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 本地月掩页脚契约:方向说明与补充说明(含时标声明)都排在可用宽度内,同一块各行宽度接近,
// 不留孤立短尾行,且显示时区的偏移只写一次。
const (
localOccultationFooterDirectionFill = "#4f595b"
localOccultationFooterNoteFill = "#747c7d"
)
type localOccultationFooterText struct {
fill string
fontSize float64
value string
}
func localOccultationFooterTexts(t *testing.T, diagram string) []localOccultationFooterText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
texts := []localOccultationFooterText{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("decode SVG: %v", err)
}
element, ok := token.(xml.StartElement)
if !ok || element.Name.Local != "text" {
continue
}
text := localOccultationFooterText{}
for _, attribute := range element.Attr {
switch attribute.Name.Local {
case "fill":
text.fill = attribute.Value
case "font-size":
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
}
}
if text.fill != localOccultationFooterDirectionFill && text.fill != localOccultationFooterNoteFill {
continue
}
var content string
if err := decoder.DecodeElement(&content, &element); err != nil {
t.Fatalf("decode text element: %v", err)
}
text.value = content
texts = append(texts, text)
}
return texts
}
func localOccultationFooterWidths(lines []localOccultationFooterText) ([]string, []float64) {
values := make([]string, 0, len(lines))
widths := make([]float64, 0, len(lines))
for _, line := range lines {
values = append(values, line.value)
widths = append(widths, svgchart.EstimatedTextWidth(line.value, line.fontSize))
}
return values, widths
}
func localOccultationFooterSpread(lines []localOccultationFooterText) float64 {
_, widths := localOccultationFooterWidths(lines)
minimum, maximum := widths[0], widths[0]
for _, width := range widths {
minimum = math.Min(minimum, width)
maximum = math.Max(maximum, width)
}
return maximum / minimum
}
func localOccultationFooterGroup(lines []localOccultationFooterText, fill string) []localOccultationFooterText {
group := []localOccultationFooterText{}
for _, line := range lines {
if line.fill == fill {
group = append(group, line)
}
}
return group
}
// 说明块各行的最大最小宽度比不得超过 15%,整块最短行不得短于最长行的 60%(旧实现里时标声明独占的短尾行只有 25%~47%)。
func TestLocalOccultationFooterLinesAreEven(t *testing.T) {
location := time.FixedZone("UTC+08:00", 8*3600)
const width, height = 920, 720
for _, language := range []string{"zh", "en"} {
star, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Language: language, Location: location, Width: width, Height: height,
})
if err != nil {
t.Fatalf("star %s: %v", language, err)
}
planet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{
Language: language, Location: location, Width: width, Height: height,
})
if err != nil {
t.Fatalf("planet %s: %v", language, err)
}
for name, diagram := range map[string]string{"star": star, "planet": planet} {
lines := localOccultationFooterTexts(t, diagram)
if len(lines) < 2 {
t.Fatalf("%s %s: footer lines = %#v, want the direction and note blocks", name, language, lines)
}
values, widths := localOccultationFooterWidths(lines)
for index, line := range lines {
if limit := float64(width) - 80; widths[index] > limit {
t.Fatalf("%s %s: footer line %q width %.1f exceeds %.1f", name, language, line.value, widths[index], limit)
}
if strings.Contains(line.value, "…") {
t.Fatalf("%s %s: default footer text is truncated: %q", name, language, line.value)
}
}
note := localOccultationFooterGroup(lines, localOccultationFooterNoteFill)
if len(note) == 0 {
t.Fatalf("%s %s: missing the footer note block", name, language)
}
// 说明块与方向说明行字号不同(fs9 / fs10),故按块判 1.15;整块只兜"孤尾短行"(1.60)。
if spread := localOccultationFooterSpread(note); spread > 1.15 {
t.Fatalf("%s %s: note block spread = %.3f, want <= 1.15 (%q)", name, language, spread, values)
}
if spread := localOccultationFooterSpread(lines); spread > 1.60 {
t.Fatalf("%s %s: footer spread = %.3f, want no orphan short line (%q)", name, language, spread, values)
}
joined := strings.Join(values, " ")
if zone := "显示时区"; language == "en" {
if !strings.Contains(joined, "shown in") {
t.Fatalf("%s %s: footer note lost the time zone declaration: %q", name, language, joined)
}
} else if !strings.Contains(joined, zone) {
t.Fatalf("%s %s: footer note lost the time zone declaration: %q", name, language, joined)
}
}
}
}
// 偏移型时区名(UTC+08:00)不得让时标声明写成“显示时区 UTC+08:00,UTC+08:00”。
func TestLocalOccultationFooterNamesTheZoneOnce(t *testing.T) {
location := time.FixedZone("UTC+08:00", 8*3600)
for _, language := range []string{"zh", "en"} {
star, err := LocalStarOccultationSVG(localHR4799Occultation(t), hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Language: language, Location: location,
})
if err != nil {
t.Fatalf("star %s: %v", language, err)
}
planet, err := LocalPlanetOccultationSVG(localSaturnOccultation(t), LocalPlanetOccultationSVGOptions{
Language: language, Location: location,
})
if err != nil {
t.Fatalf("planet %s: %v", language, err)
}
for name, diagram := range map[string]string{"star": star, "planet": planet} {
note := localOccultationFooterGroup(localOccultationFooterTexts(t, diagram), localOccultationFooterNoteFill)
values, _ := localOccultationFooterWidths(note)
joined := strings.Join(values, " ")
if got := strings.Count(joined, "UTC+08:00"); got != 1 {
t.Fatalf("%s %s: footer note names UTC+08:00 %d times, want once: %q", name, language, got, joined)
}
}
}
}