16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
404 lines
11 KiB
Go
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
|
|
}
|