feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) {
|
||||
diagrams, err := FindPlanetOccultationSVGs(
|
||||
time.Date(2024, time.August, 20, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2024, time.August, 22, 0, 0, 0, 0, time.UTC),
|
||||
moon.OccultationSaturn,
|
||||
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
|
||||
PlanetOccultationSVGOptions{Width: 720, Height: 520},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPlanetOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 1 {
|
||||
t.Fatalf("FindPlanetOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
|
||||
}
|
||||
diagram := diagrams[0]
|
||||
for _, want := range []string{
|
||||
`<svg`, `width="720"`, `height="520"`, "2024-08-21", "土星",
|
||||
"全球掩带", "外掩始", "全掩始", "掩甚", "全掩终", "外掩终", "外切锥面", "内切锥面",
|
||||
"部分掩带", "全掩带", `class="occultation-band"`, `class="total-occultation-band"`,
|
||||
`fill-rule="nonzero"`, `class="center-line"`,
|
||||
`class="occultation-time-marker"`,
|
||||
`class="event-marker event-total-start"`, `class="event-marker event-greatest"`,
|
||||
`class="event-marker event-total-end"`, `class="event-marker-leader"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("planetary SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, obsolete := range []string{
|
||||
`class="northern-limit"`, `class="southern-limit"`,
|
||||
`class="northern-total-limit"`, `class="southern-total-limit"`,
|
||||
} {
|
||||
if strings.Contains(diagram, obsolete) {
|
||||
t.Fatalf("planetary footprint sweep retains obsolete approximate line %q", obsolete)
|
||||
}
|
||||
}
|
||||
if strings.Contains(diagram, "月掩Saturn") {
|
||||
t.Fatal("Chinese planetary SVG title contains the English default target name")
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated planetary SVG is not valid XML: %v", err)
|
||||
}
|
||||
for _, pair := range [][2]string{{"外掩始", "全掩始"}, {"全掩终", "外掩终"}} {
|
||||
_, firstY := planetOccultationSVGTextPosition(t, diagram, pair[0])
|
||||
_, secondY := planetOccultationSVGTextPosition(t, diagram, pair[1])
|
||||
if math.Abs(firstY-secondY) < 13 {
|
||||
t.Fatalf("planetary SVG labels %q and %q overlap vertically at y %.3f / %.3f", pair[0], pair[1], firstY, secondY)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diagram, `class="event-marker-leader"`); got != 4 {
|
||||
t.Fatalf("planetary SVG close-contact leader count = %d, want 4", got)
|
||||
}
|
||||
if got := strings.Count(diagram, `fill="#087f8c" stroke="#ffffff" stroke-width="1.0"`); got < 2 {
|
||||
t.Fatalf("planetary SVG total-contact marker count = %d, want at least 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *testing.T) {
|
||||
path := samplePlanetOccultationPath()
|
||||
options := normalizeStarOccultationSVGOptions(PlanetOccultationSVGOptions{
|
||||
Width: 1100,
|
||||
SummaryText: strings.Repeat("A", 130),
|
||||
})
|
||||
options = planetOccultationSVGDefaults(path, options)
|
||||
lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options)
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("planetary SVG header lines = %d, want wrapped summary plus greatest line", len(lines))
|
||||
}
|
||||
if width := starOccultationTextWidth(lines[0], 14); width > float64(options.Width)-80 {
|
||||
t.Fatalf("planetary SVG first header line width = %.1f, canvas allowance %.1f", width, float64(options.Width)-80)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGUsesLargeGlobalMapDefault(t *testing.T) {
|
||||
diagram, err := PlanetOccultationPathSVG(samplePlanetOccultationPath(), PlanetOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`width="1200" height="800"`,
|
||||
`preserveAspectRatio="xMidYMid meet"`,
|
||||
`style="max-width:100%;height:auto;display:block"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("planetary SVG missing large responsive canvas marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planetOccultationSVGTextPosition(t *testing.T, diagram, label string) (float64, float64) {
|
||||
t.Helper()
|
||||
decoder := xml.NewDecoder(strings.NewReader(diagram))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("decode planetary SVG: %v", err)
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local != "text" {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if err := decoder.DecodeElement(&text, &start); err != nil {
|
||||
t.Fatalf("decode planetary SVG text: %v", err)
|
||||
}
|
||||
if text != label {
|
||||
continue
|
||||
}
|
||||
values := map[string]float64{}
|
||||
for _, attribute := range start.Attr {
|
||||
if attribute.Name.Local != "x" && attribute.Name.Local != "y" {
|
||||
continue
|
||||
}
|
||||
value, parseErr := strconv.ParseFloat(attribute.Value, 64)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("parse %s coordinate %q: %v", attribute.Name.Local, attribute.Value, parseErr)
|
||||
}
|
||||
values[attribute.Name.Local] = value
|
||||
}
|
||||
return values["x"], values["y"]
|
||||
}
|
||||
t.Fatalf("planetary SVG label %q not found", label)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGRejectsInvalidCanvasWithPlanetError(t *testing.T) {
|
||||
path := samplePlanetOccultationPath()
|
||||
_, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1})
|
||||
if !errors.Is(err, ErrInvalidPlanetOccultationSVGOptions) {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationSVGOptions", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGEnglishAndCustomText(t *testing.T) {
|
||||
diagram, err := PlanetOccultationPathSVG(samplePlanetOccultationPath(), PlanetOccultationSVGOptions{
|
||||
Language: "en",
|
||||
Location: time.UTC,
|
||||
Title: "Custom planetary occultation",
|
||||
SummaryText: "Custom summary",
|
||||
GreatestText: "Custom greatest",
|
||||
FooterNote: "Custom footer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Custom planetary occultation", "Custom summary", "Custom greatest", "Custom footer",
|
||||
"Partial begins", "Total begins", "Greatest", "Total ends", "Partial ends",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("English planetary SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("English planetary SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGRejectsInvalidPath(t *testing.T) {
|
||||
_, err := PlanetOccultationPathSVG(moon.PlanetOccultationPath{Planet: moon.OccultationSaturn}, PlanetOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
|
||||
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err)
|
||||
}
|
||||
path := samplePlanetOccultationPath()
|
||||
path.TotalComplete = false
|
||||
_, err = PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
|
||||
t.Fatalf("incomplete total band error = %v, want ErrInvalidPlanetOccultationPath", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanetOccultationPathSVGSupportsPartialOnlyGlobalBand(t *testing.T) {
|
||||
path := samplePlanetOccultationPath()
|
||||
path.HasTotalBand = false
|
||||
path.TotalStart = moon.OccultationPathPoint{}
|
||||
path.TotalEnd = moon.OccultationPathPoint{}
|
||||
path.TotalComplete = false
|
||||
path.NorthernTotalLimit = nil
|
||||
path.SouthernTotalLimit = nil
|
||||
path.GreatestTotalWidthKM = 0
|
||||
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanetOccultationPathSVG() partial-only error = %v", err)
|
||||
}
|
||||
if !strings.Contains(diagram, "部分掩带") || strings.Contains(diagram, `class="total-occultation-band"`) ||
|
||||
strings.Contains(diagram, "全掩始") || strings.Contains(diagram, "全掩终") {
|
||||
t.Fatal("partial-only planetary SVG contains inconsistent total-band content")
|
||||
}
|
||||
if got := strings.Count(diagram, `class="event-marker `); got != 3 {
|
||||
t.Fatalf("partial-only global marker count = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func samplePlanetOccultationPath() moon.PlanetOccultationPath {
|
||||
starPath := sampleStarOccultationPath()
|
||||
totalStart := starOccultationInterpolatePathPoint(
|
||||
starPath.CenterLine[0], starPath.CenterLine[1], 0.5, 167.5,
|
||||
)
|
||||
totalEnd := starOccultationInterpolatePathPoint(
|
||||
starPath.CenterLine[2], starPath.CenterLine[3], 0.5, -167.5,
|
||||
)
|
||||
northernTotal := []moon.OccultationPathPoint{totalStart}
|
||||
southernTotal := []moon.OccultationPathPoint{totalStart}
|
||||
for _, point := range starPath.CenterLine[1:3] {
|
||||
north, south := point, point
|
||||
north.Latitude += 10
|
||||
south.Latitude -= 10
|
||||
north.WidthKM = 0
|
||||
south.WidthKM = 0
|
||||
northernTotal = append(northernTotal, north)
|
||||
southernTotal = append(southernTotal, south)
|
||||
}
|
||||
northernTotal = append(northernTotal, totalEnd)
|
||||
southernTotal = append(southernTotal, totalEnd)
|
||||
return moon.PlanetOccultationPath{
|
||||
Planet: moon.OccultationSaturn,
|
||||
TargetID: "Saturn",
|
||||
Start: starPath.Start,
|
||||
Greatest: starPath.Greatest,
|
||||
End: starPath.End,
|
||||
Complete: true,
|
||||
CenterLine: starPath.CenterLine,
|
||||
NorthernLimit: starPath.NorthernLimit,
|
||||
SouthernLimit: starPath.SouthernLimit,
|
||||
HasTotalBand: true,
|
||||
TotalStart: totalStart,
|
||||
TotalEnd: totalEnd,
|
||||
TotalComplete: true,
|
||||
NorthernTotalLimit: northernTotal,
|
||||
SouthernTotalLimit: southernTotal,
|
||||
GreatestTotalWidthKM: 3100,
|
||||
Step: starPath.Step,
|
||||
TargetSpacingKM: starPath.TargetSpacingKM,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user