Files
astro/moon/svg/occultation_svg_support_test.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

404 lines
11 KiB
Go

package svg
import (
"encoding/xml"
"io"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"b612.me/astro/moon"
)
// 本文件提供解析 SVG 产物、按保守字宽估算文本占位的测试工具。
type occultationSVGText struct {
class string
parent string
parentClass string
value string
fill string
x, y float64
fontSize float64
anchor string
}
func occultationSVGTexts(t *testing.T, diagram string) []occultationSVGText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
stack := []string{}
classes := []string{}
texts := []occultationSVGText{}
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode SVG: %v", err)
}
switch element := token.(type) {
case xml.StartElement:
stack = append(stack, element.Name.Local)
class := ""
for _, attribute := range element.Attr {
if attribute.Name.Local == "class" {
class = attribute.Value
}
}
classes = append(classes, class)
if element.Name.Local != "text" {
continue
}
text := occultationSVGText{class: class, fontSize: 10}
if len(classes) >= 2 {
text.parent = stack[len(stack)-2]
text.parentClass = classes[len(classes)-2]
}
for _, attribute := range element.Attr {
switch attribute.Name.Local {
case "x":
text.x, _ = strconv.ParseFloat(attribute.Value, 64)
case "y":
text.y, _ = strconv.ParseFloat(attribute.Value, 64)
case "font-size":
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "text-anchor":
text.anchor = attribute.Value
case "fill":
text.fill = attribute.Value
}
}
texts = append(texts, text)
case xml.EndElement:
if len(stack) == 0 {
t.Fatal("unbalanced SVG element")
}
stack = stack[:len(stack)-1]
classes = classes[:len(classes)-1]
case xml.CharData:
if len(stack) == 0 || stack[len(stack)-1] != "text" || len(texts) == 0 {
continue
}
texts[len(texts)-1].value += string(element)
}
}
return texts
}
// occultationSVGTextBox 用偏大的字宽估算占位,避免真实字体比测试度量更宽时漏判重叠。
func occultationSVGTextBox(text occultationSVGText) [4]float64 {
fontSize := text.fontSize
if fontSize <= 0 {
fontSize = 10
}
units := 0.0
for _, current := range text.value {
if current < 0x80 {
units += 0.62
continue
}
units++
units += 0.05
}
width := units * fontSize
left := text.x
switch text.anchor {
case "middle":
left = text.x - width/2
case "end":
left = text.x - width
}
return [4]float64{left, text.y - fontSize*0.85, left + width, text.y + fontSize*0.3}
}
func occultationSVGTextsOutsideCanvas(texts []occultationSVGText, width, height float64) []string {
outside := []string{}
for _, text := range texts {
box := occultationSVGTextBox(text)
if box[0] < 0 || box[2] > width || box[1] < 0 || box[3] > height {
outside = append(outside, text.value)
}
}
return outside
}
func occultationSVGTextOverlapPairs(texts []occultationSVGText) int {
pairs := 0
for first := 0; first < len(texts); first++ {
for second := first + 1; second < len(texts); second++ {
a, b := occultationSVGTextBox(texts[first]), occultationSVGTextBox(texts[second])
if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] {
pairs++
}
}
}
return pairs
}
// occultationSVGLabelTexts 取三族地图标签:等时线标注、中心线时刻标注、事件标记。
func occultationSVGLabelTexts(texts []occultationSVGText) []occultationSVGText {
labels := []occultationSVGText{}
for _, text := range texts {
if strings.Contains(text.class, "greatest-time-isoline-label") ||
strings.Contains(text.parentClass, "occultation-time-marker") ||
strings.Contains(text.parentClass, "event-marker") {
labels = append(labels, text)
}
}
return labels
}
// occultationSVGDetailedPanelRows 解析详细版式数据块:返回"块标题 → 行标签 → 值"。
func occultationSVGDetailedPanelRows(t *testing.T, diagram string) map[string]map[string]string {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
panels := map[string]map[string]string{}
inPanel := false
texts := []occultationSVGText{}
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode SVG: %v", err)
}
switch element := token.(type) {
case xml.StartElement:
if element.Name.Local == "g" {
for _, attribute := range element.Attr {
if attribute.Name.Local == "class" && attribute.Value == "occultation-detailed-panel" {
inPanel, texts = true, nil
}
}
continue
}
if !inPanel || element.Name.Local != "text" {
continue
}
text := occultationSVGText{}
for _, attribute := range element.Attr {
if attribute.Name.Local == "text-anchor" {
text.anchor = attribute.Value
}
}
texts = append(texts, text)
case xml.EndElement:
if !inPanel || element.Name.Local != "g" {
continue
}
inPanel = false
rows := map[string]string{}
title := ""
for index := 0; index < len(texts); {
if texts[index].anchor == "end" {
index++
continue
}
if index+1 < len(texts) && texts[index+1].anchor == "end" {
rows[texts[index].value] = texts[index+1].value
index += 2
continue
}
if title == "" {
title = texts[index].value
}
index++
}
panels[title] = rows
case xml.CharData:
if inPanel && len(texts) > 0 {
texts[len(texts)-1].value += string(element)
}
}
}
return panels
}
func occultationSVGPanelValue(t *testing.T, panels map[string]map[string]string, title, label string) string {
t.Helper()
rows, ok := panels[title]
if !ok {
titles := make([]string, 0, len(panels))
for current := range panels {
titles = append(titles, current)
}
sort.Strings(titles)
t.Fatalf("detailed SVG is missing panel %q, have %v", title, titles)
}
value, ok := rows[label]
if !ok {
labels := make([]string, 0, len(rows))
for current := range rows {
labels = append(labels, current)
}
sort.Strings(labels)
t.Fatalf("panel %q is missing row %q, have %v", title, label, labels)
}
return value
}
// occultationSVGNumericPrefix 取 "69.2 s" / "-5.86°" 这类文本开头的数值。
func occultationSVGNumericPrefix(t *testing.T, value string) float64 {
t.Helper()
trimmed := strings.TrimSpace(value)
end := 0
for end < len(trimmed) && (trimmed[end] == '+' || trimmed[end] == '-' || trimmed[end] == '.' ||
(trimmed[end] >= '0' && trimmed[end] <= '9')) {
end++
}
parsed, err := strconv.ParseFloat(trimmed[:end], 64)
if err != nil {
t.Fatalf("parse number from %q: %v", value, err)
}
return parsed
}
// occultationSVGSexagesimal 解析 "±DD°MM'SS.S\"" 与 "HHhMMmSS.Ss" 两种六十进制文本,返回度。
func occultationSVGSexagesimal(t *testing.T, value string) float64 {
t.Helper()
fields := strings.FieldsFunc(value, func(current rune) bool {
return current != '.' && (current < '0' || current > '9')
})
if len(fields) != 3 {
t.Fatalf("parse sexagesimal %q: got %d fields", value, len(fields))
}
numbers := make([]float64, 3)
for index, field := range fields {
parsed, err := strconv.ParseFloat(field, 64)
if err != nil {
t.Fatalf("parse sexagesimal field %q of %q: %v", field, value, err)
}
numbers[index] = parsed
}
degrees := numbers[0] + numbers[1]/60 + numbers[2]/3600
if strings.HasSuffix(strings.TrimSpace(value), "s") {
degrees *= 15
}
if strings.HasPrefix(strings.TrimSpace(value), "-") {
degrees = -degrees
}
return degrees
}
// occultationSVGPanelRects 取详细版式数据块自身的矩形。
func occultationSVGPanelRects(t *testing.T, diagram string) [][4]float64 {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
rects := [][4]float64{}
inPanel := false
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode SVG: %v", err)
}
switch element := token.(type) {
case xml.StartElement:
if element.Name.Local == "g" {
inPanel = false
for _, attribute := range element.Attr {
if attribute.Name.Local == "class" && attribute.Value == "occultation-detailed-panel" {
inPanel = true
}
}
continue
}
if !inPanel || element.Name.Local != "rect" {
continue
}
rect := [4]float64{}
for _, attribute := range element.Attr {
value, parseErr := strconv.ParseFloat(attribute.Value, 64)
if parseErr != nil {
continue
}
switch attribute.Name.Local {
case "x":
rect[0] = value
case "y":
rect[1] = value
case "width":
rect[2] = value
case "height":
rect[3] = value
}
}
rects = append(rects, rect)
case xml.EndElement:
if element.Name.Local == "g" {
inPanel = false
}
}
}
return rects
}
var (
occultationTestStarOnce sync.Once
occultationTestStarEvent moon.StarOccultationPath
occultationTestSaturnOnce sync.Once
occultationTestSaturnData moon.PlanetOccultationPath
occultationTestMarsOnce sync.Once
occultationTestMarsData moon.PlanetOccultationPath
)
// occultationTestStarPath 取一个带等时线的真实恒星掩事件。
func occultationTestStarPath(t *testing.T) moon.StarOccultationPath {
t.Helper()
occultationTestStarOnce.Do(func() {
location := time.FixedZone("CST", 8*3600)
paths, err := moon.FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799StarCoordinate(),
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, GreatestTimeStep: 30 * time.Minute},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
occultationTestStarEvent = paths[0]
})
return occultationTestStarEvent
}
// occultationTestSaturnPath 取一个关闭密集足迹、带等时线的真实行星掩事件。
func occultationTestSaturnPath(t *testing.T) moon.PlanetOccultationPath {
t.Helper()
occultationTestSaturnOnce.Do(func() {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
occultationTestSaturnData = paths[0]
})
return occultationTestSaturnData
}
// occultationTestMarsPath 取一个同时带偏掩带与全掩带的真实行星掩事件。
func occultationTestMarsPath(t *testing.T) moon.PlanetOccultationPath {
t.Helper()
occultationTestMarsOnce.Do(func() {
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationMars,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute, GreatestTimeStep: 30 * time.Minute,
})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
occultationTestMarsData = paths[0]
})
return occultationTestMarsData
}