feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user