feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/internal/svgmap"
|
||||
"b612.me/astro/moon"
|
||||
)
|
||||
|
||||
func TestFindStarOccultationSVGsHR4799(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
diagrams, err := FindStarOccultationSVGs(
|
||||
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},
|
||||
StarOccultationSVGOptions{Width: 720, Height: 520},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 1 {
|
||||
t.Fatalf("FindStarOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
|
||||
}
|
||||
diagram := diagrams[0]
|
||||
for _, want := range []string{
|
||||
`<svg`, `width="720"`, `height="520"`, "2025-06-05", "HR 4799",
|
||||
"全球掩带", "掩始", "掩甚", "掩终", "掩带宽", "UTC+8",
|
||||
`class="occultation-band"`, `class="center-line"`, `class="northern-limit"`,
|
||||
`class="southern-limit"`, `class="event-marker event-greatest"`, `class="land"`,
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diagram, `class="occultation-band"`); got != 2 {
|
||||
t.Fatalf("HR 4799 occultation-band segment count = %d, want two antimeridian-clipped fragments", got)
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) {
|
||||
layout := starOccultationSVGLayoutFor(
|
||||
StarOccultationSVGOptions{Width: 900, Height: 760},
|
||||
110,
|
||||
svgmap.ProjectionNorthPolar,
|
||||
)
|
||||
legendY := layout.mapY + layout.mapHeight + 30
|
||||
if gap := layout.footerY - legendY; gap < 24 {
|
||||
t.Fatalf("polar legend/footer gap = %.1f px, want at least 24 px", gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGEnglishAndCustomText(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.TargetID = "Alpha < Beta & Gamma"
|
||||
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{
|
||||
Language: "en",
|
||||
Location: time.UTC,
|
||||
Title: "Custom <occultation> & title",
|
||||
SummaryText: "Custom summary",
|
||||
GreatestText: "Custom greatest",
|
||||
MapTitle: "Custom map",
|
||||
ContactsTitle: "Custom events",
|
||||
FooterNote: "Custom footer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Custom <occultation> & title",
|
||||
"Custom summary",
|
||||
"Custom greatest",
|
||||
"Custom map",
|
||||
"Custom events",
|
||||
"Custom footer",
|
||||
"Start",
|
||||
"Greatest",
|
||||
"End",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(diagram, "Custom <occultation>") {
|
||||
t.Fatal("custom title was not XML escaped")
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("generated SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGIncludesAlignedTimeLabels(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{
|
||||
Location: time.UTC, TimeLabelStep: 30 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`class="occultation-time-marker"`, `>10:30</text>`, `>12:30</text>`} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("stellar occultation SVG missing time marker %q", want)
|
||||
}
|
||||
}
|
||||
markers := occultationTimeMarkerPoints(
|
||||
sampleStarOccultationPath().CenterLine,
|
||||
30*time.Minute,
|
||||
time.UTC,
|
||||
[]time.Time{sampleStarOccultationPath().Start.Time, sampleStarOccultationPath().End.Time},
|
||||
)
|
||||
foundGreatestTime := false
|
||||
for _, marker := range markers {
|
||||
if marker.Time.Equal(sampleStarOccultationPath().Greatest.Time) {
|
||||
foundGreatestTime = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundGreatestTime {
|
||||
t.Fatal("aligned time at greatest was discarded instead of being placed below the event label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGCanDisableTimeLabels(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{TimeLabelStep: -1})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
if strings.Contains(diagram, `class="occultation-time-marker"`) {
|
||||
t.Fatal("disabled occultation time labels were rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGSplitsAntimeridian(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="center-line"`); got != 2 {
|
||||
t.Fatalf("center-line segment count = %d, want 2", got)
|
||||
}
|
||||
if got := strings.Count(diagram, `class="occultation-band"`); got != 2 {
|
||||
t.Fatalf("occultation-band segment count = %d, want 2", got)
|
||||
}
|
||||
segments := starOccultationPathSegments(sampleStarOccultationPath().CenterLine)
|
||||
if len(segments) != 2 {
|
||||
t.Fatalf("path segment count = %d, want 2", len(segments))
|
||||
}
|
||||
if segments[0][len(segments[0])-1].Longitude != 180 || segments[1][0].Longitude != -180 {
|
||||
t.Fatalf("antimeridian interpolation = %.3f / %.3f, want +180 / -180",
|
||||
segments[0][len(segments[0])-1].Longitude, segments[1][0].Longitude)
|
||||
}
|
||||
if !segments[0][len(segments[0])-1].Time.Equal(segments[1][0].Time) {
|
||||
t.Fatal("antimeridian split points do not share the interpolated time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGUsesDetailedPhysicalLand(t *testing.T) {
|
||||
diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`class="land-layer"`,
|
||||
`class="land"`,
|
||||
`fill="#d8d9d2"`,
|
||||
`stroke="#a6aaa4"`,
|
||||
`fill-rule="evenodd"`,
|
||||
`vector-effect="non-scaling-stroke"`,
|
||||
"Natural Earth 1:50m",
|
||||
"不含行政边界",
|
||||
} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("SVG missing detailed-land marker %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(diagram, `class="land"`); got != 1 {
|
||||
t.Fatalf("land path count = %d, want one compact path", got)
|
||||
}
|
||||
if strings.Contains(diagram, `fill="#000`) || strings.Contains(diagram, `fill="black"`) {
|
||||
t.Fatal("land path uses a black fill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGUsesNorthPolarProjection(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.Greatest.Latitude = 72
|
||||
for index := range path.CenterLine {
|
||||
path.CenterLine[index].Latitude = 62 + float64(index)*4
|
||||
}
|
||||
for index := range path.NorthernLimit {
|
||||
path.NorthernLimit[index].Latitude = 68 + float64(index)*3
|
||||
path.SouthernLimit[index].Latitude = 58 + float64(index)*3
|
||||
}
|
||||
path.Start.Latitude = path.NorthernLimit[0].Latitude
|
||||
path.End.Latitude = path.NorthernLimit[len(path.NorthernLimit)-1].Latitude
|
||||
|
||||
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "北极方位等距投影"} {
|
||||
if !strings.Contains(diagram, want) {
|
||||
t.Fatalf("north-polar SVG missing %q", want)
|
||||
}
|
||||
}
|
||||
if err := validateXML(diagram); err != nil {
|
||||
t.Fatalf("north-polar SVG is not valid XML: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationBandSegmentsPreserveAsymmetricAntimeridianCrossing(t *testing.T) {
|
||||
start := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
|
||||
northern := []moon.OccultationPathPoint{
|
||||
{Time: start, Longitude: 170, Latitude: 20},
|
||||
{Time: start.Add(time.Hour), Longitude: -170, Latitude: 10},
|
||||
}
|
||||
southern := []moon.OccultationPathPoint{
|
||||
{Time: start, Longitude: 150, Latitude: 0},
|
||||
{Time: start.Add(time.Hour), Longitude: 160, Latitude: -10},
|
||||
}
|
||||
segments := starOccultationBandSegments(northern, southern)
|
||||
if len(segments) != 2 {
|
||||
t.Fatalf("asymmetric antimeridian band segment count = %d, want 2", len(segments))
|
||||
}
|
||||
hasWest, hasEast := false, false
|
||||
for _, segment := range segments {
|
||||
for _, point := range segment {
|
||||
hasWest = hasWest || point.longitude < -179
|
||||
hasEast = hasEast || point.longitude > 179
|
||||
}
|
||||
}
|
||||
if !hasWest || !hasEast {
|
||||
t.Fatalf("split band does not cover both map edges: west=%v east=%v", hasWest, hasEast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindStarOccultationSVGsNoEvent(t *testing.T) {
|
||||
diagrams, err := FindStarOccultationSVGs(
|
||||
time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC),
|
||||
moon.StarCoordinate{
|
||||
ID: "polar-star",
|
||||
RA: 0,
|
||||
Dec: 89,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameICRS,
|
||||
},
|
||||
moon.OccultationPathOptions{},
|
||||
StarOccultationSVGOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FindStarOccultationSVGs() error = %v", err)
|
||||
}
|
||||
if len(diagrams) != 0 {
|
||||
t.Fatalf("FindStarOccultationSVGs() returned %d diagrams, want none", len(diagrams))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGRejectsInvalidPath(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.Greatest.Longitude = math.NaN()
|
||||
_, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidStarOccultationPath) {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGRejectsMisalignedLimits(t *testing.T) {
|
||||
path := sampleStarOccultationPath()
|
||||
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
|
||||
_, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
|
||||
if !errors.Is(err, ErrInvalidStarOccultationPath) {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOccultationPathSVGRejectsCanvasTooSmall(t *testing.T) {
|
||||
_, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{Width: 1, Height: 1})
|
||||
if !errors.Is(err, ErrInvalidStarOccultationSVGOptions) {
|
||||
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationSVGOptions", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hr4799StarCoordinate() moon.StarCoordinate {
|
||||
return moon.StarCoordinate{
|
||||
ID: "HR 4799",
|
||||
RA: 189.1975,
|
||||
Dec: -5.831944444444,
|
||||
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Frame: moon.CoordinateFrameJ2000,
|
||||
ProperMotionRACosDecMasPerYear: -28,
|
||||
ProperMotionDecMasPerYear: -18,
|
||||
}
|
||||
}
|
||||
|
||||
func sampleStarOccultationPath() moon.StarOccultationPath {
|
||||
start := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)
|
||||
center := []moon.OccultationPathPoint{
|
||||
{Time: start, Longitude: 160, Latitude: 18, MoonAltitude: 5, WidthKM: 3200},
|
||||
{Time: start.Add(time.Hour), Longitude: 175, Latitude: 10, MoonAltitude: 35, WidthKM: 3300},
|
||||
{Time: start.Add(2 * time.Hour), Longitude: -175, Latitude: 1, MoonAltitude: 50, WidthKM: 3400},
|
||||
{Time: start.Add(3 * time.Hour), Longitude: -160, Latitude: -8, MoonAltitude: 12, WidthKM: 3300},
|
||||
}
|
||||
northern := make([]moon.OccultationPathPoint, len(center))
|
||||
southern := make([]moon.OccultationPathPoint, len(center))
|
||||
for index, point := range center {
|
||||
northern[index] = point
|
||||
northern[index].Latitude += 12
|
||||
northern[index].WidthKM = 0
|
||||
southern[index] = point
|
||||
southern[index].Latitude -= 12
|
||||
southern[index].WidthKM = 0
|
||||
}
|
||||
return moon.StarOccultationPath{
|
||||
TargetID: "synthetic-star",
|
||||
Start: center[0],
|
||||
Greatest: center[1],
|
||||
End: center[3],
|
||||
Complete: true,
|
||||
CenterLine: center,
|
||||
NorthernLimit: northern,
|
||||
SouthernLimit: southern,
|
||||
Step: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func validateXML(value string) error {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
for {
|
||||
if _, err := decoder.Token(); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user