feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/svgchart"
|
||||
)
|
||||
|
||||
// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,站心图的页脚各行必须等行距。
|
||||
// 这两条都是排版不变量,与具体事件无关,因此在这里按图种各取一个样本固定下来。
|
||||
|
||||
const chartBottomMarginMinimum = 6.0
|
||||
|
||||
type chartTextElement struct {
|
||||
x float64
|
||||
y float64
|
||||
fontSize float64
|
||||
fill string
|
||||
value string
|
||||
}
|
||||
|
||||
func chartTextElements(t *testing.T, document string) []chartTextElement {
|
||||
t.Helper()
|
||||
decoder := xml.NewDecoder(strings.NewReader(document))
|
||||
elements := []chartTextElement{}
|
||||
stack := []string{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("chart is not valid XML: %v", err)
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
stack = append(stack, typed.Name.Local)
|
||||
if typed.Name.Local != "text" {
|
||||
continue
|
||||
}
|
||||
element := chartTextElement{}
|
||||
for _, attribute := range typed.Attr {
|
||||
switch attribute.Name.Local {
|
||||
case "x":
|
||||
element.x, _ = strconv.ParseFloat(attribute.Value, 64)
|
||||
case "y":
|
||||
element.y, _ = strconv.ParseFloat(attribute.Value, 64)
|
||||
case "font-size":
|
||||
element.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
|
||||
case "fill":
|
||||
element.fill = attribute.Value
|
||||
}
|
||||
}
|
||||
elements = append(elements, element)
|
||||
case xml.EndElement:
|
||||
stack = stack[:len(stack)-1]
|
||||
case xml.CharData:
|
||||
if len(stack) > 0 && stack[len(stack)-1] == "text" && len(elements) > 0 {
|
||||
elements[len(elements)-1].value += string(typed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
func chartBottomMarginViolations(t *testing.T, document string, height int) []string {
|
||||
t.Helper()
|
||||
violations := []string{}
|
||||
for _, element := range chartTextElements(t, document) {
|
||||
if strings.TrimSpace(element.value) == "" {
|
||||
continue
|
||||
}
|
||||
_, below := svgchart.EstimatedTextExtents(element.fontSize)
|
||||
if margin := float64(height) - (element.y + below); margin < chartBottomMarginMinimum {
|
||||
violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", element.value, margin, chartBottomMarginMinimum))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func chartFooterCases(t *testing.T) []struct {
|
||||
name string
|
||||
height int
|
||||
document string
|
||||
} {
|
||||
t.Helper()
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
|
||||
long := footerFitLongText("footer ")
|
||||
cases := []struct {
|
||||
name string
|
||||
height int
|
||||
document string
|
||||
}{}
|
||||
add := func(name string, height int, document string, ok bool) {
|
||||
if !ok {
|
||||
t.Fatalf("%s: chart missing", name)
|
||||
}
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
height int
|
||||
document string
|
||||
}{name, height, document})
|
||||
}
|
||||
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
|
||||
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
|
||||
add("站心日食", 692, solar, ok)
|
||||
solarLong, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
|
||||
LocalSolarEclipseSVGOptions{Width: 640, Height: 520, Location: cst, FooterNote: long})
|
||||
add("站心日食 窄画布长说明", 520, solarLong, ok)
|
||||
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
|
||||
add("站心月食", 640, lunar, ok)
|
||||
lunarLong, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 640, Height: 520, Language: "zh", Location: cst, FooterNote: long})
|
||||
add("站心月食 窄画布长说明", 520, lunarLong, ok)
|
||||
detailed, ok := LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1000, Language: "zh", Location: cst, FooterNote: long})
|
||||
add("月食详图 长说明", 1000, detailed, ok)
|
||||
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
|
||||
add("月食地图 长说明", 640, mapChart, ok)
|
||||
solarMap, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
|
||||
SolarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC, FooterNote: long})
|
||||
add("日食地图 长说明", 640, solarMap, ok)
|
||||
return cases
|
||||
}
|
||||
|
||||
func TestChartTextKeepsBottomMargin(t *testing.T) {
|
||||
for _, tc := range chartFooterCases(t) {
|
||||
if violations := chartBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 {
|
||||
t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiagramFooterLinesAreEvenlySpaced(t *testing.T) {
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
cases := []struct {
|
||||
name string
|
||||
height int
|
||||
document string
|
||||
}{}
|
||||
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
|
||||
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
|
||||
if !ok {
|
||||
t.Fatal("站心日食:缺图")
|
||||
}
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
height int
|
||||
document string
|
||||
}{"站心日食", 692, solar})
|
||||
lunar, ok := LunarEclipseSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
|
||||
if !ok {
|
||||
t.Fatal("站心月食:缺图")
|
||||
}
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
height int
|
||||
document string
|
||||
}{"站心月食", 640, lunar})
|
||||
|
||||
for _, tc := range cases {
|
||||
baselines := []float64{}
|
||||
for _, element := range chartTextElements(t, tc.document) {
|
||||
if element.fontSize != 12 || (element.fill != "#333" && element.fill != "#555") {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(element.value) == "" {
|
||||
continue
|
||||
}
|
||||
baselines = append(baselines, element.y)
|
||||
}
|
||||
if len(baselines) < 3 {
|
||||
t.Fatalf("%s:页脚只有 %d 行,样本太少", tc.name, len(baselines))
|
||||
}
|
||||
sort.Float64s(baselines)
|
||||
step := svgchart.FooterLineHeight(12)
|
||||
for index := 1; index < len(baselines); index++ {
|
||||
if gap := baselines[index] - baselines[index-1]; math.Abs(gap-step) > 0.01 {
|
||||
t.Fatalf("%s:页脚第 %d 行与上一行间距 %.3f,应为 %.1f(基线 %v)", tc.name, index+1, gap, step, baselines)
|
||||
}
|
||||
}
|
||||
if margin := float64(tc.height) - baselines[len(baselines)-1]; math.Abs(margin-localDiagramFooterBottomPadding) > 0.01 {
|
||||
t.Fatalf("%s:页脚末行基线距底边 %.3f,应为 %.1f(须落在图框内)", tc.name, margin, localDiagramFooterBottomPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页脚时标声明契约:调用方给超长自定义说明时,时标声明仍须留在页脚里(截断可以吃掉说明,不能吃掉声明)。
|
||||
func TestChartFooterKeepsTimeScaleDeclaration(t *testing.T) {
|
||||
long := footerFitLongText("scale ")
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
|
||||
cases := []struct {
|
||||
name string
|
||||
document string
|
||||
ok bool
|
||||
}{}
|
||||
solar, ok := LocalSolarEclipseSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 118.0894, 24.4798, 0,
|
||||
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Location: cst, FooterNote: long})
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
document string
|
||||
ok bool
|
||||
}{"站心日食", solar, ok})
|
||||
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
document string
|
||||
ok bool
|
||||
}{"站心月食", lunar, ok})
|
||||
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
|
||||
cases = append(cases, struct {
|
||||
name string
|
||||
document string
|
||||
ok bool
|
||||
}{"月食地图", mapChart, ok})
|
||||
for _, tc := range cases {
|
||||
if !tc.ok {
|
||||
t.Fatalf("%s:缺图", tc.name)
|
||||
}
|
||||
if !strings.Contains(tc.document, "显示时区") && !strings.Contains(tc.document, "图中时刻为 UTC") {
|
||||
t.Errorf("%s:超长说明把时标声明挤掉了", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user