feat: 新增月掩与日月食地理绘图并提升观测计算精度

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+3 -2
View File
@@ -216,9 +216,10 @@ func CulminationTime(date time.Time, lon, lat float64) time.Time {
// RiseTime 月出时刻 / moonrise time.
//
// date 取其所在时区的当地日期,返回值保持相同时区;lon/lat 为观测者经纬度,东正西负、北正南负;
// height 为海拔高度,单位米;aero 为 true 时加入标准大气折射。
// height 为海拔高度,单位米;aero 为 true 时按动态标准大气折射和实时月球视半径计算上缘过地平线
// date is interpreted on its local civil day and the result keeps the same time zone. lon/lat are east-positive and north-positive;
// height is observer elevation in meters, and aero enables standard atmospheric refraction.
// height 为观测者海拔,单位米;aero 使用动态标准折射和实时月球视半径计算上缘过地平线。
// height is observer elevation in meters; aero uses dynamic standard refraction and the instantaneous lunar semidiameter for an upper-limb crossing.
func RiseTime(date time.Time, lon, lat, height float64, aero bool) (time.Time, error) {
if date.Hour() > 12 {
date = date.Add(time.Hour * -12)
+121
View File
@@ -0,0 +1,121 @@
package moon
import "b612.me/astro/basic"
// ErrInvalidOccultationInput 表示月掩输入无效。
// ErrInvalidOccultationInput reports invalid lunar-occultation input.
var ErrInvalidOccultationInput = basic.ErrInvalidOccultationInput
// ErrOccultationPathSamplingLimit 表示请求的路径分辨率超过确定性采样预算。
// ErrOccultationPathSamplingLimit reports that the requested path resolution exceeds the deterministic sampling budget.
var ErrOccultationPathSamplingLimit = basic.ErrOccultationPathSamplingLimit
// CoordinateFrame 标识恒星坐标的赤道参考系。
// CoordinateFrame identifies the equatorial frame of a stellar coordinate.
type CoordinateFrame = basic.CoordinateFrame
const (
// CoordinateFrameICRS 表示 ICRS 星表坐标系。
// CoordinateFrameICRS is the ICRS catalog frame.
CoordinateFrameICRS = basic.CoordinateFrameICRS
// CoordinateFrameJ2000 表示 J2000 平均赤道坐标系。
// CoordinateFrameJ2000 is the mean equatorial J2000 frame.
CoordinateFrameJ2000 = basic.CoordinateFrameJ2000
// CoordinateFrameApparentOfDate 表示历元时刻的视赤道坐标系。
// CoordinateFrameApparentOfDate is the apparent equatorial frame of date.
CoordinateFrameApparentOfDate = basic.CoordinateFrameApparentOfDate
)
// OccultationType 标识点光源或有限盘面月掩几何。
// OccultationType identifies point-source or finite-disk occultation geometry.
type OccultationType = basic.OccultationType
const (
// OccultationTotal 表示掩甚时目标盘面被完全覆盖。
// OccultationTotal means the target disk is fully covered at greatest occultation.
OccultationTotal = basic.OccultationTotal
// OccultationPartial 表示掩甚时有限目标盘面只有部分被覆盖。
// OccultationPartial means only part of a finite target disk is covered at greatest occultation.
OccultationPartial = basic.OccultationPartial
// OccultationGrazing 表示两边缘相切,且没有正持续时间的重叠。
// OccultationGrazing means the limbs are tangent without a positive-duration overlap.
OccultationGrazing = basic.OccultationGrazing
)
// OccultationPlanet 标识有限盘面行星目标。
// OccultationPlanet identifies a finite-disk planetary target.
type OccultationPlanet = basic.OccultationPlanet
const (
// OccultationMercury 表示水星有限盘面目标。
// OccultationMercury identifies Mercury as the finite-disk target.
OccultationMercury = basic.OccultationMercury
// OccultationVenus 表示金星有限盘面目标。
// OccultationVenus identifies Venus as the finite-disk target.
OccultationVenus = basic.OccultationVenus
// OccultationMars 表示火星有限盘面目标。
// OccultationMars identifies Mars as the finite-disk target.
OccultationMars = basic.OccultationMars
// OccultationJupiter 表示木星有限盘面目标。
// OccultationJupiter identifies Jupiter as the finite-disk target.
OccultationJupiter = basic.OccultationJupiter
// OccultationSaturn 表示土星有限盘面目标。
// OccultationSaturn identifies Saturn as the finite-disk target.
OccultationSaturn = basic.OccultationSaturn
// OccultationUranus 表示天王星有限盘面目标。
// OccultationUranus identifies Uranus as the finite-disk target.
OccultationUranus = basic.OccultationUranus
// OccultationNeptune 表示海王星有限盘面目标。
// OccultationNeptune identifies Neptune as the finite-disk target.
OccultationNeptune = basic.OccultationNeptune
)
// Observer 描述站心观测地点。
// Observer describes a topocentric observing site.
type Observer = basic.Observer
// StarCoordinate 是调用者提供的恒星星表坐标或视位置坐标。
// StarCoordinate is a catalog or apparent coordinate supplied for a star.
type StarCoordinate = basic.StarCoordinate
// StarData 是内嵌 9100 星表中的一条记录。
// StarData is one entry from the embedded 9100-star catalog.
type StarData = basic.StarData
// StarCoordinateFromStarData 将星表记录转换为恒星月掩搜索接受的 J2000 输入;不会加载星表。
// StarCoordinateFromStarData converts a catalog entry into the J2000 input accepted by stellar lunar-occultation searches. It does not load the catalog.
func StarCoordinateFromStarData(star StarData) (StarCoordinate, error) {
return basic.StarCoordinateFromStarData(star)
}
// OccultationSearchOptions 控制恒星和行星月掩搜索。
// OccultationSearchOptions controls stellar and planetary occultation searches.
type OccultationSearchOptions = basic.OccultationSearchOptions
// OccultationPathOptions 控制全球月掩路径采样。
// OccultationPathOptions controls global occultation-path sampling.
type OccultationPathOptions = basic.OccultationPathOptions
// StarOccultationInfo 描述一次点光源恒星月掩。
// StarOccultationInfo describes one point-source stellar occultation.
type StarOccultationInfo = basic.StarOccultationInfo
// PlanetOccultationInfo 描述一次有限盘面行星月掩。
// PlanetOccultationInfo describes one finite-disk planetary occultation.
type PlanetOccultationInfo = basic.PlanetOccultationInfo
// OccultationPathPoint 是全球月掩路径上的地理采样点。
// OccultationPathPoint is a geographic sample of a global occultation path.
type OccultationPathPoint = basic.OccultationPathPoint
// StarOccultationPath 包含点光源恒星月掩掩带。
// StarOccultationPath contains a point-source stellar occultation footprint.
type StarOccultationPath = basic.StarOccultationPath
// PlanetOccultationPath 包含有限盘面行星月掩掩带。
// PlanetOccultationPath contains a finite-disk planetary occultation footprint.
type PlanetOccultationPath = basic.PlanetOccultationPath
// PlanetOccultationFootprint 是一个时刻的可见行星接触区域。
// PlanetOccultationFootprint is one instantaneous visible planetary-contact region.
type PlanetOccultationFootprint = basic.PlanetOccultationFootprint
+121
View File
@@ -0,0 +1,121 @@
package moon
import (
"errors"
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestOccultationCommonInputsValidate(t *testing.T) {
if err := (Observer{Longitude: 121.5, Latitude: 31.2, Height: 10}).Validate(); err != nil {
t.Fatalf("valid observer rejected: %v", err)
}
if err := (OccultationSearchOptions{SafetyMarginArcsec: 2, MaxEvents: 3}).Validate(); err != nil {
t.Fatalf("valid options rejected: %v", err)
}
if err := (OccultationSearchOptions{}).Validate(); err != nil {
t.Fatalf("zero options rejected: %v", err)
}
if err := (OccultationSearchOptions{MaxStep: 250 * time.Millisecond}).Validate(); err != nil {
t.Fatalf("minimum search step rejected: %v", err)
}
invalid := []error{
(Observer{Latitude: 91}).Validate(),
(Observer{Longitude: math.NaN()}).Validate(),
(OccultationSearchOptions{SafetyMarginArcsec: -1}).Validate(),
(OccultationSearchOptions{MaxEvents: -1}).Validate(),
(OccultationSearchOptions{MaxStep: 100 * time.Millisecond}).Validate(),
}
for index, err := range invalid {
if !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("invalid input %d error = %v, want ErrInvalidOccultationInput", index, err)
}
}
}
func TestStarCoordinateValidate(t *testing.T) {
valid := StarCoordinate{
ID: "star",
RA: 101.28715533,
Dec: -16.71611586,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameICRS,
ProperMotionRACosDecMasPerYear: 10,
ProperMotionDecMasPerYear: -20,
ParallaxMas: 7,
}
if err := valid.Validate(); err != nil {
t.Fatalf("valid star coordinate rejected: %v", err)
}
tests := []struct {
name string
modify func(*StarCoordinate)
}{
{"RA=360", func(s *StarCoordinate) { s.RA = 360 }},
{"Dec out of range", func(s *StarCoordinate) { s.Dec = -91 }},
{"missing epoch", func(s *StarCoordinate) { s.Epoch = time.Time{} }},
{"unsupported frame", func(s *StarCoordinate) { s.Frame = CoordinateFrame("galactic") }},
{"non-finite proper motion", func(s *StarCoordinate) { s.ProperMotionDecMasPerYear = math.NaN() }},
{"negative parallax", func(s *StarCoordinate) { s.ParallaxMas = -1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
candidate := valid
tc.modify(&candidate)
if err := candidate.Validate(); !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("StarCoordinate.Validate() error = %v, want ErrInvalidOccultationInput", err)
}
})
}
}
func TestStarCoordinateFromStarData(t *testing.T) {
data, err := basic.StarDataByChinese("进贤增九")
if err != nil {
t.Fatalf("load HR 4799: %v", err)
}
data.Pc = 50
coordinate, err := StarCoordinateFromStarData(data)
if err != nil {
t.Fatalf("StarCoordinateFromStarData() error = %v", err)
}
if coordinate.ID != "进贤增九" {
t.Fatalf("ID = %q, want %q", coordinate.ID, "进贤增九")
}
if coordinate.RA != data.Ra || coordinate.Dec != data.Dec {
t.Fatalf("RA/Dec = %.12f %.12f, want %.12f %.12f", coordinate.RA, coordinate.Dec, data.Ra, data.Dec)
}
if coordinate.Frame != CoordinateFrameJ2000 || !coordinate.Epoch.Equal(time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC)) {
t.Fatalf("frame/epoch = %q %v, want J2000", coordinate.Frame, coordinate.Epoch)
}
if coordinate.ProperMotionRACosDecMasPerYear != data.PmRA*1000 ||
coordinate.ProperMotionDecMasPerYear != data.PmDec*1000 {
t.Fatalf("proper motion = %.3f %.3f mas/year", coordinate.ProperMotionRACosDecMasPerYear, coordinate.ProperMotionDecMasPerYear)
}
if coordinate.ParallaxMas != 20 {
t.Fatalf("parallax = %.6f mas, want 20", coordinate.ParallaxMas)
}
}
func TestStarCoordinateFromStarDataRejectsInvalidData(t *testing.T) {
tests := []struct {
name string
data StarData
}{
{name: "zero value"},
{name: "non-finite distance", data: StarData{InnerStarData: basic.InnerStarData{HR: 1, Ra: 10, Dec: 20, Pc: math.NaN()}}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := StarCoordinateFromStarData(tc.data); !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("StarCoordinateFromStarData() error = %v, want ErrInvalidOccultationInput", err)
}
})
}
}
+15
View File
@@ -0,0 +1,15 @@
package moon
import (
"time"
"b612.me/astro/basic"
)
// FindStarOccultationPaths 搜索单颗点源恒星月掩的全球掩带。
// 调用者显式提供坐标;函数不会加载内嵌星表。查询端点使用核心求解器的 10 ms 数值选择容差。
// FindStarOccultationPaths searches the global lunar-occultation footprint of one point-source star.
// Callers provide the coordinate explicitly; this function does not load the embedded star catalog. Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindStarOccultationPaths(start, end time.Time, star StarCoordinate, options OccultationPathOptions) ([]StarOccultationPath, error) {
return basic.FindStarOccultationPaths(start, end, star, options)
}
+236
View File
@@ -0,0 +1,236 @@
package moon
import (
"fmt"
"math"
"testing"
"time"
)
func TestFindStarOccultationPathsHR4799(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
star := StarCoordinate{
ID: "HR 4799",
RA: 189.1975,
Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28,
ProperMotionDecMasPerYear: -18,
}
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, OccultationPathOptions{
Step: 5 * time.Minute,
TargetSpacingKM: 200,
})
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
}
path := paths[0]
t.Logf("start=%s %.6f %.6f greatest=%s %.6f %.6f width=%.3f end=%s %.6f %.6f center=%d north=%d south=%d",
path.Start.Time, path.Start.Longitude, path.Start.Latitude,
path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude, path.Greatest.WidthKM,
path.End.Time, path.End.Longitude, path.End.Latitude,
len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
if !path.Start.Time.Before(path.Greatest.Time) || !path.Greatest.Time.Before(path.End.Time) {
t.Fatalf("path times are not ordered: start=%v greatest=%v end=%v", path.Start.Time, path.Greatest.Time, path.End.Time)
}
if !path.Complete {
t.Fatal("global path must be marked complete")
}
if math.Abs(path.Start.Time.Sub(time.Date(2025, 6, 5, 17, 45, 26, 200000000, location)).Seconds()) > 5 {
t.Fatalf("path start is too far from reference: got %v", path.Start.Time)
}
if math.Abs(path.End.Time.Sub(time.Date(2025, 6, 5, 22, 18, 53, 100000000, location)).Seconds()) > 5 {
t.Fatalf("path end is too far from reference: got %v", path.End.Time)
}
if math.Abs(path.Start.Longitude-63.97388) > 0.5 || math.Abs(path.Start.Latitude-39.11772) > 0.5 {
t.Fatalf("path start point is too far from reference: got %.6f %.6f", path.Start.Longitude, path.Start.Latitude)
}
if math.Abs(path.End.Longitude-172.33859) > 0.5 || math.Abs(path.End.Latitude-(-16.67159)) > 0.5 {
t.Fatalf("path end point is too far from reference: got %.6f %.6f", path.End.Longitude, path.End.Latitude)
}
if math.Abs(path.Greatest.Longitude-121.55381) > 0.1 || math.Abs(path.Greatest.Latitude-6.79657) > 0.1 {
t.Fatalf("greatest point is too far from reference: got %.6f %.6f", path.Greatest.Longitude, path.Greatest.Latitude)
}
if len(path.CenterLine) == 0 || len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 {
t.Fatalf("path limits missing: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
}
if math.Abs(path.Greatest.Time.Sub(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)).Seconds()) > 5 {
t.Fatalf("greatest time is too far from reference: got %v", path.Greatest.Time)
}
if math.Abs(path.Greatest.WidthKM-3571.9) > 20 {
t.Fatalf("unexpected occultation width: got %.3f km", path.Greatest.WidthKM)
}
if len(path.NorthernLimit) != len(path.SouthernLimit) {
t.Fatalf("limit counts differ: north=%d south=%d", len(path.NorthernLimit), len(path.SouthernLimit))
}
last := len(path.NorthernLimit) - 1
if last < 1 || !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
t.Fatalf("limits do not cover the global start/end")
}
best, err := FindBestStarOccultations(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, OccultationSearchOptions{MaxEvents: 1},
)
if err != nil || len(best) != 1 {
t.Fatalf("FindBestStarOccultations() = %d events, %v; want one event", len(best), err)
}
if math.Abs(best[0].Greatest.Sub(path.Greatest.Time).Seconds()) > 0.1 ||
math.Abs(best[0].Observer.Longitude-path.Greatest.Longitude) > 1e-6 ||
math.Abs(best[0].Observer.Latitude-path.Greatest.Latitude) > 1e-6 {
t.Fatalf("best observer does not match global greatest: best=%v %+v path=%v %.9f %.9f",
best[0].Greatest, best[0].Observer, path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude)
}
}
func TestFindStarOccultationPathsHR4799LimitsRemainContinuous(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799CoordinateForTest(),
OccultationPathOptions{Step: 30 * time.Second},
)
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
}
for _, limit := range []struct {
name string
points []OccultationPathPoint
}{
{name: "northern", points: paths[0].NorthernLimit},
{name: "southern", points: paths[0].SouthernLimit},
} {
for i := 1; i < len(limit.points); i++ {
distance := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i])
if distance > 750 {
t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
limit.points[i-1].Time, limit.points[i].Time)
}
}
for i := 1; i+1 < len(limit.points); i++ {
detour := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i]) +
occultationPathPointDistanceKM(limit.points[i], limit.points[i+1]) -
occultationPathPointDistanceKM(limit.points[i-1], limit.points[i+1])
if detour > 100 {
t.Fatalf("%s limit has a %.1f km three-point detour at %v", limit.name, detour, limit.points[i].Time)
}
}
}
}
func occultationPathPointDistanceKM(a, b OccultationPathPoint) float64 {
lat1 := a.Latitude * math.Pi / 180
lat2 := b.Latitude * math.Pi / 180
dLat := lat2 - lat1
dLon := (b.Longitude - a.Longitude) * math.Pi / 180
dLon = math.Atan2(math.Sin(dLon), math.Cos(dLon))
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, h)))
}
func TestFindStarOccultationPathsReturnsFullPathOutsideQueryWindow(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
greatest := time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)
start := greatest.Add(-time.Minute)
end := greatest.Add(time.Minute)
paths, err := FindStarOccultationPaths(start, end, hr4799CoordinateForTest(), OccultationPathOptions{Step: 5 * time.Minute})
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
}
path := paths[0]
if !path.Complete || !path.Start.Time.Before(start) || !path.End.Time.After(end) {
t.Fatalf("path was clipped to query window: complete=%v start=%v window=%v..%v end=%v", path.Complete, path.Start.Time, start, end, path.End.Time)
}
}
func TestGlobalStarOccultationQueriesSelectByGeometricGreatest(t *testing.T) {
star := hr4799CoordinateForTest()
dayStart := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
paths, err := FindStarOccultationPaths(
dayStart, dayStart.Add(24*time.Hour), star,
OccultationPathOptions{Step: 5 * time.Minute},
)
if err != nil || len(paths) != 1 {
t.Fatalf("full-day FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
start := paths[0].Greatest.Time.Add(-500 * time.Microsecond)
end := start.Add(time.Millisecond)
narrowPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{Step: 5 * time.Minute})
if err != nil || len(narrowPaths) != 1 {
t.Fatalf("narrow FindStarOccultationPaths() paths=%d err=%v, want one", len(narrowPaths), err)
}
if !occultationTimeWithinNumericalWindowForTest(narrowPaths[0].Greatest.Time, start, end) {
t.Fatalf("path greatest %v is outside query window %v..%v", narrowPaths[0].Greatest.Time, start, end)
}
best, err := FindBestStarOccultations(start, end, star, OccultationSearchOptions{})
if err != nil || len(best) != 1 {
t.Fatalf("narrow FindBestStarOccultations() events=%d err=%v, want one", len(best), err)
}
if !occultationTimeWithinNumericalWindowForTest(best[0].Greatest, start, end) {
t.Fatalf("best greatest %v is outside query window %v..%v", best[0].Greatest, start, end)
}
}
func occultationTimeWithinNumericalWindowForTest(value, start, end time.Time) bool {
const tolerance = 10 * time.Millisecond
return !value.Before(start.Add(-tolerance)) && !value.After(end.Add(tolerance))
}
func TestOccultationPathOptionsValidate(t *testing.T) {
if err := (OccultationPathOptions{Step: -time.Second}).Validate(); err == nil {
t.Fatal("negative path step should be rejected")
}
if err := (OccultationPathOptions{Step: 500 * time.Millisecond}).Validate(); err == nil {
t.Fatal("subsecond positive path step should be rejected")
}
if err := (OccultationPathOptions{Step: time.Second}).Validate(); err != nil {
t.Fatalf("one-second path step rejected: %v", err)
}
if err := (OccultationPathOptions{TargetSpacingKM: math.NaN()}).Validate(); err == nil {
t.Fatal("non-finite target spacing should be rejected")
}
if err := (OccultationPathOptions{TargetSpacingKM: 0.5}).Validate(); err == nil {
t.Fatal("sub-kilometer target spacing should be rejected")
}
if err := (OccultationPathOptions{TargetSpacingKM: 1}).Validate(); err != nil {
t.Fatalf("one-kilometer target spacing rejected: %v", err)
}
}
func BenchmarkFindStarOccultationPathTargetSpacing(b *testing.B) {
location := time.FixedZone("CST", 8*3600)
star := hr4799CoordinateForTest()
for _, spacing := range []float64{1, 10, 200} {
b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
for i := 0; i < b.N; i++ {
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: spacing},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindStarOccultationPaths() paths=%d err=%v", len(paths), err)
}
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package moon
import (
"time"
"b612.me/astro/basic"
)
// FindPlanetOccultations 搜索固定地点的有限盘面行星月掩。
// 经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。圆形目标使用行星赤道半径,忽略行星环。
// FindPlanetOccultations searches finite-disk lunar occultations of one planet at a fixed observing site.
// Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is above mean sea level in meters. The circular target uses the planet's equatorial body radius; rings are excluded.
func FindPlanetOccultations(start, end time.Time, planet OccultationPlanet, longitude, latitude, height float64,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
return basic.FindPlanetOccultations(start, end, planet, longitude, latitude, height, options)
}
// FindBestPlanetOccultations 返回窗口内每次有限盘面行星月掩的全球海平面几何掩甚点。
// 地平线可见性只报告、不参与点选择;查询端点使用核心求解器的 10 ms 数值选择容差。
// FindBestPlanetOccultations returns the global geometric greatest sea-level point for every finite-disk occultation of one planet in the window.
// Horizon visibility is reported but does not affect point selection. Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindBestPlanetOccultations(start, end time.Time, planet OccultationPlanet,
options OccultationSearchOptions) ([]PlanetOccultationInfo, error) {
return basic.FindBestPlanetOccultations(start, end, planet, options)
}
+24
View File
@@ -0,0 +1,24 @@
package moon
import "b612.me/astro/basic"
// PlanetOccultationDiagramOptions 控制本地行星月掩轨迹采样。
// PlanetOccultationDiagramOptions controls local planetary-occultation track sampling.
type PlanetOccultationDiagramOptions = basic.PlanetOccultationDiagramOptions
// PlanetOccultationDiagramFrame 描述一个站心月球与行星几何采样点。
// PlanetOccultationDiagramFrame describes one topocentric Moon-planet geometry sample.
type PlanetOccultationDiagramFrame = basic.PlanetOccultationDiagramFrame
// PlanetOccultationDiagramResult 包含固定地点的月心行星轨迹。
// PlanetOccultationDiagramResult contains a fixed-site Moon-centered planetary track.
type PlanetOccultationDiagramResult = basic.PlanetOccultationDiagramResult
// PlanetOccultationDiagram 为已求解的固定地点行星月掩计算图形几何数据。
// PlanetOccultationDiagram computes diagram geometry for an already solved fixed-site planetary occultation.
func PlanetOccultationDiagram(
info PlanetOccultationInfo,
options PlanetOccultationDiagramOptions,
) PlanetOccultationDiagramResult {
return basic.PlanetOccultationDiagram(info, options)
}
+16
View File
@@ -0,0 +1,16 @@
package moon
import (
"time"
"b612.me/astro/basic"
)
// FindPlanetOccultationPaths 搜索窗口内每次有限盘面行星月掩的完整全球外接触掩带。
// 查询端点使用核心求解器的 10 ms 数值选择容差。
// FindPlanetOccultationPaths searches the complete global outer-contact footprint of every finite-disk planetary occultation selected by the window.
// Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindPlanetOccultationPaths(start, end time.Time, planet OccultationPlanet,
options OccultationPathOptions) ([]PlanetOccultationPath, error) {
return basic.FindPlanetOccultationPaths(start, end, planet, options)
}
+136
View File
@@ -0,0 +1,136 @@
package moon
import (
"fmt"
"math"
"testing"
"time"
)
func TestFindPlanetOccultationPathsSaturnFiniteDisk(t *testing.T) {
start := time.Date(2024, time.August, 20, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, time.August, 22, 0, 0, 0, 0, time.UTC)
paths, err := FindPlanetOccultationPaths(
start, end, OccultationSaturn,
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil {
t.Fatalf("FindPlanetOccultationPaths() error = %v", err)
}
if len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() returned %d paths, want 1", len(paths))
}
path := paths[0]
if path.Planet != OccultationSaturn || path.TargetID != "Saturn" {
t.Fatalf("target = %v/%q, want Saturn", path.Planet, path.TargetID)
}
if !path.Complete {
t.Fatal("planetary global path must be complete")
}
if !(path.Start.Time.Before(path.Greatest.Time) && path.Greatest.Time.Before(path.End.Time)) {
t.Fatalf("global event order is invalid: start=%v greatest=%v end=%v", path.Start.Time, path.Greatest.Time, path.End.Time)
}
wantGreatest := time.Date(2024, time.August, 21, 2, 41, 36, 0, time.UTC)
if delta := path.Greatest.Time.Sub(wantGreatest); delta < -2*time.Second || delta > 2*time.Second {
t.Fatalf("global greatest differs from regression baseline by %v: got %v want %v", delta, path.Greatest.Time, wantGreatest)
}
if path.Greatest.WidthKM < 3000 || path.Greatest.WidthKM > 5000 {
t.Fatalf("global outer width = %.3f km, want a physical lunar occultation band", path.Greatest.WidthKM)
}
if !path.HasTotalBand || !path.TotalComplete {
t.Fatalf("global Saturn path total band = %v complete=%v, want a complete total-occultation band", path.HasTotalBand, path.TotalComplete)
}
if !(path.Start.Time.Before(path.TotalStart.Time) && path.TotalStart.Time.Before(path.Greatest.Time) &&
path.Greatest.Time.Before(path.TotalEnd.Time) && path.TotalEnd.Time.Before(path.End.Time)) {
t.Fatalf("global total-band order is invalid: outer=%v..%v total=%v..%v greatest=%v",
path.Start.Time, path.End.Time, path.TotalStart.Time, path.TotalEnd.Time, path.Greatest.Time)
}
if path.GreatestTotalWidthKM <= 0 || path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
t.Fatalf("global total width = %.3f km, want positive and narrower than outer width %.3f km",
path.GreatestTotalWidthKM, path.Greatest.WidthKM)
}
if len(path.CenterLine) < 3 || len(path.NorthernLimit) < 3 || len(path.NorthernLimit) != len(path.SouthernLimit) {
t.Fatalf("incomplete global samples: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
}
last := len(path.NorthernLimit) - 1
if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
t.Fatal("planetary path limits do not span global start through end")
}
if len(path.NorthernTotalLimit) < 3 || len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) {
t.Fatalf("incomplete global total samples: north=%d south=%d", len(path.NorthernTotalLimit), len(path.SouthernTotalLimit))
}
totalLast := len(path.NorthernTotalLimit) - 1
if !path.NorthernTotalLimit[0].Time.Equal(path.TotalStart.Time) || !path.SouthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
!path.NorthernTotalLimit[totalLast].Time.Equal(path.TotalEnd.Time) || !path.SouthernTotalLimit[totalLast].Time.Equal(path.TotalEnd.Time) {
t.Fatal("planetary total limits do not span global total start through end")
}
if len(path.PartialFootprints) < 3 || len(path.TotalFootprints) < 3 {
t.Fatalf("incomplete planetary footprint sweep: partial=%d total=%d",
len(path.PartialFootprints), len(path.TotalFootprints))
}
for _, footprints := range [][]PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} {
for index, footprint := range footprints {
if len(footprint.Polygons) == 0 {
t.Fatalf("planetary footprint[%d] contains no visible polygons", index)
}
}
}
best, err := FindBestPlanetOccultations(start, end, OccultationSaturn, OccultationSearchOptions{MaxEvents: 1})
if err != nil || len(best) != 1 {
t.Fatalf("FindBestPlanetOccultations() = %d events, %v; want one event", len(best), err)
}
if math.Abs(best[0].Greatest.Sub(path.Greatest.Time).Seconds()) > 0.1 ||
math.Abs(best[0].Observer.Longitude-path.Greatest.Longitude) > 1e-6 ||
math.Abs(best[0].Observer.Latitude-path.Greatest.Latitude) > 1e-6 {
t.Fatalf("best observer does not match global greatest: best=%v %+v path=%v %.9f %.9f",
best[0].Greatest, best[0].Observer, path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude)
}
}
func TestGlobalPlanetOccultationQueriesSelectByGeometricGreatest(t *testing.T) {
dayStart := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
paths, err := FindPlanetOccultationPaths(
dayStart, dayStart.Add(24*time.Hour), OccultationSaturn,
OccultationPathOptions{Step: 5 * time.Minute},
)
if err != nil || len(paths) != 1 {
t.Fatalf("full-day FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
start := paths[0].Greatest.Time.Add(-500 * time.Microsecond)
end := start.Add(time.Millisecond)
narrowPaths, err := FindPlanetOccultationPaths(start, end, OccultationSaturn, OccultationPathOptions{Step: 5 * time.Minute})
if err != nil || len(narrowPaths) != 1 {
t.Fatalf("narrow FindPlanetOccultationPaths() paths=%d err=%v, want one", len(narrowPaths), err)
}
if !occultationTimeWithinNumericalWindowForTest(narrowPaths[0].Greatest.Time, start, end) {
t.Fatalf("path greatest %v is outside query window %v..%v", narrowPaths[0].Greatest.Time, start, end)
}
best, err := FindBestPlanetOccultations(start, end, OccultationSaturn, OccultationSearchOptions{})
if err != nil || len(best) != 1 {
t.Fatalf("narrow FindBestPlanetOccultations() events=%d err=%v, want one", len(best), err)
}
if !occultationTimeWithinNumericalWindowForTest(best[0].Greatest, start, end) {
t.Fatalf("best greatest %v is outside query window %v..%v", best[0].Greatest, start, end)
}
}
func BenchmarkFindPlanetOccultationPathTargetSpacing(b *testing.B) {
for _, spacing := range []float64{1, 10, 200} {
b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
for i := 0; i < b.N; i++ {
paths, err := FindPlanetOccultationPaths(
time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
time.Date(2025, 2, 2, 0, 0, 0, 0, time.UTC),
OccultationSaturn,
OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: spacing},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v", len(paths), err)
}
}
})
}
}
+126
View File
@@ -0,0 +1,126 @@
package moon
import (
"errors"
"math"
"testing"
"time"
)
func TestFindPlanetOccultationsReturnsFiniteDiskContacts(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
observer := Observer{Longitude: -30.072, Latitude: 16.21}
start := time.Date(2024, time.August, 21, 9, 30, 0, 0, location)
end := time.Date(2024, time.August, 21, 12, 0, 0, 0, location)
results, err := FindPlanetOccultations(
start, end, OccultationSaturn,
observer.Longitude, observer.Latitude, observer.Height,
OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Planet != OccultationSaturn || result.TargetID != "Saturn" {
t.Fatalf("target = %v/%q, want Saturn", result.Planet, result.TargetID)
}
if result.Observer != observer {
t.Fatalf("observer = %+v, want %+v", result.Observer, observer)
}
if result.Type != OccultationTotal || !result.HasInternalContacts || !result.ContactsComplete {
t.Fatalf("unexpected event geometry: type=%q internal=%v complete=%v", result.Type, result.HasInternalContacts, result.ContactsComplete)
}
if result.Greatest.Location() != location || result.ExternalImmersion.Location() != location || result.ExternalEmersion.Location() != location {
t.Fatalf("result times did not preserve the start location: %+v", result)
}
wantGreatest := time.Date(2024, time.August, 21, 10, 49, 10, 0, location)
if delta := math.Abs(result.Greatest.Sub(wantGreatest).Seconds()); delta > 2 {
t.Fatalf("greatest differs from regression baseline by %.3fs: got %v want %v", delta, result.Greatest, wantGreatest)
}
if result.MinimumSeparationArcsec > result.MoonSemidiameterArcsec-result.PlanetSemidiameterArcsec {
t.Fatalf("total event does not fully cover the planet: separation=%.6f Moon=%.6f planet=%.6f",
result.MinimumSeparationArcsec, result.MoonSemidiameterArcsec, result.PlanetSemidiameterArcsec)
}
}
func TestFindBestPlanetOccultationsReturnsVisibleGroundPoint(t *testing.T) {
start := time.Date(2024, time.August, 20, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, time.August, 22, 0, 0, 0, 0, time.UTC)
results, err := FindBestPlanetOccultations(start, end, OccultationSaturn, OccultationSearchOptions{MaxEvents: 1})
if err != nil {
t.Fatalf("FindBestPlanetOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindBestPlanetOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Observer.Longitude < -180 || result.Observer.Longitude > 180 || result.Observer.Latitude < -90 || result.Observer.Latitude > 90 {
t.Fatalf("best observer out of bounds: %+v", result.Observer)
}
if result.Observer.Height != 0 {
t.Fatalf("best observer height = %.1f, want sea level", result.Observer.Height)
}
if !result.VisibleAtGreatest || result.MoonAltitudeAtGreatest < 0 {
t.Fatalf("best event is not visible: altitude=%.6f visible=%v", result.MoonAltitudeAtGreatest, result.VisibleAtGreatest)
}
if !result.HasInternalContacts || !result.ContactsComplete {
t.Fatalf("best event contacts are incomplete: %+v", result)
}
}
func TestFindPlanetOccultationsReturnsPartialDiskEventWithoutInternalContacts(t *testing.T) {
start := time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC)
end := time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC)
// 使用站点到月球的距离计算月球视半径后,-6.5 度仍在内接触外侧 / -6.5 degrees remains just outside internal contact after using the
// 使用站点到月球的距离计算月球视半径 / station-to-Moon distance for the lunar semidiameter.
results, err := FindPlanetOccultations(start, end, OccultationSaturn, -30.072, -6.5, 0, OccultationSearchOptions{})
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Type != OccultationPartial || result.HasInternalContacts {
t.Fatalf("event geometry = %q internal=%v, want partial without internal contacts", result.Type, result.HasInternalContacts)
}
if result.InternalImmersion != (time.Time{}) || result.InternalEmersion != (time.Time{}) {
t.Fatalf("partial event returned internal contacts: C2=%v C3=%v", result.InternalImmersion, result.InternalEmersion)
}
if !result.ContactsComplete || result.ExternalImmersion.IsZero() || result.ExternalEmersion.IsZero() {
t.Fatalf("partial event external contacts are incomplete: %+v", result)
}
if !(result.ExternalImmersion.Before(result.Greatest) && result.Greatest.Before(result.ExternalEmersion)) {
t.Fatalf("partial event contact order is invalid: %+v", result)
}
}
func TestFindPlanetOccultationsValidatesInputs(t *testing.T) {
start := time.Date(2024, time.August, 21, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
tests := []struct {
name string
start time.Time
end time.Time
planet OccultationPlanet
longitude float64
options OccultationSearchOptions
}{
{name: "zero time", planet: OccultationSaturn},
{name: "unordered time", start: start, end: start, planet: OccultationSaturn},
{name: "unsupported planet", start: start, end: end, planet: OccultationPlanet("pluto")},
{name: "invalid observer", start: start, end: end, planet: OccultationSaturn, longitude: math.NaN()},
{name: "invalid options", start: start, end: end, planet: OccultationSaturn, options: OccultationSearchOptions{MaxEvents: -1}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := FindPlanetOccultations(test.start, test.end, test.planet, test.longitude, 0, 0, test.options)
if !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("FindPlanetOccultations() error = %v, want ErrInvalidOccultationInput", err)
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package moon
import (
"time"
"b612.me/astro/basic"
)
// FindStarOccultations 搜索单颗点源恒星的月掩星。
//
// 输入坐标从历元传播并转换到当日视坐标系。此函数不会加载内嵌星表;需要星表搜索时,调用者必须显式加载并选择恒星。经度东为正、纬度北为正,单位为度;高度为平均海平面以上米数。
// FindStarOccultations searches for lunar occultations of one point-source star.
// The input coordinate is propagated from its epoch and converted to the apparent frame of date. This function never loads the embedded star catalog; callers that need catalog searches must load and select stars explicitly. Longitude is east-positive in degrees, latitude is north-positive in degrees, and height is the observer elevation above mean sea level in meters.
func FindStarOccultations(start, end time.Time, star StarCoordinate, longitude, latitude, height float64,
options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return basic.FindStarOccultations(start, end, star, longitude, latitude, height, options)
}
// FindBestStarOccultations 返回窗口内每次恒星月掩的全球海平面几何掩甚点。
// 点选择不使用地平线或可见性评分;查询端点使用核心求解器的 10 ms 数值选择容差。
// FindBestStarOccultations returns the global geometric greatest sea-level point for every stellar occultation whose greatest instant is in the window.
// The point is selected without a horizon or visibility score. Query endpoints use the core solver's 10 ms numerical selection tolerance.
func FindBestStarOccultations(start, end time.Time, star StarCoordinate, options OccultationSearchOptions) ([]StarOccultationInfo, error) {
return basic.FindBestStarOccultations(start, end, star, options)
}
+25
View File
@@ -0,0 +1,25 @@
package moon
import "b612.me/astro/basic"
// StarOccultationDiagramOptions 控制本地恒星月掩轨迹采样。
// StarOccultationDiagramOptions controls local stellar-occultation track sampling.
type StarOccultationDiagramOptions = basic.StarOccultationDiagramOptions
// StarOccultationDiagramFrame 描述一个站心月球与恒星几何采样点。
// StarOccultationDiagramFrame describes one topocentric Moon-star geometry sample.
type StarOccultationDiagramFrame = basic.StarOccultationDiagramFrame
// StarOccultationDiagramResult 包含固定地点的月心恒星轨迹。
// StarOccultationDiagramResult contains a fixed-site Moon-centered stellar track.
type StarOccultationDiagramResult = basic.StarOccultationDiagramResult
// StarOccultationDiagram 为已求解的固定地点恒星月掩计算图形几何数据。
// StarOccultationDiagram computes diagram geometry for an already solved fixed-site stellar occultation.
func StarOccultationDiagram(
info StarOccultationInfo,
star StarCoordinate,
options StarOccultationDiagramOptions,
) StarOccultationDiagramResult {
return basic.StarOccultationDiagram(info, star, options)
}
+198
View File
@@ -0,0 +1,198 @@
package moon
import (
"errors"
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestFindStarOccultationsFindsPointSourceEvent(t *testing.T) {
observer := Observer{Longitude: 121.4737, Latitude: 31.2304, Height: 4}
eventTime := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
tt := occultationTestTimeToTT(eventTime)
ra, dec := occultationTestMoonTopocentricRaDec(tt, observer, -1)
star := StarCoordinate{
ID: "synthetic-point-source",
RA: ra,
Dec: dec,
Epoch: eventTime,
Frame: CoordinateFrameApparentOfDate,
}
start := eventTime.In(time.FixedZone("CST", 8*3600)).Add(-3 * time.Hour)
end := eventTime.In(time.FixedZone("CST", 8*3600)).Add(3 * time.Hour)
results, err := FindStarOccultations(start, end, star,
observer.Longitude, observer.Latitude, observer.Height, OccultationSearchOptions{MaxEvents: 1})
if err != nil {
t.Fatalf("FindStarOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindStarOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Observer != observer {
t.Fatalf("observer = %+v, want %+v", result.Observer, observer)
}
if result.Greatest.Location().String() != "CST" {
t.Fatalf("greatest location = %q, want CST", result.Greatest.Location().String())
}
if result.Type != OccultationTotal {
t.Fatalf("event type = %q, want %q", result.Type, OccultationTotal)
}
if result.MinimumSeparationArcsec > result.MoonSemidiameterArcsec {
t.Fatalf("minimum separation %.3f exceeds lunar radius %.3f", result.MinimumSeparationArcsec, result.MoonSemidiameterArcsec)
}
if result.Immersion.IsZero() || result.Emersion.IsZero() {
t.Fatalf("contact times must be populated: immersion=%v emersion=%v", result.Immersion, result.Emersion)
}
if !result.ContactsComplete {
t.Fatal("contacts must be marked complete")
}
if !(result.Immersion.Before(result.Greatest) && result.Greatest.Before(result.Emersion)) {
t.Fatalf("contact ordering invalid: immersion=%v greatest=%v emersion=%v", result.Immersion, result.Greatest, result.Emersion)
}
if delta := math.Abs(result.Greatest.Sub(eventTime).Seconds()); delta > 60 {
t.Fatalf("greatest differs from synthetic event by %.1fs", delta)
}
if result.MoonAzimuthAtGreatest < 0 || result.MoonAzimuthAtGreatest >= 360 {
t.Fatalf("moon azimuth = %.6f, want [0,360)", result.MoonAzimuthAtGreatest)
}
if result.PositionAngleDeg < 0 || result.PositionAngleDeg >= 360 {
t.Fatalf("position angle = %.6f, want [0,360)", result.PositionAngleDeg)
}
}
func TestFindBestStarOccultationsReturnsTopocentricGroundPoint(t *testing.T) {
observer := Observer{Longitude: 121.4737, Latitude: 31.2304, Height: 4}
eventTime := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
ra, dec := occultationTestMoonTopocentricRaDec(occultationTestTimeToTT(eventTime), observer, -1)
star := StarCoordinate{
ID: "synthetic-best-point-source",
RA: ra,
Dec: dec,
Epoch: eventTime,
Frame: CoordinateFrameApparentOfDate,
}
results, err := FindBestStarOccultations(
eventTime.Add(-3*time.Hour), eventTime.Add(3*time.Hour), star, OccultationSearchOptions{MaxEvents: 1})
if err != nil {
t.Fatalf("FindBestStarOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindBestStarOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if result.Observer.Longitude < -180 || result.Observer.Longitude > 180 || result.Observer.Latitude < -90 || result.Observer.Latitude > 90 {
t.Fatalf("best observer out of bounds: %+v", result.Observer)
}
if result.Observer.Height != 0 {
t.Fatalf("best observer height = %.1f, want sea-level default", result.Observer.Height)
}
if result.MinimumSeparationArcsec > result.MoonSemidiameterArcsec {
t.Fatalf("best point is not an occultation: separation=%.3f radius=%.3f", result.MinimumSeparationArcsec, result.MoonSemidiameterArcsec)
}
if !result.VisibleAtGreatest || result.MoonAltitudeAtGreatest < 0 {
t.Fatalf("best observer must see the Moon: altitude=%.6f visible=%v", result.MoonAltitudeAtGreatest, result.VisibleAtGreatest)
}
if result.Immersion.IsZero() || result.Emersion.IsZero() || !result.Immersion.Before(result.Greatest) || !result.Greatest.Before(result.Emersion) {
t.Fatalf("best point contact ordering invalid: immersion=%v greatest=%v emersion=%v", result.Immersion, result.Greatest, result.Emersion)
}
}
func TestFindStarOccultationsLatitudePrefilterRejectsPolarStar(t *testing.T) {
star := StarCoordinate{
ID: "polar-star",
RA: 0,
Dec: 89,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameICRS,
}
results, err := FindStarOccultations(
time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
star, 0, 0, 0, OccultationSearchOptions{})
if err != nil {
t.Fatalf("FindStarOccultations() error = %v", err)
}
if len(results) != 0 {
t.Fatalf("polar star returned %d events, want none", len(results))
}
}
func TestFindStarOccultationsReturnsCompleteContactsOutsideQueryWindow(t *testing.T) {
observer := Observer{Longitude: 121.4737, Latitude: 31.2304, Height: 4}
eventTime := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
ra, dec := occultationTestMoonTopocentricRaDec(occultationTestTimeToTT(eventTime), observer, -1)
star := StarCoordinate{ID: "clipped-window", RA: ra, Dec: dec, Epoch: eventTime, Frame: CoordinateFrameApparentOfDate}
start := eventTime.Add(-time.Minute)
end := eventTime.Add(time.Minute)
results, err := FindStarOccultations(start, end, star,
observer.Longitude, observer.Latitude, observer.Height, OccultationSearchOptions{})
if err != nil {
t.Fatalf("FindStarOccultations() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("FindStarOccultations() returned %d events, want 1", len(results))
}
result := results[0]
if !result.ContactsComplete || result.Immersion.IsZero() || result.Emersion.IsZero() {
t.Fatalf("incomplete contacts: %+v", result)
}
if !result.Immersion.Before(start) || !result.Emersion.After(end) {
t.Fatalf("contacts were clipped to query window: immersion=%v window=%v..%v emersion=%v", result.Immersion, start, end, result.Emersion)
}
}
func occultationTestTimeToTT(value time.Time) float64 {
return basic.TD2UT(basic.Date2JDE(value.UTC()), true)
}
func occultationTestMoonTopocentricRaDec(tt float64, observer Observer, n int) (float64, float64) {
ra, dec := basic.HMoonGeocentricApparentRaDecN(tt, n)
distanceAU := basic.HMoonAwayN(tt, n) / 149597870.7
ra, dec = basic.TopocentricRaDec(ra, dec, observer.Latitude, observer.Longitude, basic.TD2UT(tt, false), distanceAU, observer.Height)
ra = math.Mod(ra, 360)
if ra < 0 {
ra += 360
}
return ra, dec
}
func hr4799CoordinateForTest() StarCoordinate {
return StarCoordinate{
ID: "HR 4799",
RA: 189.1975,
Dec: -5.831944444444,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -28,
ProperMotionDecMasPerYear: -18,
}
}
func TestFindStarOccultationsValidatesInputs(t *testing.T) {
star := StarCoordinate{
RA: 10,
Dec: 20,
Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameICRS,
}
_, err := FindStarOccultations(time.Time{}, time.Time{}, star, 0, 0, 0, OccultationSearchOptions{})
if !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("FindStarOccultations() error = %v, want ErrInvalidOccultationInput", err)
}
start := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)
_, err = FindStarOccultations(start, start, star, 0, 0, 0, OccultationSearchOptions{})
if !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("FindStarOccultations() unordered range error = %v, want ErrInvalidOccultationInput", err)
}
_, err = FindStarOccultations(start, start.Add(time.Hour), star, math.NaN(), 0, 0, OccultationSearchOptions{})
if !errors.Is(err, ErrInvalidOccultationInput) {
t.Fatalf("FindStarOccultations() observer error = %v, want ErrInvalidOccultationInput", err)
}
}
+17
View File
@@ -0,0 +1,17 @@
package moon_test
import (
"fmt"
"b612.me/astro/moon"
"b612.me/astro/star"
)
func ExampleStarCoordinateFromStarData() {
data, _ := star.StarDataByName("进贤增九")
coordinate, _ := moon.StarCoordinateFromStarData(data)
fmt.Printf("%s %.4f %.4f %s\n", coordinate.ID, coordinate.RA, coordinate.Dec, coordinate.Frame)
// Output:
// 进贤增九 189.1975 -5.8319 j2000
}
+508
View File
@@ -0,0 +1,508 @@
// Package svg 生成自包含的月掩星图。
// Package svg renders self-contained lunar-occultation diagrams.
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
const (
starOccultationSVGDefaultWidth = 1200
starOccultationSVGDefaultHeight = 800
starOccultationSVGMinimumWidth = 480
starOccultationSVGMinimumHeight = 360
starOccultationSVGDefaultZone = 8 * 60 * 60
starOccultationSVGLanguageChinese = "zh"
starOccultationSVGLanguageEnglish = "en"
)
// ErrInvalidStarOccultationSVGOptions 表示画布选项无法容纳地图、事件面板和页脚而不发生重叠。
// ErrInvalidStarOccultationSVGOptions reports canvas options that cannot hold the map, event panel, and footer without overlap.
var ErrInvalidStarOccultationSVGOptions = errors.New("invalid stellar occultation SVG options")
// StarOccultationSVGOptions 控制恒星月掩全球掩带 SVG 输出。
// StarOccultationSVGOptions controls a global stellar-occultation path SVG.
type StarOccultationSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值。
// Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default.
Width int
Height int
// Title 及后续文本字段覆盖自动生成的标签。
// Title and the following text fields override automatically generated labels.
Title string
SummaryText string
GreatestText string
MapTitle string
ContactsTitle string
FooterNote string
// Language 为 "en" 时使用英文,其他值使用中文。
// Language uses English for "en" and Chinese for every other value.
Language string
// Location 控制显示的事件时刻;nil 使用 UTC+8。
// Location controls displayed event times. Nil uses UTC+8.
Location *time.Location
// Projection 选择全球地图投影;零值会为限于单半球的高纬事件自动使用极区地图。
// Projection selects the global map projection. The zero value automatically uses a polar map for a high-latitude event confined to one hemisphere.
Projection MapProjection
// TimeLabelStep 控制可见中心线上的 HH:MM 标签;零值使用 30 分钟,负值禁用标签。
// TimeLabelStep controls HH:MM labels along the visible center line. Zero uses 30 minutes; a negative value disables the labels.
TimeLabelStep time.Duration
}
// FindStarOccultationSVGs 搜索时间窗并渲染其中所有恒星月掩全球掩带。
// 使用调用者提供的坐标,不加载内嵌星表;无错误的空切片表示没有事件。
// FindStarOccultationSVGs searches a time window and renders every global path it finds. It uses the coordinate supplied by the caller and does not load the embedded star catalog. An empty slice without an error means no event.
func FindStarOccultationSVGs(
start, end time.Time,
star moon.StarCoordinate,
pathOptions moon.OccultationPathOptions,
options StarOccultationSVGOptions,
) ([]string, error) {
paths, err := moon.FindStarOccultationPaths(start, end, star, pathOptions)
if err != nil {
return nil, err
}
diagrams := make([]string, 0, len(paths))
for _, path := range paths {
diagram, renderErr := StarOccultationPathSVG(path, options)
if renderErr != nil {
return nil, renderErr
}
diagrams = append(diagrams, diagram)
}
return diagrams, nil
}
// StarOccultationPathSVG 将已计算的恒星月掩全球路径渲染为 SVG。
// 不会运行事件搜索,也不会加载内嵌星表。
// StarOccultationPathSVG renders an already computed global stellar-occultation path. It does not run the event search or load the embedded star catalog.
func StarOccultationPathSVG(
path moon.StarOccultationPath,
options StarOccultationSVGOptions,
) (string, error) {
if err := validateStarOccultationPath(path); err != nil {
return "", err
}
if err := validateStarOccultationSVGOptions(options); err != nil {
return "", err
}
options = normalizeStarOccultationSVGOptions(options)
return renderStarOccultationPathSVG(path, options), nil
}
func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error {
if options.Width > 0 && options.Width < starOccultationSVGMinimumWidth {
return fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumWidth)
}
if options.Height > 0 && options.Height < starOccultationSVGMinimumHeight {
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumHeight)
}
switch options.Projection {
case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar:
default:
return fmt.Errorf("%w: unsupported map projection %q", ErrInvalidStarOccultationSVGOptions, options.Projection)
}
return nil
}
func normalizeStarOccultationSVGOptions(options StarOccultationSVGOptions) StarOccultationSVGOptions {
if options.Width <= 0 {
options.Width = starOccultationSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = starOccultationSVGDefaultHeight
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
}
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
options.Language = starOccultationSVGLanguageEnglish
} else {
options.Language = starOccultationSVGLanguageChinese
}
if options.TimeLabelStep < 0 {
options.TimeLabelStep = 0
} else if options.TimeLabelStep == 0 {
options.TimeLabelStep = 30 * time.Minute
} else if options.TimeLabelStep < time.Minute {
options.TimeLabelStep = time.Minute
}
return options
}
func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
return renderOccultationPathSVG(path, nil, options)
}
func renderOccultationPathSVG(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options StarOccultationSVGOptions,
) string {
projection := resolveStarOccultationMapProjection(path, options.Projection)
options.Projection = MapProjection(projection)
title := starOccultationSVGTitle(path, options)
headerLines := starOccultationSVGHeaderLines(path, options)
headerBottom := 72.0 + float64(len(headerLines))*19
layout := starOccultationSVGLayoutFor(options, headerBottom, projection)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMid meet" style="max-width:100%%;height:auto;display:block" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(layout.mapFrame().ClipDefinition("occultation-map-clip"))
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
layout.width-44, layout.height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
for index, line := range headerLines {
fontSize := 13
fill := "#3b4143"
if index == 0 {
fontSize = 14
fill = "#222222"
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="%d" text-anchor="middle">%s</text>`,
layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line))
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.mapX, layout.mapY-10, html.EscapeString(starOccultationSVGMapTitle(options)))
if planetPath == nil {
writeStarOccultationMap(&b, path, layout, options)
writeStarOccultationEventsPanel(&b, path, layout, options)
} else {
writePlanetOccultationMap(&b, *planetPath, path, layout, options)
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
}
writeStarOccultationFooter(&b, layout, options)
b.WriteString(`</svg>`)
return b.String()
}
func writeStarOccultationMap(
b *strings.Builder,
path moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
layout.mapFrame().WriteOcean(b)
writeStarOccultationGraticule(b, layout)
writeStarOccultationLand(b, layout)
writeStarOccultationBand(b, path, layout)
writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.35, "")
writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.35, "")
writeStarOccultationGeoLine(b, path.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4")
writeStarOccultationVisibleCenterLine(b, path.CenterLine, layout)
writeOccultationTimeMarkers(b, path.CenterLine, layout, options,
[]time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time)
writeStarOccultationEventMarker(b, path.Start, layout, starOccultationEventLabel("start", options.Language), "start")
writeStarOccultationEventMarker(b, path.Greatest, layout, starOccultationEventLabel("greatest", options.Language), "greatest")
writeStarOccultationEventMarker(b, path.End, layout, starOccultationEventLabel("end", options.Language), "end")
layout.mapFrame().WriteFrame(b)
writeStarOccultationLegend(b, layout, options.Language)
}
func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVGLayout) {
layout.mapFrame().WriteGraticule(b, "occultation-map-clip")
if layout.projection != svgmap.ProjectionEquirectangular {
return
}
for _, longitude := range []float64{-120, -60, 0, 60, 120} {
x, _ := layout.project(longitude, 0)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="middle">%.0f°</text>`,
x, layout.mapY+layout.mapHeight+13, longitude)
}
for _, latitude := range []float64{-60, -30, 0, 30, 60} {
_, y := layout.project(0, latitude)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="end">%.0f°</text>`,
layout.mapX-5, y+3, latitude)
}
}
func writeStarOccultationLand(b *strings.Builder, layout starOccultationSVGLayout) {
layout.mapFrame().WriteLand(b, "occultation-map-clip")
}
func writeStarOccultationBand(b *strings.Builder, path moon.StarOccultationPath, layout starOccultationSVGLayout) {
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
"occultation-band-layer", "occultation-band", "#e0ae43", 0.28)
}
func writeOccultationBand(
b *strings.Builder,
northern, southern []moon.OccultationPathPoint,
layout starOccultationSVGLayout,
layerClass, pathClass, color string,
opacity float64,
) {
segments := starOccultationBandFragments(northern, southern, layout.projection)
if len(segments) == 0 {
return
}
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="none">`,
layerClass, color, opacity)
for _, segment := range segments {
fmt.Fprintf(b, `<path class="%s" d="`, pathClass)
for index, point := range segment {
x, y := layout.project(point.longitude, point.latitude)
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, x, y)
}
b.WriteString(`Z"/>`)
}
b.WriteString(`</g>`)
}
func writeStarOccultationGeoLine(
b *strings.Builder,
points []moon.OccultationPathPoint,
layout starOccultationSVGLayout,
className, color string,
strokeWidth float64,
dash string,
) {
for _, segment := range starOccultationPathSegmentsForProjection(points, layout.projection) {
if len(segment) < 2 {
continue
}
fmt.Fprintf(b, `<path class="%s" d="`, className)
for index, point := range segment {
x, y := layout.project(point.Longitude, point.Latitude)
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, x, y)
}
fmt.Fprintf(b, `" clip-path="url(#occultation-map-clip)" fill="none" stroke="%s" stroke-width="%.2f"`, color, strokeWidth)
if dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, dash)
}
b.WriteString(` stroke-linecap="round" stroke-linejoin="round"/>`)
}
}
func writeStarOccultationVisibleCenterLine(
b *strings.Builder,
points []moon.OccultationPathPoint,
layout starOccultationSVGLayout,
) {
visible := make([]moon.OccultationPathPoint, 0, len(points))
flush := func() {
writeStarOccultationGeoLine(b, visible, layout, "visible-center-line", "#087f8c", 2.8, "")
visible = visible[:0]
}
for _, point := range points {
if point.MoonAltitude <= 0 {
flush()
continue
}
if len(visible) > 0 && math.Abs(point.Longitude-visible[len(visible)-1].Longitude) > 180 {
flush()
}
visible = append(visible, point)
}
flush()
}
func writeStarOccultationEventMarker(
b *strings.Builder,
point moon.OccultationPathPoint,
layout starOccultationSVGLayout,
label, kind string,
) {
x, y, visible := layout.mapFrame().Project(point.Longitude, point.Latitude)
if !visible {
return
}
placement := starOccultationDefaultEventMarkerPlacement(x, y, kind, layout)
writeStarOccultationProjectedEventMarker(b, x, y, label, kind, placement)
}
type starOccultationEventMarkerPlacement struct {
labelX, labelY float64
anchor string
leader bool
}
func starOccultationDefaultEventMarkerPlacement(
x, y float64,
kind string,
layout starOccultationSVGLayout,
) starOccultationEventMarkerPlacement {
placement := starOccultationEventMarkerPlacement{
labelX: x - 7,
labelY: y - 8,
anchor: "end",
}
if kind == "greatest" {
placement.anchor = "middle"
placement.labelX = x
placement.labelY = y - 10
} else if kind == "end" || kind == "total-end" {
placement.anchor = "start"
placement.labelX = x + 7
}
placement.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, placement.labelY))
return placement
}
func writeStarOccultationProjectedEventMarker(
b *strings.Builder,
x, y float64,
label, kind string,
placement starOccultationEventMarkerPlacement,
) {
color := "#273d49"
radius := 4.0
strokeWidth := 1.4
if kind == "greatest" {
color = "#c44336"
radius = 5.2
} else if kind == "total-start" || kind == "total-end" {
color = "#087f8c"
radius = 3.2
strokeWidth = 1.0
}
fmt.Fprintf(b, `<g class="event-marker event-%s">`, kind)
if placement.leader {
leaderX := placement.labelX
switch placement.anchor {
case "end":
leaderX += 3
case "start":
leaderX -= 3
}
leaderY := placement.labelY + 3
if placement.labelY > y {
leaderY = placement.labelY - 12
}
fmt.Fprintf(b, `<line class="event-marker-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#52646b" stroke-width="0.9" vector-effect="non-scaling-stroke"/>`,
x, y, leaderX, leaderY)
}
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="%s" stroke="#ffffff" stroke-width="%.1f"/>`,
x, y, radius, color, strokeWidth)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#182124" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text></g>`,
placement.labelX, placement.labelY, placement.anchor, html.EscapeString(label))
}
func writeStarOccultationLegend(b *strings.Builder, layout starOccultationSVGLayout, language string) {
y := layout.mapY + layout.mapHeight + 30
labels := []string{"可见中心线", "几何中心线", "掩带边界"}
if language == starOccultationSVGLanguageEnglish {
labels = []string{"Visible center line", "Geometric center line", "Occultation limits"}
}
available := layout.mapWidth / 3
styles := []struct {
color string
dash string
}{
{"#087f8c", ""},
{"#59676b", "5 4"},
{"#a66f18", ""},
}
for index, label := range labels {
x := layout.mapX + float64(index)*available
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="2"`, x, y-4, x+22, y-4, styles[index].color)
if styles[index].dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, styles[index].dash)
}
b.WriteString(`/>`)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
x+28, y, html.EscapeString(label))
}
}
func writeStarOccultationEventsPanel(
b *strings.Builder,
path moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
writeOccultationEventsPanel(b, starOccultationSVGEventRows(path, options.Language), layout, options)
}
func writeOccultationEventsPanel(
b *strings.Builder,
rows []starOccultationEventRow,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.mapHeight)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.panelX, layout.panelY+13, html.EscapeString(starOccultationSVGContactsTitle(options)))
rowTop := layout.panelY + 27
rowHeight := math.Max(34, (layout.mapHeight-27)/float64(len(rows)))
for index, row := range rows {
y := rowTop + float64(index)*rowHeight
if index > 0 {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e0e1df" stroke-width="0.8"/>`,
layout.panelX, y-5, layout.panelX+layout.panelWidth, y-5)
}
pointTime := row.point.Time.In(options.Location)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="12" font-weight="700">%s</text>`,
layout.panelX, y+11, html.EscapeString(row.name))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" text-anchor="end">%s</text>`,
layout.panelX+layout.panelWidth, y+11, html.EscapeString(pointTime.Format("15:04:05.0")))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4c585a" font-family="Arial, sans-serif" font-size="10">%s</text>`,
layout.panelX, y+29, html.EscapeString(starOccultationFormatCoordinates(row.point.Longitude, row.point.Latitude)))
if rowHeight >= 59 {
altitudeLabel := "Moon alt."
if options.Language != starOccultationSVGLanguageEnglish {
altitudeLabel = "月球高度"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#697476" font-family="Arial, sans-serif" font-size="10">%s %s</text>`,
layout.panelX, y+45, html.EscapeString(altitudeLabel), html.EscapeString(starOccultationFormatSignedDegree(row.point.MoonAltitude)))
}
}
}
func writeStarOccultationFooter(
b *strings.Builder,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
lines := starOccultationWrapText(starOccultationSVGFooter(options), layout.width-80, 11)
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
layout.footerY+float64(index)*15, html.EscapeString(line))
}
}
func starOccultationEventLabel(kind, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch kind {
case "start":
return "Start"
case "greatest":
return "Greatest"
default:
return "End"
}
}
switch kind {
case "start":
return "掩始"
case "greatest":
return "掩甚"
default:
return "掩终"
}
}
+777
View File
@@ -0,0 +1,777 @@
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/internal/svgasset"
"b612.me/astro/moon"
)
const (
localStarOccultationSVGDefaultWidth = 920
localStarOccultationSVGDefaultHeight = 720
localStarOccultationSVGMinimumWidth = 640
localStarOccultationSVGMinimumHeight = 520
localStarOccultationSVGDefaultStep = 2 * time.Minute
)
// ErrInvalidLocalStarOccultationSVGOptions 表示本地图表选项无效。
// ErrInvalidLocalStarOccultationSVGOptions reports invalid local chart options.
var ErrInvalidLocalStarOccultationSVGOptions = errors.New("invalid local stellar occultation SVG options")
// ErrInvalidLocalStarOccultationInfo 表示固定地点事件数据格式错误。
// ErrInvalidLocalStarOccultationInfo reports malformed fixed-site event data.
var ErrInvalidLocalStarOccultationInfo = errors.New("invalid local stellar occultation info")
// LocalStarOccultationSVGOptions 控制固定地点月球与恒星盘面图。
// LocalStarOccultationSVGOptions controls a fixed-site Moon-star disk chart.
type LocalStarOccultationSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;非正值使用默认值。
// Width and Height are SVG canvas dimensions. Values <= 0 use defaults.
Width int
Height int
// Step 是月心恒星轨迹请求采样步长;零值使用两分钟,负值无效,正值小于一秒时提升到一秒。长事件可能增大实际步长,以保持基础轨迹在有界采样预算内。
// Step is the requested Moon-centered stellar-track sampling step. Zero uses two minutes; negative values are invalid. Positive values below one second are raised to one second, and long events may use a larger effective step to keep the base track within its bounded sample budget.
Step time.Duration
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
SummaryText string
GreatestText string
OverviewTitle string
PhasePanelsTitle string
ContactsTitle string
DirectionText string
FooterNote string
// Language 为 "en" 时使用英文,其他值使用中文。
// Language uses English for "en" and Chinese for every other value.
Language string
// Location 控制显示的事件时刻;nil 使用 UTC+8。
// Location controls displayed event times. Nil uses UTC+8.
Location *time.Location
}
type localStarOccultationSVGLayout struct {
width float64
height float64
margin float64
overviewLeft float64
overviewRight float64
overviewTop float64
overviewBottom float64
panelX float64
panelWidth float64
stageTop float64
stageBottom float64
footerY float64
}
type localStarOccultationEventFrame struct {
label string
name string
time time.Time
frame moon.StarOccultationDiagramFrame
}
// FindLocalStarOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次点光源恒星月掩。不会加载内嵌星表;空切片表示没有本地事件。
// FindLocalStarOccultationSVGs searches one fixed observing site and renders every point-source stellar occultation in the requested time window. It does not load the embedded star catalog; an empty slice means no local event.
func FindLocalStarOccultationSVGs(
start, end time.Time,
star moon.StarCoordinate,
longitude, latitude, height float64,
searchOptions moon.OccultationSearchOptions,
options LocalStarOccultationSVGOptions,
) ([]string, error) {
if err := validateLocalStarOccultationSVGOptions(options); err != nil {
return nil, err
}
events, err := moon.FindStarOccultations(start, end, star, longitude, latitude, height, searchOptions)
if err != nil {
return nil, err
}
diagrams := make([]string, 0, len(events))
for _, event := range events {
diagram, renderErr := LocalStarOccultationSVG(event, star, options)
if renderErr != nil {
return nil, renderErr
}
diagrams = append(diagrams, diagram)
}
return diagrams, nil
}
// LocalStarOccultationSVG 渲染已求解的固定地点恒星月掩的月心轨迹及掩始、掩甚、掩终阶段面板;不会运行事件搜索或加载星表。
// LocalStarOccultationSVG renders an already solved fixed-site stellar occultation as a Moon-centered track with immersion, greatest, and emersion stage panels. It does not run the event search or load the star catalog.
func LocalStarOccultationSVG(
info moon.StarOccultationInfo,
star moon.StarCoordinate,
options LocalStarOccultationSVGOptions,
) (string, error) {
if err := validateLocalStarOccultationInfo(info, star); err != nil {
return "", err
}
if err := validateLocalStarOccultationSVGOptions(options); err != nil {
return "", err
}
options = normalizeLocalStarOccultationSVGOptions(options)
diagram := moon.StarOccultationDiagram(info, star, moon.StarOccultationDiagramOptions{
StepDays: options.Step.Hours() / 24,
})
if len(diagram.Frames) == 0 {
return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalStarOccultationInfo)
}
return renderLocalStarOccultationSVG(info, diagram, options), nil
}
func validateLocalStarOccultationSVGOptions(options LocalStarOccultationSVGOptions) error {
if options.Width > 0 && options.Width < localStarOccultationSVGMinimumWidth {
return fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidLocalStarOccultationSVGOptions, localStarOccultationSVGMinimumWidth)
}
if options.Height > 0 && options.Height < localStarOccultationSVGMinimumHeight {
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidLocalStarOccultationSVGOptions, localStarOccultationSVGMinimumHeight)
}
if options.Step < 0 {
return fmt.Errorf("%w: step cannot be negative", ErrInvalidLocalStarOccultationSVGOptions)
}
return nil
}
func validateLocalStarOccultationInfo(info moon.StarOccultationInfo, star moon.StarCoordinate) error {
if err := star.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidLocalStarOccultationInfo, err)
}
if info.TargetID != "" && star.ID != "" && info.TargetID != star.ID {
return fmt.Errorf("%w: event target %q does not match star %q", ErrInvalidLocalStarOccultationInfo, info.TargetID, star.ID)
}
if err := info.Observer.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidLocalStarOccultationInfo, err)
}
if !info.ContactsComplete || info.Immersion.IsZero() || info.Greatest.IsZero() || info.Emersion.IsZero() {
return fmt.Errorf("%w: complete immersion, greatest, and emersion times are required", ErrInvalidLocalStarOccultationInfo)
}
if info.Greatest.Before(info.Immersion) || info.Emersion.Before(info.Greatest) {
return fmt.Errorf("%w: event times must be ordered", ErrInvalidLocalStarOccultationInfo)
}
if info.Type != moon.OccultationTotal && info.Type != moon.OccultationGrazing {
return fmt.Errorf("%w: unsupported point-source geometry %q", ErrInvalidLocalStarOccultationInfo, info.Type)
}
if !starOccultationFinite(info.MinimumSeparationArcsec) || info.MinimumSeparationArcsec < 0 ||
!starOccultationFinite(info.MoonSemidiameterArcsec) || info.MoonSemidiameterArcsec <= 0 ||
!starOccultationFinite(info.PositionAngleDeg) ||
!starOccultationFinite(info.MoonAltitudeAtGreatest) ||
!starOccultationFinite(info.MoonAzimuthAtGreatest) {
return fmt.Errorf("%w: event geometry must be finite", ErrInvalidLocalStarOccultationInfo)
}
return nil
}
func normalizeLocalStarOccultationSVGOptions(options LocalStarOccultationSVGOptions) LocalStarOccultationSVGOptions {
if options.Width <= 0 {
options.Width = localStarOccultationSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = localStarOccultationSVGDefaultHeight
}
if options.Step <= 0 {
options.Step = localStarOccultationSVGDefaultStep
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
}
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
options.Language = starOccultationSVGLanguageEnglish
} else {
options.Language = starOccultationSVGLanguageChinese
}
return options
}
func renderLocalStarOccultationSVG(
info moon.StarOccultationInfo,
diagram moon.StarOccultationDiagramResult,
options LocalStarOccultationSVGOptions,
) string {
title := localStarOccultationSVGTitle(info, options)
headerLines := localStarOccultationSVGHeaderLines(info, options)
headerBottom := 72.0 + float64(len(headerLines))*19
layout := localStarOccultationSVGLayoutFor(options, headerBottom)
events := localStarOccultationSVGEventFrames(info, diagram.Frames, options.Language)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`<marker id="local-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></marker>`)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`, layout.width-44, layout.height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
for index, line := range headerLines {
fontSize, fill := 13, "#3b4143"
if index == 0 {
fontSize, fill = 14, "#222222"
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="%d" text-anchor="middle">%s</text>`,
layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line))
}
writeLocalStarOccultationOverview(&b, diagram, events, layout, options)
writeLocalStarOccultationContacts(&b, events, layout, options)
writeLocalStarOccultationStages(&b, events, layout, options)
writeLocalStarOccultationFooter(&b, layout, options)
b.WriteString(`</svg>`)
return b.String()
}
func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, headerBottom float64) localStarOccultationSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(30, math.Min(44, width*0.05))
gap := math.Max(18, math.Min(24, width*0.025))
panelWidth := math.Max(188, math.Min(236, width*0.25))
overviewLeft := margin
overviewRight := width - margin - panelWidth - gap
footerSpace := 64.0
stageHeight := math.Max(132, math.Min(170, height*0.23))
stageBottom := height - footerSpace
stageTop := stageBottom - stageHeight
overviewTop := headerBottom + 26
overviewBottom := stageTop - 20
return localStarOccultationSVGLayout{
width: width,
height: height,
margin: margin,
overviewLeft: overviewLeft,
overviewRight: overviewRight,
overviewTop: overviewTop,
overviewBottom: overviewBottom,
panelX: overviewRight + gap,
panelWidth: panelWidth,
stageTop: stageTop,
stageBottom: stageBottom,
footerY: height - 43,
}
}
func writeLocalStarOccultationOverview(
b *strings.Builder,
diagram moon.StarOccultationDiagramResult,
events []localStarOccultationEventFrame,
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.overviewLeft, layout.overviewTop-10, html.EscapeString(localStarOccultationSVGOverviewTitle(options)))
cx := (layout.overviewLeft + layout.overviewRight) / 2
cy := (layout.overviewTop+layout.overviewBottom)/2 + 4
extent := localStarOccultationSVGExtent(diagram.Frames)
availableWidth := layout.overviewRight - layout.overviewLeft - 74
availableHeight := layout.overviewBottom - layout.overviewTop - 50
scale := math.Min(availableWidth/(2*extent), availableHeight/(2*extent))
if !starOccultationFinite(scale) || scale <= 0 {
scale = 1
}
mapX := func(value float64) float64 { return cx - value*scale }
mapY := func(value float64) float64 { return cy - value*scale }
moonRadius := localStarOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon")
writeLocalStarOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalStarOccultationTrack(b, diagram.Frames, mapX, mapY)
for _, event := range events {
x := mapX(event.frame.StarXArcsec)
y := mapY(event.frame.StarYArcsec)
writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "overview-star", event.label)
writeLocalStarOccultationOverviewLabel(b, event, x, y, cx, cy, options.Language)
}
}
func localStarOccultationSVGExtent(frames []moon.StarOccultationDiagramFrame) float64 {
extent := 1.0
for _, frame := range frames {
extent = math.Max(extent, frame.MoonRadiusArcsec)
extent = math.Max(extent, math.Abs(frame.StarXArcsec))
extent = math.Max(extent, math.Abs(frame.StarYArcsec))
}
return extent * 1.28
}
func localStarOccultationSVGMaximumMoonRadius(frames []moon.StarOccultationDiagramFrame) float64 {
radius := 1.0
for _, frame := range frames {
radius = math.Max(radius, frame.MoonRadiusArcsec)
}
return radius
}
func writeLocalStarOccultationTrack(
b *strings.Builder,
frames []moon.StarOccultationDiagramFrame,
mapX, mapY func(float64) float64,
) {
if len(frames) < 2 {
return
}
var path strings.Builder
for index, frame := range frames {
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.StarXArcsec), mapY(frame.StarYArcsec))
}
fmt.Fprintf(b, `<path class="local-star-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
}
func writeLocalStarOccultationLunarPath(
b *strings.Builder,
frames []moon.StarOccultationDiagramFrame,
mapX, mapY func(float64) float64,
extent float64,
language string,
) {
unitX, unitY, ok := localStarOccultationSVGLunarPathDirection(frames)
if !ok {
return
}
lineExtent := extent * 0.92
startX := mapX(-unitX * lineExtent)
startY := mapY(-unitY * lineExtent)
endX := mapX(unitX * lineExtent)
endY := mapY(unitY * lineExtent)
if !starOccultationFinite(startX) || !starOccultationFinite(startY) ||
!starOccultationFinite(endX) || !starOccultationFinite(endY) {
return
}
fmt.Fprintf(b, `<line class="lunar-path-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#506975" stroke-width="1.1" stroke-dasharray="7 4" opacity="0.86"/>`,
startX, startY, endX, endY)
labelX, labelY, anchor := startX, startY, "end"
if endX < startX {
labelX, labelY = endX, endY
}
fmt.Fprintf(b, `<text class="lunar-path-label" x="%.3f" y="%.3f" fill="#405966" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="%s">%s</text>`,
labelX-6, labelY-6, anchor, html.EscapeString(localStarOccultationSVGLunarPathLabel(language)))
}
// 恒星轨迹相对固定月心测量,因此其切线与月球站心视运动反向。参考线没有箭头,因此归一化轨迹切线就是图表使用的月掩路径轴。
// The star track is measured relative to a fixed lunar center, so its tangent is antiparallel to the Moon's topocentric apparent motion. A reference line has no arrow, therefore the normalized track tangent is the same lunar-path axis used by the chart.
func localStarOccultationSVGLunarPathDirection(frames []moon.StarOccultationDiagramFrame) (float64, float64, bool) {
if len(frames) < 2 {
return 0, 0, false
}
greatest := -1
for index, frame := range frames {
if localStarOccultationSVGFrameHasLabel(frame, "Greatest") {
greatest = index
break
}
}
left, right := 0, len(frames)-1
if greatest > 0 && greatest+1 < len(frames) {
left, right = greatest-1, greatest+1
} else if greatest == 0 {
left, right = 0, 1
} else if greatest == len(frames)-1 {
left, right = len(frames)-2, len(frames)-1
}
dx := frames[right].StarXArcsec - frames[left].StarXArcsec
dy := frames[right].StarYArcsec - frames[left].StarYArcsec
length := math.Hypot(dx, dy)
if !starOccultationFinite(length) || length == 0 {
return 0, 0, false
}
return dx / length, dy / length, true
}
func localStarOccultationSVGLunarPathLabel(language string) string {
if language == starOccultationSVGLanguageEnglish {
return "Lunar path"
}
return "白道"
}
func writeLocalStarOccultationMoon(b *strings.Builder, cx, cy, radius float64, class string) {
fmt.Fprintf(b, `<g class="%s">`, html.EscapeString(class))
fmt.Fprintf(b, `<use href="#le-moon" x="%.3f" y="%.3f" width="%.3f" height="%.3f"/>`,
cx-radius, cy-radius, radius*2, radius*2)
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#4f5d60" stroke-width="1.1" opacity="0.9"/>`, cx, cy, radius)
b.WriteString(`</g>`)
}
func writeLocalStarOccultationAxes(b *strings.Builder, cx, cy, radius float64, language string) {
north, east, west, south := "北", "东", "西", "南"
if language == starOccultationSVGLanguageEnglish {
north, east, west, south = "N", "E", "W", "S"
}
labels := []struct {
x, y float64
anchor string
text string
}{
{cx, cy - radius - 15, "middle", north},
{cx - radius - 18, cy + 4, "middle", east},
{cx + radius + 18, cy + 4, "middle", west},
{cx, cy + radius + 24, "middle", south},
}
for _, label := range labels {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700" text-anchor="%s">%s</text>`,
label.x, label.y, label.anchor, html.EscapeString(label.text))
}
}
func writeLocalStarOccultationStar(b *strings.Builder, x, y float64, hidden bool, class, label string) {
fill, stroke, dash := "#fff7d6", "#9b3028", ""
if hidden {
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
}
fmt.Fprintf(b, `<g class="%s" data-label="%s">`, html.EscapeString(class), html.EscapeString(label))
fmt.Fprintf(b, `<circle cx="%.3f" cy="%.3f" r="4.2" fill="%s" stroke="%s" stroke-width="1.4"%s/>`, x, y, fill, stroke, dash)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x-6, y, x+6, y, stroke)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1"/>`, x, y-6, x, y+6, stroke)
b.WriteString(`</g>`)
}
func writeLocalStarOccultationOverviewLabel(
b *strings.Builder,
event localStarOccultationEventFrame,
x, y, cx, cy float64,
language string,
) {
dx, dy, anchor := 8.0, -9.0, "start"
if x > cx {
dx, anchor = -8, "end"
}
if y < cy-20 {
dy = 15
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#792d28" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(localStarOccultationSVGEventName(event.label, language)))
}
func writeLocalStarOccultationContacts(
b *strings.Builder,
events []localStarOccultationEventFrame,
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
layout.panelX-12, layout.overviewTop-12, layout.panelX-12, layout.overviewBottom)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.panelX, layout.overviewTop-10, html.EscapeString(localStarOccultationSVGContactsTitle(options)))
available := layout.overviewBottom - layout.overviewTop - 10
rowHeight := math.Min(86, available/math.Max(1, float64(len(events))))
for index, event := range events {
y := layout.overviewTop + 17 + float64(index)*rowHeight
if index > 0 {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e0e1df" stroke-width="0.8"/>`,
layout.panelX, y-13, layout.panelX+layout.panelWidth, y-13)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="12" font-weight="700">%s</text>`,
layout.panelX, y, html.EscapeString(event.name))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" text-anchor="end">%s</text>`,
layout.panelX+layout.panelWidth, y, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#536063" font-family="Arial, sans-serif" font-size="10">PA %.1f° | %s %.1f°</text>`,
layout.panelX, y+18, event.frame.PositionAngleDeg, localStarOccultationSVGAltitudeLabel(options.Language), event.frame.MoonAltitudeDeg)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b7577" font-family="Arial, sans-serif" font-size="10">%s %.1f° | %s</text>`,
layout.panelX, y+34, localStarOccultationSVGAzimuthLabel(options.Language), event.frame.MoonAzimuthDeg,
html.EscapeString(localStarOccultationSVGVisibilityText(event.frame.MoonAltitudeDeg >= 0, options.Language)))
}
}
func writeLocalStarOccultationStages(
b *strings.Builder,
events []localStarOccultationEventFrame,
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.margin, layout.stageTop+13, html.EscapeString(localStarOccultationSVGPhasePanelsTitle(options)))
if len(events) == 0 {
return
}
usableWidth := layout.width - 2*layout.margin
columnWidth := usableWidth / float64(len(events))
maxMoonRadius := localStarOccultationSVGMaximumMoonRadiusFromEvents(events)
radius := math.Min(46, math.Max(31, (layout.stageBottom-layout.stageTop-53)/2))
scale := radius / maxMoonRadius
cy := layout.stageTop + 34 + radius
for index, event := range events {
cx := layout.margin + columnWidth*(float64(index)+0.5)
writeLocalStarOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon")
x := cx - event.frame.StarXArcsec*scale
y := cy - event.frame.StarYArcsec*scale
writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "stage-star", event.label)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy+radius+19, html.EscapeString(event.name))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#667174" font-family="Arial, sans-serif" font-size="10" text-anchor="middle">%s</text>`,
cx, cy+radius+34, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
}
}
func localStarOccultationSVGMaximumMoonRadiusFromEvents(events []localStarOccultationEventFrame) float64 {
radius := 1.0
for _, event := range events {
radius = math.Max(radius, event.frame.MoonRadiusArcsec)
}
return radius
}
func writeLocalStarOccultationFooter(
b *strings.Builder,
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
directionLines := starOccultationWrapText(localStarOccultationSVGDirectionText(options), layout.width-80, 10)
for index, line := range directionLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
layout.margin, layout.footerY+float64(index)*14-8, html.EscapeString(line))
}
footerLines := starOccultationWrapText(localStarOccultationSVGFooterNote(options), layout.width-80, 9)
for index, line := range footerLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" font-family="Georgia, 'Times New Roman', serif" font-size="9">%s</text>`,
layout.margin, layout.footerY+float64(len(directionLines))*14+float64(index)*12-6, html.EscapeString(line))
}
}
func localStarOccultationSVGEventFrames(
info moon.StarOccultationInfo,
frames []moon.StarOccultationDiagramFrame,
language string,
) []localStarOccultationEventFrame {
times := map[string]time.Time{
"Immersion": info.Immersion,
"Greatest": info.Greatest,
"Emersion": info.Emersion,
}
labels := []string{"Immersion", "Greatest", "Emersion"}
result := make([]localStarOccultationEventFrame, 0, len(labels))
for _, label := range labels {
for _, frame := range frames {
if localStarOccultationSVGFrameHasLabel(frame, label) {
result = append(result, localStarOccultationEventFrame{
label: label,
name: localStarOccultationSVGEventName(label, language),
time: times[label],
frame: frame,
})
break
}
}
}
return result
}
func localStarOccultationSVGFrameHasLabel(frame moon.StarOccultationDiagramFrame, label string) bool {
if frame.Label == label {
return true
}
for _, current := range frame.Labels {
if current == label {
return true
}
}
return false
}
func localStarOccultationSVGHeaderLines(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) []string {
lines := make([]string, 0, 4)
for _, value := range []string{
localStarOccultationSVGSummaryText(info, options),
localStarOccultationSVGGreatestText(info, options),
} {
lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...)
}
return lines
}
func localStarOccultationSVGTitle(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string {
if options.Title != "" {
return options.Title
}
target := info.TargetID
if target == "" {
if options.Language == starOccultationSVGLanguageEnglish {
target = "star"
} else {
target = "恒星"
}
}
date := info.Greatest.In(options.Location).Format("2006-01-02")
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("%s Local Lunar Occultation of %s", date, target)
}
return fmt.Sprintf("%s 指定地点月掩%s", date, target)
}
func localStarOccultationSVGSummaryText(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string {
if options.SummaryText != "" {
return options.SummaryText
}
coordinates := starOccultationFormatCoordinates(info.Observer.Longitude, info.Observer.Latitude)
duration := localStarOccultationSVGFormatDuration(info.Emersion.Sub(info.Immersion), options.Language)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Site %s | elevation %.0f m | %s | duration %s", coordinates, info.Observer.Height,
localStarOccultationSVGTypeName(info.Type, options.Language), duration)
}
return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | 掩星历时 %s", coordinates, info.Observer.Height,
localStarOccultationSVGTypeName(info.Type, options.Language), duration)
}
func localStarOccultationSVGGreatestText(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string {
if options.GreatestText != "" {
return options.GreatestText
}
value := info.Greatest.In(options.Location)
zone := starOccultationLocationLabel(value, options.Location)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest %s %s | minimum separation %.2f arcsec | Moon altitude %+.1f° azimuth %.1f°",
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec, info.MoonAltitudeAtGreatest, info.MoonAzimuthAtGreatest)
}
return fmt.Sprintf("掩甚 %s %s | 最小角距 %.2f 角秒 | 月球高度 %+.1f° 方位 %.1f°",
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec, info.MoonAltitudeAtGreatest, info.MoonAzimuthAtGreatest)
}
func localStarOccultationSVGOverviewTitle(options LocalStarOccultationSVGOptions) string {
if options.OverviewTitle != "" {
return options.OverviewTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Topocentric star track"
}
return "站心恒星轨迹"
}
func localStarOccultationSVGPhasePanelsTitle(options LocalStarOccultationSVGOptions) string {
if options.PhasePanelsTitle != "" {
return options.PhasePanelsTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Contact stages"
}
return "掩始、掩甚与掩终视圆"
}
func localStarOccultationSVGContactsTitle(options LocalStarOccultationSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Local contacts"
}
return "本地接触时刻"
}
func localStarOccultationSVGDirectionText(options LocalStarOccultationSVGOptions) string {
if options.DirectionText != "" {
return options.DirectionText
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track."
}
return "月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为恒星相对月心轨迹。"
}
func localStarOccultationSVGFooterNote(options LocalStarOccultationSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
if options.Language == starOccultationSVGLanguageEnglish {
return "The star is a point source; immersion and emersion occur where it crosses the topocentric apparent lunar limb. Lunar texture is schematic."
}
return "恒星按点光源绘制;掩始和掩终是恒星穿越站心月球视圆外缘的时刻,月面纹理仅作方向辅助。"
}
func localStarOccultationSVGEventName(label, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch label {
case "Immersion":
return "Immersion"
case "Greatest":
return "Greatest"
case "Emersion":
return "Emersion"
}
}
switch label {
case "Immersion":
return "掩始"
case "Greatest":
return "掩甚"
case "Emersion":
return "掩终"
default:
return label
}
}
func localStarOccultationSVGTypeName(eventType moon.OccultationType, language string) string {
if language == starOccultationSVGLanguageEnglish {
if eventType == moon.OccultationGrazing {
return "grazing"
}
return "total"
}
if eventType == moon.OccultationGrazing {
return "擦边掩星"
}
return "全掩"
}
func localStarOccultationSVGFormatDuration(duration time.Duration, language string) string {
if duration < 0 {
duration = -duration
}
duration = duration.Round(100 * time.Millisecond)
hours := int(duration / time.Hour)
duration -= time.Duration(hours) * time.Hour
minutes := int(duration / time.Minute)
duration -= time.Duration(minutes) * time.Minute
seconds := float64(duration) / float64(time.Second)
if language == starOccultationSVGLanguageEnglish {
if hours > 0 {
return fmt.Sprintf("%dh %02dm %04.1fs", hours, minutes, seconds)
}
return fmt.Sprintf("%dm %04.1fs", minutes, seconds)
}
if hours > 0 {
return fmt.Sprintf("%d时%02d分%04.1f秒", hours, minutes, seconds)
}
return fmt.Sprintf("%d分%04.1f秒", minutes, seconds)
}
func localStarOccultationSVGAltitudeLabel(language string) string {
if language == starOccultationSVGLanguageEnglish {
return "alt"
}
return "高度"
}
func localStarOccultationSVGAzimuthLabel(language string) string {
if language == starOccultationSVGLanguageEnglish {
return "az"
}
return "方位"
}
func localStarOccultationSVGVisibilityText(visible bool, language string) string {
if language == starOccultationSVGLanguageEnglish {
if visible {
return "above horizon"
}
return "below horizon"
}
if visible {
return "地平线上"
}
return "地平线下"
}
+173
View File
@@ -0,0 +1,173 @@
package svg
import (
"errors"
"math"
"strings"
"testing"
"time"
"b612.me/astro/moon"
)
func TestFindLocalStarOccultationSVGsHR4799(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
diagrams, err := FindLocalStarOccultationSVGs(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799StarCoordinate(),
121.56601, 6.80706, 0,
moon.OccultationSearchOptions{},
LocalStarOccultationSVGOptions{Width: 720, Height: 560, Location: location},
)
if err != nil {
t.Fatalf("FindLocalStarOccultationSVGs() error = %v", err)
}
if len(diagrams) != 1 {
t.Fatalf("FindLocalStarOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
}
diagram := diagrams[0]
for _, want := range []string{
`<svg`, `width="720"`, `height="560"`, "2025-06-05", "HR 4799",
"指定地点月掩", "观测点", "站心恒星轨迹", "掩始、掩甚与掩终视圆",
"本地接触时刻", "掩始", "掩甚", "掩终", "左东右西", "UTC+8",
`class="local-star-track"`, `class="overview-moon"`, `class="overview-star"`,
`class="lunar-path-line"`, `class="lunar-path-label"`, "白道",
`<symbol id="le-moon"`, `href="#le-moon"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("local SVG missing %q", want)
}
}
if strings.Contains(diagram, "local-occultation-moon") || strings.Contains(diagram, "<radialGradient") {
t.Fatalf("local SVG still contains the temporary synthetic Moon rendering")
}
if strings.Contains(diagram, "全球掩带") || strings.Contains(diagram, "全球中心线") {
t.Fatalf("local SVG unexpectedly contains global-path labels")
}
if got := strings.Count(diagram, `class="stage-moon"`); got != 3 {
t.Fatalf("stage Moon count = %d, want 3", got)
}
if got := strings.Count(diagram, `class="stage-star"`); got != 3 {
t.Fatalf("stage star count = %d, want 3", got)
}
if err := validateXML(diagram); err != nil {
t.Fatalf("generated local SVG is not valid XML: %v", err)
}
}
func TestLocalStarOccultationSVGEnglishAndCustomText(t *testing.T) {
info := localHR4799Occultation(t)
diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Language: "en",
Location: time.UTC,
Title: "Custom local title",
SummaryText: "Custom local summary",
GreatestText: "Custom local greatest",
OverviewTitle: "Custom local overview",
PhasePanelsTitle: "Custom local stages",
ContactsTitle: "Custom local contacts",
DirectionText: "Custom local direction",
FooterNote: "Custom local footer",
})
if err != nil {
t.Fatalf("LocalStarOccultationSVG() error = %v", err)
}
for _, want := range []string{
"Custom local title", "Custom local summary", "Custom local greatest",
"Custom local overview", "Custom local stages", "Custom local contacts",
"Custom local direction", "Custom local footer", "Immersion", "Greatest", "Emersion", "Lunar path",
} {
if !strings.Contains(diagram, want) {
t.Fatalf("English local SVG missing %q", want)
}
}
}
func TestLocalStarOccultationSVGLunarPathUsesGreatestTangent(t *testing.T) {
frames := []moon.StarOccultationDiagramFrame{
{StarXArcsec: 100, StarYArcsec: 100},
{StarXArcsec: -1, StarYArcsec: -2},
{StarXArcsec: 0, StarYArcsec: 0, Label: "Greatest", Labels: []string{"Greatest"}},
{StarXArcsec: 1, StarYArcsec: 2},
{StarXArcsec: -100, StarYArcsec: 100},
}
unitX, unitY, ok := localStarOccultationSVGLunarPathDirection(frames)
if !ok {
t.Fatalf("localStarOccultationSVGLunarPathDirection() returned no direction")
}
if math.Abs(unitX-1/math.Sqrt(5)) > 1e-12 || math.Abs(unitY-2/math.Sqrt(5)) > 1e-12 {
t.Fatalf("lunar path direction = %.12f %.12f, want greatest tangent", unitX, unitY)
}
}
func TestFindLocalStarOccultationSVGsNoEvent(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
star := hr4799StarCoordinate()
star.Dec = 80
diagrams, err := FindLocalStarOccultationSVGs(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
star, 121.56601, 6.80706, 0,
moon.OccultationSearchOptions{}, LocalStarOccultationSVGOptions{},
)
if err != nil {
t.Fatalf("FindLocalStarOccultationSVGs() error = %v", err)
}
if len(diagrams) != 0 {
t.Fatalf("FindLocalStarOccultationSVGs() returned %d diagrams, want none", len(diagrams))
}
}
func TestLocalStarOccultationSVGRejectsInvalidInput(t *testing.T) {
info := localHR4799Occultation(t)
_, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{Width: 1})
if !errors.Is(err, ErrInvalidLocalStarOccultationSVGOptions) {
t.Fatalf("invalid canvas error = %v, want ErrInvalidLocalStarOccultationSVGOptions", err)
}
info.ContactsComplete = false
_, err = LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
t.Fatalf("incomplete event error = %v, want ErrInvalidLocalStarOccultationInfo", err)
}
info = localHR4799Occultation(t)
otherStar := hr4799StarCoordinate()
otherStar.ID = "another-star"
_, err = LocalStarOccultationSVG(info, otherStar, LocalStarOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
t.Fatalf("mismatched target error = %v, want ErrInvalidLocalStarOccultationInfo", err)
}
info = localHR4799Occultation(t)
wrongCoordinate := hr4799StarCoordinate()
wrongCoordinate.RA += 30
_, err = LocalStarOccultationSVG(info, wrongCoordinate, LocalStarOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
t.Fatalf("same-ID wrong coordinate error = %v, want ErrInvalidLocalStarOccultationInfo", err)
}
info.TargetID = ""
wrongCoordinate.ID = ""
_, err = LocalStarOccultationSVG(info, wrongCoordinate, LocalStarOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidLocalStarOccultationInfo) {
t.Fatalf("empty-ID wrong coordinate error = %v, want ErrInvalidLocalStarOccultationInfo", err)
}
}
func localHR4799Occultation(t *testing.T) moon.StarOccultationInfo {
t.Helper()
location := time.FixedZone("CST", 8*3600)
events, err := moon.FindStarOccultations(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799StarCoordinate(), 121.56601, 6.80706, 0, moon.OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindStarOccultations() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("FindStarOccultations() returned %d events, want 1", len(events))
}
return events[0]
}
+28
View File
@@ -0,0 +1,28 @@
package svg
import "b612.me/astro/internal/svgmap"
// MapProjection 控制全球月掩地图使用的投影。
// 零值根据事件几何自动选择投影。
// MapProjection controls the projection used by a global occultation map.
// The zero value selects a projection from the event geometry.
type MapProjection string
const (
// MapProjectionAuto 根据事件几何自动选择投影。
// MapProjectionAuto selects a projection from event geometry.
MapProjectionAuto MapProjection = ""
// MapProjectionEquirectangular 使用等经纬投影。
// MapProjectionEquirectangular uses the equirectangular projection.
MapProjectionEquirectangular MapProjection = "equirectangular"
// MapProjectionNorthPolar 使用北极方位等距投影。
// MapProjectionNorthPolar uses the north-polar azimuthal equidistant projection.
MapProjectionNorthPolar MapProjection = "north-polar"
// MapProjectionSouthPolar 使用南极方位等距投影。
// MapProjectionSouthPolar uses the south-polar azimuthal equidistant projection.
MapProjectionSouthPolar MapProjection = "south-polar"
)
func internalMapProjection(value MapProjection) svgmap.Projection {
return svgmap.Projection(value)
}
+670
View File
@@ -0,0 +1,670 @@
package svg
import (
"errors"
"fmt"
"math"
"strings"
"time"
"unicode"
"unicode/utf8"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
// ErrInvalidStarOccultationPath 表示传入 SVG 渲染器的掩带数据无效。
// ErrInvalidStarOccultationPath reports malformed path data passed to the SVG renderer.
var ErrInvalidStarOccultationPath = errors.New("invalid stellar occultation path")
type starOccultationSVGLayout struct {
width float64
height float64
margin float64
mapX float64
mapY float64
mapWidth float64
mapHeight float64
panelX float64
panelY float64
panelWidth float64
footerY float64
projection svgmap.Projection
}
type starOccultationGeoPoint struct {
longitude float64
latitude float64
}
type starOccultationEventRow struct {
name string
point moon.OccultationPathPoint
}
func validateStarOccultationPath(path moon.StarOccultationPath) error {
if !path.Complete {
return fmt.Errorf("%w: global path is incomplete", ErrInvalidStarOccultationPath)
}
if err := validateStarOccultationPathPoint("start", path.Start); err != nil {
return err
}
if err := validateStarOccultationPathPoint("greatest", path.Greatest); err != nil {
return err
}
if err := validateStarOccultationPathPoint("end", path.End); err != nil {
return err
}
if path.Greatest.Time.Before(path.Start.Time) || path.End.Time.Before(path.Greatest.Time) {
return fmt.Errorf("%w: event times must be ordered start, greatest, end", ErrInvalidStarOccultationPath)
}
if err := (moon.OccultationPathOptions{Step: path.Step, TargetSpacingKM: path.TargetSpacingKM}).Validate(); err != nil {
return fmt.Errorf("%w: invalid path sampling metadata: %v", ErrInvalidStarOccultationPath, err)
}
series := []struct {
name string
points []moon.OccultationPathPoint
}{
{"center line", path.CenterLine},
{"northern limit", path.NorthernLimit},
{"southern limit", path.SouthernLimit},
}
for _, current := range series {
name, points := current.name, current.points
for index, point := range points {
if err := validateStarOccultationPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
return err
}
if index > 0 && !point.Time.After(points[index-1].Time) {
return fmt.Errorf("%w: %s times must be strictly increasing", ErrInvalidStarOccultationPath, name)
}
}
}
if len(path.NorthernLimit) != len(path.SouthernLimit) {
return fmt.Errorf("%w: northern and southern limits must have the same sample count", ErrInvalidStarOccultationPath)
}
if len(path.NorthernLimit) < 2 {
return fmt.Errorf("%w: global limits must contain start and end", ErrInvalidStarOccultationPath)
}
for index := range path.NorthernLimit {
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
return fmt.Errorf("%w: northern and southern limit sample %d times must match", ErrInvalidStarOccultationPath, index)
}
}
last := len(path.NorthernLimit) - 1
if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
return fmt.Errorf("%w: global limits must span start through end", ErrInvalidStarOccultationPath)
}
return nil
}
func validateStarOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("%w: %s time is required", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 {
return fmt.Errorf("%w: %s longitude must be in [-180, 180]", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("%w: %s latitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("%w: %s width must be finite and non-negative", ErrInvalidStarOccultationPath, name)
}
return nil
}
func starOccultationSVGLayoutFor(
options StarOccultationSVGOptions,
headerBottom float64,
projection svgmap.Projection,
) starOccultationSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(22, math.Min(42, width*0.04))
gap := math.Max(16, math.Min(24, width*0.025))
panelWidth := math.Max(148, math.Min(238, width*0.23))
mapWidth := width - 2*margin - gap - panelWidth
if mapWidth < 220 {
panelWidth = math.Max(126, width*0.21)
mapWidth = width - 2*margin - gap - panelWidth
}
contentTop := headerBottom + 28
footerSpace := 82.0
if projection != svgmap.ProjectionEquirectangular {
footerSpace = 116
}
availableHeight := math.Max(110, height-contentTop-footerSpace)
mapHeight := math.Min(mapWidth/2, availableHeight)
if projection != svgmap.ProjectionEquirectangular {
mapHeight = math.Min(mapWidth, availableHeight)
mapWidth = mapHeight
}
if mapHeight < 110 {
mapHeight = 110
mapWidth = math.Min(mapWidth, 2*mapHeight)
}
mapY := contentTop + math.Max(0, (availableHeight-mapHeight)/2)
return starOccultationSVGLayout{
width: width,
height: height,
margin: margin,
mapX: margin,
mapY: mapY,
mapWidth: mapWidth,
mapHeight: mapHeight,
panelX: margin + mapWidth + gap,
panelY: mapY,
panelWidth: panelWidth,
footerY: height - 54,
projection: projection,
}
}
func (layout starOccultationSVGLayout) project(longitude, latitude float64) (float64, float64) {
x, y, _ := layout.mapFrame().Project(longitude, latitude)
return x, y
}
func (layout starOccultationSVGLayout) mapFrame() svgmap.Frame {
return svgmap.Frame{
X: layout.mapX,
Y: layout.mapY,
Width: layout.mapWidth,
Height: layout.mapHeight,
Projection: layout.projection,
}
}
func resolveStarOccultationMapProjection(path moon.StarOccultationPath, requested MapProjection) svgmap.Projection {
minimumLatitude := path.Greatest.Latitude
maximumLatitude := path.Greatest.Latitude
for _, series := range [][]moon.OccultationPathPoint{path.CenterLine, path.NorthernLimit, path.SouthernLimit} {
for _, point := range series {
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
}
}
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
}
func starOccultationPathSegments(points []moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
if len(points) == 0 {
return nil
}
segments := make([][]moon.OccultationPathPoint, 0, 2)
current := []moon.OccultationPathPoint{points[0]}
for index := 1; index < len(points); index++ {
if math.Abs(points[index].Longitude-points[index-1].Longitude) <= 180 {
current = append(current, points[index])
continue
}
boundary, fraction, ok := starOccultationAntimeridianCrossing(points[index-1], points[index])
if !ok {
current = append(current, points[index])
continue
}
crossing := starOccultationInterpolatePathPoint(points[index-1], points[index], fraction, boundary)
current = append(current, crossing)
if len(current) >= 2 {
segments = append(segments, current)
}
wrapped := crossing
wrapped.Longitude = -boundary
current = []moon.OccultationPathPoint{wrapped, points[index]}
}
if len(current) >= 2 {
segments = append(segments, current)
}
return segments
}
func starOccultationPathSegmentsForProjection(
points []moon.OccultationPathPoint,
projection svgmap.Projection,
) [][]moon.OccultationPathPoint {
if projection == svgmap.ProjectionEquirectangular {
return starOccultationPathSegments(points)
}
geographic := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
clipped := svgmap.PolylineSegments(geographic, projection)
result := make([][]moon.OccultationPathPoint, 0, len(clipped))
for _, segment := range clipped {
converted := make([]moon.OccultationPathPoint, len(segment))
for index, point := range segment {
converted[index] = moon.OccultationPathPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
result = append(result, converted)
}
return result
}
func starOccultationAntimeridianCrossing(a, b moon.OccultationPathPoint) (float64, float64, bool) {
if math.Abs(b.Longitude-a.Longitude) <= 180 {
return 0, 0, false
}
boundary := 180.0
adjustedB := b.Longitude
if a.Longitude < 0 {
boundary = -180
adjustedB -= 360
} else {
adjustedB += 360
}
denominator := adjustedB - a.Longitude
if math.Abs(denominator) < 1e-12 {
return 0, 0, false
}
fraction := (boundary - a.Longitude) / denominator
if fraction <= 0 || fraction >= 1 {
return 0, 0, false
}
return boundary, fraction, true
}
func starOccultationInterpolatePathPoint(a, b moon.OccultationPathPoint, fraction, longitude float64) moon.OccultationPathPoint {
if fraction < 0 {
fraction = 0
}
if fraction > 1 {
fraction = 1
}
duration := b.Time.Sub(a.Time)
return moon.OccultationPathPoint{
Time: a.Time.Add(time.Duration(float64(duration) * fraction)),
Longitude: longitude,
Latitude: a.Latitude + (b.Latitude-a.Latitude)*fraction,
MoonAltitude: a.MoonAltitude + (b.MoonAltitude-a.MoonAltitude)*fraction,
WidthKM: a.WidthKM + (b.WidthKM-a.WidthKM)*fraction,
}
}
func starOccultationBandSegments(
northern, southern []moon.OccultationPathPoint,
) [][]starOccultationGeoPoint {
return starOccultationBandFragments(northern, southern, svgmap.ProjectionEquirectangular)
}
func starOccultationBandFragments(
northern, southern []moon.OccultationPathPoint,
projection svgmap.Projection,
) [][]starOccultationGeoPoint {
count := len(northern)
if len(southern) < count {
count = len(southern)
}
if count < 2 {
return nil
}
polygon := make([]starOccultationGeoPoint, 0, 2*count)
for _, point := range northern[:count] {
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
}
for index := count - 1; index >= 0; index-- {
point := southern[index]
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
}
geographic := make([]svgmap.GeoPoint, len(polygon))
for index, point := range polygon {
geographic[index] = svgmap.GeoPoint{Longitude: point.longitude, Latitude: point.latitude}
}
fragments := svgmap.PolygonFragments(geographic, projection)
segments := make([][]starOccultationGeoPoint, 0, len(fragments))
for _, fragment := range fragments {
converted := make([]starOccultationGeoPoint, len(fragment))
for index, point := range fragment {
converted[index] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude}
}
if math.Abs(starOccultationPolygonArea(converted)) > 1e-9 {
segments = append(segments, converted)
}
}
return segments
}
func legacyStarOccultationBandSegments(polygon []starOccultationGeoPoint) [][]starOccultationGeoPoint {
polygon = starOccultationUnwrapPolygon(polygon)
minimumLongitude, maximumLongitude := polygon[0].longitude, polygon[0].longitude
for _, point := range polygon[1:] {
minimumLongitude = math.Min(minimumLongitude, point.longitude)
maximumLongitude = math.Max(maximumLongitude, point.longitude)
}
firstWorld := int(math.Floor((minimumLongitude + 180) / 360))
lastWorld := int(math.Floor((maximumLongitude + 180) / 360))
segments := make([][]starOccultationGeoPoint, 0, lastWorld-firstWorld+1)
for world := firstWorld; world <= lastWorld; world++ {
left := -180.0 + 360*float64(world)
right := 180.0 + 360*float64(world)
clipped := starOccultationClipPolygonLongitude(polygon, left, true)
clipped = starOccultationClipPolygonLongitude(clipped, right, false)
if len(clipped) < 3 {
continue
}
for index := range clipped {
clipped[index].longitude -= 360 * float64(world)
}
if math.Abs(starOccultationPolygonArea(clipped)) > 1e-9 {
segments = append(segments, clipped)
}
}
return segments
}
func starOccultationUnwrapPolygon(points []starOccultationGeoPoint) []starOccultationGeoPoint {
if len(points) < 2 {
return points
}
unwrapped := make([]starOccultationGeoPoint, len(points))
unwrapped[0] = points[0]
for index := 1; index < len(points); index++ {
point := points[index]
previous := unwrapped[index-1].longitude
for point.longitude-previous > 180 {
point.longitude -= 360
}
for point.longitude-previous < -180 {
point.longitude += 360
}
unwrapped[index] = point
}
return unwrapped
}
func starOccultationClipPolygonLongitude(
points []starOccultationGeoPoint,
boundary float64,
keepGreater bool,
) []starOccultationGeoPoint {
if len(points) == 0 {
return nil
}
inside := func(point starOccultationGeoPoint) bool {
if keepGreater {
return point.longitude >= boundary
}
return point.longitude <= boundary
}
intersect := func(a, b starOccultationGeoPoint) starOccultationGeoPoint {
fraction := (boundary - a.longitude) / (b.longitude - a.longitude)
return starOccultationGeoPoint{
longitude: boundary,
latitude: a.latitude + (b.latitude-a.latitude)*fraction,
}
}
clipped := make([]starOccultationGeoPoint, 0, len(points)+2)
previous := points[len(points)-1]
previousInside := inside(previous)
for _, current := range points {
currentInside := inside(current)
if currentInside != previousInside {
clipped = append(clipped, intersect(previous, current))
}
if currentInside {
clipped = append(clipped, current)
}
previous = current
previousInside = currentInside
}
return clipped
}
func starOccultationPolygonArea(points []starOccultationGeoPoint) float64 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
area += point.longitude*next.latitude - next.longitude*point.latitude
}
return area / 2
}
func starOccultationSVGHeaderLines(path moon.StarOccultationPath, options StarOccultationSVGOptions) []string {
lines := make([]string, 0, 4)
for _, item := range []struct {
text string
fontSize float64
}{
{starOccultationSVGSummaryText(path, options), 14},
{starOccultationSVGGreatestText(path, options), 13},
} {
lines = append(lines, starOccultationWrapText(item.text, float64(options.Width)-80, item.fontSize)...)
}
return lines
}
func starOccultationSVGTitle(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
if options.Title != "" {
return options.Title
}
target := path.TargetID
if target == "" {
if options.Language == starOccultationSVGLanguageEnglish {
target = "star"
} else {
target = "恒星"
}
}
date := path.Greatest.Time.In(options.Location).Format("2006-01-02")
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("%s Lunar Occultation of %s", date, target)
}
return fmt.Sprintf("%s 月掩%s全球掩带", date, target)
}
func starOccultationSVGSummaryText(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
if options.SummaryText != "" {
return options.SummaryText
}
start := path.Start.Time.In(options.Location)
greatest := path.Greatest.Time.In(options.Location)
end := path.End.Time.In(options.Location)
zone := starOccultationLocationLabel(greatest, options.Location)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Start %s | Greatest %s | End %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
return fmt.Sprintf("掩始 %s | 掩甚 %s | 掩终 %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
func starOccultationSVGGreatestText(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
if options.GreatestText != "" {
return options.GreatestText
}
coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude)
altitude := starOccultationFormatSignedDegree(path.Greatest.MoonAltitude)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, path.Greatest.WidthKM, altitude)
}
return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, path.Greatest.WidthKM, altitude)
}
func starOccultationSVGMapTitle(options StarOccultationSVGOptions) string {
if options.MapTitle != "" {
return options.MapTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Global center line and occultation limits"
}
return "全球中心线与掩带边界"
}
func starOccultationSVGContactsTitle(options StarOccultationSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Global events"
}
return "全球事件"
}
func starOccultationSVGFooter(options StarOccultationSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
projection := starOccultationProjectionLabel(options.Projection, options.Language)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("%s with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid.", projection)
}
return fmt.Sprintf("%sNatural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。", projection)
}
func starOccultationProjectionLabel(projection MapProjection, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch projection {
case MapProjectionNorthPolar:
return "North-polar azimuthal equidistant projection"
case MapProjectionSouthPolar:
return "South-polar azimuthal equidistant projection"
default:
return "Equirectangular projection"
}
}
switch projection {
case MapProjectionNorthPolar:
return "北极方位等距投影"
case MapProjectionSouthPolar:
return "南极方位等距投影"
default:
return "等经纬投影"
}
}
func starOccultationSVGEventRows(path moon.StarOccultationPath, language string) []starOccultationEventRow {
names := []string{"掩始", "掩甚", "掩终"}
if language == starOccultationSVGLanguageEnglish {
names = []string{"Start", "Greatest", "End"}
}
return []starOccultationEventRow{
{name: names[0], point: path.Start},
{name: names[1], point: path.Greatest},
{name: names[2], point: path.End},
}
}
func starOccultationFormatEventTime(value time.Time, withDate bool) string {
layout := "15:04:05.0"
if withDate {
layout = "2006-01-02 15:04:05.0"
}
return value.Format(layout)
}
func starOccultationFormatCoordinates(longitude, latitude float64) string {
longitudeSuffix := "E"
if longitude < 0 {
longitudeSuffix = "W"
}
latitudeSuffix := "N"
if latitude < 0 {
latitudeSuffix = "S"
}
return fmt.Sprintf("%.4f°%s, %.4f°%s", math.Abs(longitude), longitudeSuffix, math.Abs(latitude), latitudeSuffix)
}
func starOccultationFormatSignedDegree(value float64) string {
return fmt.Sprintf("%+.1f°", value)
}
func starOccultationLocationLabel(value time.Time, location *time.Location) string {
if location == time.UTC {
return "UTC"
}
name, offset := value.Zone()
if name != "" && name != "Local" {
return name
}
hours := float64(offset) / 3600
return fmt.Sprintf("UTC%+.1f", hours)
}
func starOccultationSameDate(first, second time.Time) bool {
y1, m1, d1 := first.Date()
y2, m2, d2 := second.Date()
return y1 == y2 && m1 == m2 && d1 == d2
}
func starOccultationWrapText(value string, maxWidth, fontSize float64) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if starOccultationTextWidth(value, fontSize) <= maxWidth {
return []string{value}
}
runes := []rune(value)
lines := make([]string, 0, 2)
for len(runes) > 0 {
width := 0.0
end := 0
lastSpace := -1
for end < len(runes) {
nextWidth := width + starOccultationRuneWidth(runes[end], fontSize)
if nextWidth > maxWidth && end > 0 {
break
}
width = nextWidth
if unicode.IsSpace(runes[end]) {
lastSpace = end
}
end++
}
if end < len(runes) && lastSpace > 0 {
end = lastSpace
}
if end == 0 {
end = 1
}
line := strings.TrimSpace(string(runes[:end]))
if line != "" {
lines = append(lines, line)
}
runes = runes[end:]
for len(runes) > 0 && unicode.IsSpace(runes[0]) {
runes = runes[1:]
}
}
return lines
}
func starOccultationTitleFontSize(title string, width float64) int {
for size := 26; size >= 16; size-- {
if starOccultationTextWidth(title, float64(size)) <= width-80 {
return size
}
}
return 16
}
func starOccultationTextWidth(value string, fontSize float64) float64 {
width := 0.0
for _, current := range value {
width += starOccultationRuneWidth(current, fontSize)
}
return width
}
func starOccultationRuneWidth(value rune, fontSize float64) float64 {
if unicode.Is(unicode.Han, value) || value > utf8.RuneSelf {
return fontSize
}
if unicode.IsSpace(value) {
return fontSize * 0.34
}
return fontSize * 0.58
}
func starOccultationFinite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
+647
View File
@@ -0,0 +1,647 @@
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
// ErrInvalidPlanetOccultationPath 表示有限盘面路径数据格式错误。
// ErrInvalidPlanetOccultationPath reports malformed finite-disk path data.
var ErrInvalidPlanetOccultationPath = errors.New("invalid planetary occultation path")
// ErrInvalidPlanetOccultationSVGOptions 表示行星 SVG 画布选项无效。
// ErrInvalidPlanetOccultationSVGOptions reports invalid planetary SVG canvas options.
var ErrInvalidPlanetOccultationSVGOptions = errors.New("invalid planetary occultation SVG options")
const (
planetOccultationSVGMinimumWidth = 640
planetOccultationSVGMinimumHeight = 480
)
// PlanetOccultationSVGOptions 控制全球行星月掩路径 SVG。
// PlanetOccultationSVGOptions controls a global planetary-occultation path SVG.
type PlanetOccultationSVGOptions = StarOccultationSVGOptions
// FindPlanetOccultationSVGs 搜索时间窗口并渲染每条全球有限盘面行星月掩路径。
// FindPlanetOccultationSVGs searches a time window and renders every global finite-disk planetary occultation path.
func FindPlanetOccultationSVGs(
start, end time.Time,
planet moon.OccultationPlanet,
pathOptions moon.OccultationPathOptions,
options PlanetOccultationSVGOptions,
) ([]string, error) {
paths, err := moon.FindPlanetOccultationPaths(start, end, planet, pathOptions)
if err != nil {
return nil, err
}
diagrams := make([]string, 0, len(paths))
for _, path := range paths {
diagram, renderErr := PlanetOccultationPathSVG(path, options)
if renderErr != nil {
return nil, renderErr
}
diagrams = append(diagrams, diagram)
}
return diagrams, nil
}
// PlanetOccultationPathSVG 渲染已计算的有限盘面行星路径。
// PlanetOccultationPathSVG renders a computed finite-disk planetary path.
func PlanetOccultationPathSVG(
path moon.PlanetOccultationPath,
options PlanetOccultationSVGOptions,
) (string, error) {
if err := validatePlanetOccultationPath(path); err != nil {
return "", err
}
targetID := path.TargetID
if targetID == "" {
targetID = path.Planet.String()
}
if err := validateStarOccultationSVGOptions(options); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationSVGOptions, err)
}
if options.Width > 0 && options.Width < planetOccultationSVGMinimumWidth {
return "", fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidPlanetOccultationSVGOptions, planetOccultationSVGMinimumWidth)
}
if options.Height > 0 && options.Height < planetOccultationSVGMinimumHeight {
return "", fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidPlanetOccultationSVGOptions, planetOccultationSVGMinimumHeight)
}
options = normalizeStarOccultationSVGOptions(options)
starShape := planetOccultationStarShape(path, targetID)
if targetID == path.Planet.String() && options.Language == starOccultationSVGLanguageChinese {
targetID = planetOccultationChineseName(path.Planet)
starShape.TargetID = targetID
}
options.Projection = MapProjection(resolvePlanetOccultationMapProjection(path, options.Projection))
options = planetOccultationSVGDefaults(path, options)
return renderOccultationPathSVG(starShape, &path, options), nil
}
func resolvePlanetOccultationMapProjection(
path moon.PlanetOccultationPath,
requested MapProjection,
) svgmap.Projection {
minimumLatitude := path.Greatest.Latitude
maximumLatitude := path.Greatest.Latitude
for _, series := range [][]moon.OccultationPathPoint{
path.CenterLine, path.NorthernLimit, path.SouthernLimit,
path.NorthernTotalLimit, path.SouthernTotalLimit,
} {
for _, point := range series {
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
}
}
for _, footprints := range [][]moon.PlanetOccultationFootprint{path.PartialFootprints, path.TotalFootprints} {
for _, footprint := range footprints {
for _, polygon := range footprint.Polygons {
for _, point := range polygon {
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
}
}
}
}
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
}
func planetOccultationStarShape(path moon.PlanetOccultationPath, targetID string) moon.StarOccultationPath {
return moon.StarOccultationPath{
TargetID: targetID,
Start: path.Start,
Greatest: path.Greatest,
End: path.End,
Complete: path.Complete,
CenterLine: path.CenterLine,
NorthernLimit: path.NorthernLimit,
SouthernLimit: path.SouthernLimit,
Step: path.Step,
TargetSpacingKM: path.TargetSpacingKM,
}
}
func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error {
if err := path.Planet.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if err := validateStarOccultationPath(planetOccultationStarShape(path, path.TargetID)); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if !path.HasTotalBand {
if path.TotalComplete || !path.TotalStart.Time.IsZero() || !path.TotalEnd.Time.IsZero() ||
len(path.NorthernTotalLimit) != 0 || len(path.SouthernTotalLimit) != 0 ||
len(path.TotalFootprints) != 0 || path.GreatestTotalWidthKM != 0 {
return fmt.Errorf("%w: total-band fields require HasTotalBand", ErrInvalidPlanetOccultationPath)
}
return validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time)
}
if !path.TotalComplete {
return fmt.Errorf("%w: global total-occultation band is incomplete", ErrInvalidPlanetOccultationPath)
}
if err := validateStarOccultationPathPoint("total start", path.TotalStart); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if err := validateStarOccultationPathPoint("total end", path.TotalEnd); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if !path.TotalStart.Time.After(path.Start.Time) || !path.TotalStart.Time.Before(path.Greatest.Time) ||
!path.TotalEnd.Time.After(path.Greatest.Time) || !path.TotalEnd.Time.Before(path.End.Time) {
return fmt.Errorf("%w: total-band times must be inside outer start, greatest, and end", ErrInvalidPlanetOccultationPath)
}
if !starOccultationFinite(path.GreatestTotalWidthKM) || path.GreatestTotalWidthKM <= 0 ||
path.GreatestTotalWidthKM >= path.Greatest.WidthKM {
return fmt.Errorf("%w: total-band width must be positive and narrower than the outer band", ErrInvalidPlanetOccultationPath)
}
if err := validatePlanetOccultationTotalLimits(path); err != nil {
return err
}
if err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
return err
}
if err := validatePlanetOccultationFootprints("total", path.TotalFootprints, path.TotalStart.Time, path.TotalEnd.Time); err != nil {
return err
}
return nil
}
func validatePlanetOccultationFootprints(
name string,
footprints []moon.PlanetOccultationFootprint,
start, end time.Time,
) error {
for footprintIndex, footprint := range footprints {
if footprint.Time.Before(start) || footprint.Time.After(end) {
return fmt.Errorf("%w: %s footprint[%d] time must be inside its contact interval",
ErrInvalidPlanetOccultationPath, name, footprintIndex)
}
if footprintIndex > 0 && !footprint.Time.After(footprints[footprintIndex-1].Time) {
return fmt.Errorf("%w: %s footprint times must be strictly increasing",
ErrInvalidPlanetOccultationPath, name)
}
if len(footprint.Polygons) == 0 {
return fmt.Errorf("%w: %s footprint[%d] must contain a polygon",
ErrInvalidPlanetOccultationPath, name, footprintIndex)
}
for polygonIndex, polygon := range footprint.Polygons {
if len(polygon) < 3 {
return fmt.Errorf("%w: %s footprint[%d].polygon[%d] must contain at least three points",
ErrInvalidPlanetOccultationPath, name, footprintIndex, polygonIndex)
}
for pointIndex, point := range polygon {
pointName := fmt.Sprintf("%s footprint[%d].polygon[%d][%d]", name, footprintIndex, polygonIndex, pointIndex)
if err := validateStarOccultationPathPoint(pointName, point); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if !point.Time.Equal(footprint.Time) {
return fmt.Errorf("%w: %s time must match its footprint", ErrInvalidPlanetOccultationPath, pointName)
}
}
}
}
return nil
}
func validatePlanetOccultationTotalLimits(path moon.PlanetOccultationPath) error {
if len(path.NorthernTotalLimit) != len(path.SouthernTotalLimit) || len(path.NorthernTotalLimit) < 2 {
return fmt.Errorf("%w: total northern and southern limits must contain matching samples", ErrInvalidPlanetOccultationPath)
}
for index := range path.NorthernTotalLimit {
for _, item := range []struct {
name string
point moon.OccultationPathPoint
}{
{fmt.Sprintf("northern total limit[%d]", index), path.NorthernTotalLimit[index]},
{fmt.Sprintf("southern total limit[%d]", index), path.SouthernTotalLimit[index]},
} {
if err := validateStarOccultationPathPoint(item.name, item.point); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
}
if !path.NorthernTotalLimit[index].Time.Equal(path.SouthernTotalLimit[index].Time) {
return fmt.Errorf("%w: total limit sample %d times must match", ErrInvalidPlanetOccultationPath, index)
}
if index > 0 && !path.NorthernTotalLimit[index].Time.After(path.NorthernTotalLimit[index-1].Time) {
return fmt.Errorf("%w: total limit times must be strictly increasing", ErrInvalidPlanetOccultationPath)
}
}
last := len(path.NorthernTotalLimit) - 1
if !path.NorthernTotalLimit[0].Time.Equal(path.TotalStart.Time) || !path.SouthernTotalLimit[0].Time.Equal(path.TotalStart.Time) ||
!path.NorthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) || !path.SouthernTotalLimit[last].Time.Equal(path.TotalEnd.Time) {
return fmt.Errorf("%w: total limits must span total start through total end", ErrInvalidPlanetOccultationPath)
}
return nil
}
func planetOccultationSVGDefaults(
path moon.PlanetOccultationPath,
options PlanetOccultationSVGOptions,
) PlanetOccultationSVGOptions {
if options.SummaryText == "" {
options.SummaryText = planetOccultationSVGSummaryText(path, options)
}
if options.GreatestText == "" {
options.GreatestText = planetOccultationSVGGreatestText(path, options.Language)
}
if options.MapTitle == "" {
if options.Language == starOccultationSVGLanguageEnglish {
options.MapTitle = "Global partial- and total-occultation bands"
} else {
options.MapTitle = "全球部分掩带与全掩带"
}
}
if options.ContactsTitle == "" {
if options.Language == starOccultationSVGLanguageEnglish {
options.ContactsTitle = "Global phases"
} else {
options.ContactsTitle = "全球阶段"
}
}
if options.FooterNote == "" {
options.FooterNote = planetOccultationSVGFooter(path, options)
}
return options
}
func planetOccultationSVGSummaryText(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string {
start := path.Start.Time.In(options.Location)
greatest := path.Greatest.Time.In(options.Location)
end := path.End.Time.In(options.Location)
zone := starOccultationLocationLabel(greatest, options.Location)
if !path.HasTotalBand {
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Partial begins %s | Greatest %s | Partial ends %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
return fmt.Sprintf("外掩始 %s | 掩甚 %s | 外掩终 %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
totalStart := path.TotalStart.Time.In(options.Location)
totalEnd := path.TotalEnd.Time.In(options.Location)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Partial begins %s | Total begins %s | Greatest %s | Total ends %s | Partial ends %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(totalStart, !starOccultationSameDate(start, totalStart)),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(totalEnd, !starOccultationSameDate(start, totalEnd)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
return fmt.Sprintf("外掩始 %s | 全掩始 %s | 掩甚 %s | 全掩终 %s | 外掩终 %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(totalStart, !starOccultationSameDate(start, totalStart)),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(totalEnd, !starOccultationSameDate(start, totalEnd)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
func planetOccultationSVGGreatestText(path moon.PlanetOccultationPath, language string) string {
coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude)
if path.HasTotalBand {
if language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km | total-band width %.1f km",
coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM)
}
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km | 全掩带宽 %.1f km",
coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM)
}
if language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, path.Greatest.WidthKM)
}
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, path.Greatest.WidthKM)
}
func planetOccultationSVGFooter(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string {
projection := starOccultationProjectionLabel(options.Projection, options.Language)
ringNote := ""
if path.Planet == moon.OccultationSaturn {
if options.Language == starOccultationSVGLanguageEnglish {
ringNote = " Saturn's rings are not included in contact geometry."
} else {
ringNote = "土星环不参与接触计算。"
}
}
if options.Language == starOccultationSVGLanguageEnglish {
return projection + "; Natural Earth 1:50m physical land, no administrative boundaries. " +
"The outer-contact cone bounds any disk overlap; the inner-contact cone bounds full planet-disk coverage." + ringNote
}
return projection + "Natural Earth 1:50m 物理陆地底图,不含行政边界;" +
"外切锥面界定任意圆盘重叠,内切锥面界定行星圆盘完全被月球遮住。" + ringNote
}
func writePlanetOccultationMap(
b *strings.Builder,
path moon.PlanetOccultationPath,
starShape moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
layout.mapFrame().WriteOcean(b)
writeStarOccultationGraticule(b, layout)
writeStarOccultationLand(b, layout)
if len(path.PartialFootprints) > 0 {
writePlanetOccultationFootprintSweep(b, path.PartialFootprints, layout,
"partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34)
} else {
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
"partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34)
}
if path.HasTotalBand {
if len(path.TotalFootprints) > 0 {
writePlanetOccultationFootprintSweep(b, path.TotalFootprints, layout,
"total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72)
} else {
writeOccultationBand(b, path.NorthernTotalLimit, path.SouthernTotalLimit, layout,
"total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72)
}
}
if len(path.PartialFootprints) == 0 {
writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.25, "")
writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.25, "")
}
if path.HasTotalBand && len(path.TotalFootprints) == 0 {
writeStarOccultationGeoLine(b, path.NorthernTotalLimit, layout, "northern-total-limit", "#355878", 1.3, "")
writeStarOccultationGeoLine(b, path.SouthernTotalLimit, layout, "southern-total-limit", "#355878", 1.3, "")
}
writeStarOccultationGeoLine(b, starShape.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4")
writeStarOccultationVisibleCenterLine(b, starShape.CenterLine, layout)
excluded := []time.Time{path.Start.Time, path.End.Time}
if path.HasTotalBand {
excluded = append(excluded, path.TotalStart.Time, path.TotalEnd.Time)
}
writeOccultationTimeMarkers(b, starShape.CenterLine, layout, options, excluded, path.Greatest.Time)
writePlanetOccultationEventMarkers(b, path, layout, options.Language)
layout.mapFrame().WriteFrame(b)
writePlanetOccultationLegend(b, layout, options.Language, path.HasTotalBand)
}
func writePlanetOccultationFootprintSweep(
b *strings.Builder,
footprints []moon.PlanetOccultationFootprint,
layout starOccultationSVGLayout,
layerClass, pathClass, color string,
opacity float64,
) {
var geometry strings.Builder
for _, footprint := range footprints {
for _, polygon := range footprint.Polygons {
geographic := make([]svgmap.GeoPoint, len(polygon))
for index, point := range polygon {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
for _, fragment := range svgmap.PolygonFragments(geographic, layout.projection) {
if len(fragment) < 3 {
continue
}
if planetOccultationProjectedArea(layout, fragment) < 0 {
for left, right := 0, len(fragment)-1; left < right; left, right = left+1, right-1 {
fragment[left], fragment[right] = fragment[right], fragment[left]
}
}
for index, point := range fragment {
x, y := layout.project(point.Longitude, point.Latitude)
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(&geometry, "%s %.3f %.3f ", command, x, y)
}
geometry.WriteString("Z ")
}
}
}
if geometry.Len() == 0 {
return
}
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="none"><path class="%s" fill-rule="nonzero" d="%s"/></g>`,
layerClass, color, opacity, pathClass, geometry.String())
}
func planetOccultationProjectedArea(layout starOccultationSVGLayout, points []svgmap.GeoPoint) float64 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
x1, y1 := layout.project(point.Longitude, point.Latitude)
x2, y2 := layout.project(next.Longitude, next.Latitude)
area += x1*y2 - x2*y1
}
return area / 2
}
type planetOccultationProjectedEventMarker struct {
point moon.OccultationPathPoint
label string
kind string
x, y float64
visible bool
placement starOccultationEventMarkerPlacement
}
func writePlanetOccultationEventMarkers(
b *strings.Builder,
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
language string,
) {
markers := []planetOccultationProjectedEventMarker{
{point: path.Start, label: planetOccultationEventLabel("start", language), kind: "start"},
}
if path.HasTotalBand {
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.TotalStart, label: planetOccultationEventLabel("total-start", language), kind: "total-start",
})
}
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.Greatest, label: planetOccultationEventLabel("greatest", language), kind: "greatest",
})
if path.HasTotalBand {
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.TotalEnd, label: planetOccultationEventLabel("total-end", language), kind: "total-end",
})
}
markers = append(markers, planetOccultationProjectedEventMarker{
point: path.End, label: planetOccultationEventLabel("end", language), kind: "end",
})
for index := range markers {
markers[index].x, markers[index].y, markers[index].visible = layout.mapFrame().Project(
markers[index].point.Longitude, markers[index].point.Latitude,
)
markers[index].placement = starOccultationDefaultEventMarkerPlacement(
markers[index].x, markers[index].y, markers[index].kind, layout,
)
}
if path.HasTotalBand {
separatePlanetOccultationMarkerPair(markers, 0, 1, layout)
separatePlanetOccultationMarkerPair(markers, 4, 3, layout)
}
drawOrder := make([]int, len(markers))
for index := range drawOrder {
drawOrder[index] = index
}
if path.HasTotalBand {
// 先绘制接近重合的外接触,再绘制较小的内接触点,使两个真实地理位置都保持可读。
// Draw the near-coincident outer contacts first, then the smaller inner contacts, so both true geographic positions remain legible.
drawOrder = []int{0, 4, 1, 3, 2}
}
for _, index := range drawOrder {
marker := markers[index]
if marker.visible {
writeStarOccultationProjectedEventMarker(
b, marker.x, marker.y, marker.label, marker.kind, marker.placement,
)
}
}
}
func separatePlanetOccultationMarkerPair(
markers []planetOccultationProjectedEventMarker,
outerIndex, totalIndex int,
layout starOccultationSVGLayout,
) {
outer := &markers[outerIndex]
total := &markers[totalIndex]
if !outer.visible || !total.visible || math.Hypot(outer.x-total.x, outer.y-total.y) >= 30 {
return
}
upper := math.Min(outer.y, total.y) - 10
lower := math.Max(outer.y, total.y) + 18
top := layout.mapY + 12
bottom := layout.mapY + layout.mapHeight - 5
if lower > bottom {
lower = math.Min(outer.y, total.y) - 10
upper = lower - 16
}
if upper < top {
upper = math.Max(outer.y, total.y) + 16
lower = upper + 16
}
outer.placement.labelY = math.Max(top, math.Min(bottom, upper))
total.placement.labelY = math.Max(top, math.Min(bottom, lower))
outer.placement.leader = true
total.placement.leader = true
}
func writePlanetOccultationLegend(
b *strings.Builder,
layout starOccultationSVGLayout,
language string,
hasTotal bool,
) {
labels := []string{"部分掩带", "可见中心线", "几何中心线"}
if hasTotal {
labels = []string{"部分掩带", "全掩带", "可见中心线", "几何中心线"}
}
if language == starOccultationSVGLanguageEnglish {
labels = []string{"Partial band", "Visible center", "Geometric center"}
if hasTotal {
labels = []string{"Partial band", "Total band", "Visible center", "Geometric center"}
}
}
y := layout.mapY + layout.mapHeight + 30
itemWidth := layout.mapWidth / float64(len(labels))
for index, label := range labels {
x := layout.mapX + float64(index)*itemWidth
switch {
case index == 0:
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="#e0ae43" fill-opacity="0.55"/>`, x, y-10)
case hasTotal && index == 1:
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="#607d98" fill-opacity="0.82"/>`, x, y-10)
default:
color, dash := "#087f8c", ""
if index == len(labels)-1 {
color, dash = "#59676b", "5 4"
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="2"`, x, y-5, x+18, y-5, color)
if dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, dash)
}
b.WriteString(`/>`)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="9">%s</text>`,
x+23, y, html.EscapeString(label))
}
}
func writePlanetOccultationEventsPanel(
b *strings.Builder,
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
options PlanetOccultationSVGOptions,
) {
writeOccultationEventsPanel(b, planetOccultationSVGEventRows(path, options.Language), layout, options)
}
func planetOccultationSVGEventRows(path moon.PlanetOccultationPath, language string) []starOccultationEventRow {
rows := []starOccultationEventRow{
{name: planetOccultationEventLabel("start", language), point: path.Start},
}
if path.HasTotalBand {
rows = append(rows, starOccultationEventRow{name: planetOccultationEventLabel("total-start", language), point: path.TotalStart})
}
rows = append(rows, starOccultationEventRow{name: planetOccultationEventLabel("greatest", language), point: path.Greatest})
if path.HasTotalBand {
rows = append(rows, starOccultationEventRow{name: planetOccultationEventLabel("total-end", language), point: path.TotalEnd})
}
return append(rows, starOccultationEventRow{name: planetOccultationEventLabel("end", language), point: path.End})
}
func planetOccultationEventLabel(kind, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch kind {
case "start":
return "Partial begins"
case "total-start":
return "Total begins"
case "greatest":
return "Greatest"
case "total-end":
return "Total ends"
default:
return "Partial ends"
}
}
switch kind {
case "start":
return "外掩始"
case "total-start":
return "全掩始"
case "greatest":
return "掩甚"
case "total-end":
return "全掩终"
default:
return "外掩终"
}
}
func planetOccultationChineseName(planet moon.OccultationPlanet) string {
switch planet {
case moon.OccultationMercury:
return "水星"
case moon.OccultationVenus:
return "金星"
case moon.OccultationMars:
return "火星"
case moon.OccultationJupiter:
return "木星"
case moon.OccultationSaturn:
return "土星"
case moon.OccultationUranus:
return "天王星"
case moon.OccultationNeptune:
return "海王星"
default:
return "行星"
}
}
+783
View File
@@ -0,0 +1,783 @@
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/internal/svgasset"
"b612.me/astro/moon"
)
const (
localPlanetOccultationSVGDefaultWidth = 1040
localPlanetOccultationSVGDefaultHeight = 760
localPlanetOccultationSVGMinimumWidth = 760
localPlanetOccultationSVGMinimumHeight = 600
localPlanetOccultationSVGDefaultStep = 2 * time.Minute
)
// ErrInvalidLocalPlanetOccultationSVGOptions 表示本地行星图表选项无效。
// ErrInvalidLocalPlanetOccultationSVGOptions reports invalid local planetary chart options.
var ErrInvalidLocalPlanetOccultationSVGOptions = errors.New("invalid local planetary occultation SVG options")
// ErrInvalidLocalPlanetOccultationInfo 表示固定地点行星事件数据格式错误。
// ErrInvalidLocalPlanetOccultationInfo reports malformed fixed-site planetary event data.
var ErrInvalidLocalPlanetOccultationInfo = errors.New("invalid local planetary occultation info")
// LocalPlanetOccultationSVGOptions 控制固定地点月球与行星盘面图。
// LocalPlanetOccultationSVGOptions controls a fixed-site Moon-planet disk chart.
type LocalPlanetOccultationSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;非正值使用默认值。
// Width and Height are SVG canvas dimensions. Values <= 0 use defaults.
Width int
Height int
// Step 是月心行星轨迹请求采样步长;零值使用两分钟,负值无效,正值小于一秒时提升到一秒。长事件可能增大实际步长,以保持基础轨迹在有界采样预算内。
// Step is the requested Moon-centered planetary-track sampling step. Zero uses two minutes; negative values are invalid. Positive values below one second are raised to one second, and long events may use a larger effective step to keep the base track within its bounded sample budget.
Step time.Duration
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
SummaryText string
GreatestText string
OverviewTitle string
PhasePanelsTitle string
ContactsTitle string
DirectionText string
FooterNote string
// Language 为 "en" 时使用英文,其他值使用中文。
// Language uses English for "en" and Chinese for every other value.
Language string
// Location 控制显示的事件时刻;nil 使用 UTC+8。
// Location controls displayed event times. Nil uses UTC+8.
Location *time.Location
}
type localPlanetOccultationEventFrame struct {
label string
name string
time time.Time
hidden bool
frame moon.PlanetOccultationDiagramFrame
}
// FindLocalPlanetOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次有限盘面行星月掩。空切片表示没有本地事件。
// FindLocalPlanetOccultationSVGs searches one fixed observing site and renders every finite-disk planetary occultation in the requested time window. An empty slice means no local event.
func FindLocalPlanetOccultationSVGs(
start, end time.Time,
planet moon.OccultationPlanet,
longitude, latitude, height float64,
searchOptions moon.OccultationSearchOptions,
options LocalPlanetOccultationSVGOptions,
) ([]string, error) {
if err := validateLocalPlanetOccultationSVGOptions(options); err != nil {
return nil, err
}
events, err := moon.FindPlanetOccultations(
start, end, planet, longitude, latitude, height, searchOptions,
)
if err != nil {
return nil, err
}
diagrams := make([]string, 0, len(events))
for _, event := range events {
diagram, renderErr := LocalPlanetOccultationSVG(event, options)
if renderErr != nil {
return nil, renderErr
}
diagrams = append(diagrams, diagram)
}
return diagrams, nil
}
// LocalPlanetOccultationSVG 渲染已求解的固定地点行星月掩的月心轨迹及适用的 C1-C4 阶段面板;不会运行事件搜索。
// LocalPlanetOccultationSVG renders an already solved fixed-site planetary occultation as a Moon-centered track with all applicable C1-C4 stage panels. It does not run the event search.
func LocalPlanetOccultationSVG(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) (string, error) {
if err := validateLocalPlanetOccultationInfo(info); err != nil {
return "", err
}
if err := validateLocalPlanetOccultationSVGOptions(options); err != nil {
return "", err
}
options = normalizeLocalPlanetOccultationSVGOptions(options)
diagram := moon.PlanetOccultationDiagram(info, moon.PlanetOccultationDiagramOptions{
StepDays: options.Step.Hours() / 24,
})
if len(diagram.Frames) == 0 {
return "", fmt.Errorf("%w: diagram geometry is unavailable", ErrInvalidLocalPlanetOccultationInfo)
}
return renderLocalPlanetOccultationSVG(info, diagram, options), nil
}
func validateLocalPlanetOccultationSVGOptions(options LocalPlanetOccultationSVGOptions) error {
if options.Width > 0 && options.Width < localPlanetOccultationSVGMinimumWidth {
return fmt.Errorf("%w: width must be zero or at least %d", ErrInvalidLocalPlanetOccultationSVGOptions, localPlanetOccultationSVGMinimumWidth)
}
if options.Height > 0 && options.Height < localPlanetOccultationSVGMinimumHeight {
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidLocalPlanetOccultationSVGOptions, localPlanetOccultationSVGMinimumHeight)
}
if options.Step < 0 {
return fmt.Errorf("%w: step cannot be negative", ErrInvalidLocalPlanetOccultationSVGOptions)
}
return nil
}
func validateLocalPlanetOccultationInfo(info moon.PlanetOccultationInfo) error {
if err := info.Planet.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidLocalPlanetOccultationInfo, err)
}
if err := info.Observer.Validate(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidLocalPlanetOccultationInfo, err)
}
if !info.ContactsComplete || info.ExternalImmersion.IsZero() || info.Greatest.IsZero() || info.ExternalEmersion.IsZero() {
return fmt.Errorf("%w: complete external contacts and greatest time are required", ErrInvalidLocalPlanetOccultationInfo)
}
if info.Greatest.Before(info.ExternalImmersion) || info.ExternalEmersion.Before(info.Greatest) {
return fmt.Errorf("%w: event times must be ordered", ErrInvalidLocalPlanetOccultationInfo)
}
if info.Type == moon.OccultationTotal {
if !info.HasInternalContacts || info.InternalImmersion.IsZero() || info.InternalEmersion.IsZero() ||
!info.InternalImmersion.After(info.ExternalImmersion) || !info.InternalImmersion.Before(info.Greatest) ||
!info.InternalEmersion.After(info.Greatest) || !info.InternalEmersion.Before(info.ExternalEmersion) {
return fmt.Errorf("%w: a total event requires ordered C2 and C3 contacts", ErrInvalidLocalPlanetOccultationInfo)
}
} else if info.Type != moon.OccultationPartial && info.Type != moon.OccultationGrazing {
return fmt.Errorf("%w: unsupported finite-disk geometry %q", ErrInvalidLocalPlanetOccultationInfo, info.Type)
} else if info.HasInternalContacts || !info.InternalImmersion.IsZero() || !info.InternalEmersion.IsZero() {
return fmt.Errorf("%w: partial and grazing events cannot contain internal contacts", ErrInvalidLocalPlanetOccultationInfo)
}
if !starOccultationFinite(info.MinimumSeparationArcsec) || info.MinimumSeparationArcsec < 0 ||
!starOccultationFinite(info.MoonSemidiameterArcsec) || info.MoonSemidiameterArcsec <= 0 ||
!starOccultationFinite(info.PlanetSemidiameterArcsec) || info.PlanetSemidiameterArcsec <= 0 ||
info.PlanetSemidiameterArcsec >= info.MoonSemidiameterArcsec ||
!starOccultationFinite(info.PositionAngleDeg) ||
!starOccultationFinite(info.MoonAltitudeAtGreatest) ||
!starOccultationFinite(info.MoonAzimuthAtGreatest) {
return fmt.Errorf("%w: event geometry must be finite", ErrInvalidLocalPlanetOccultationInfo)
}
return nil
}
func normalizeLocalPlanetOccultationSVGOptions(options LocalPlanetOccultationSVGOptions) LocalPlanetOccultationSVGOptions {
if options.Width <= 0 {
options.Width = localPlanetOccultationSVGDefaultWidth
}
if options.Height <= 0 {
options.Height = localPlanetOccultationSVGDefaultHeight
}
if options.Step <= 0 {
options.Step = localPlanetOccultationSVGDefaultStep
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
}
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
options.Language = starOccultationSVGLanguageEnglish
} else {
options.Language = starOccultationSVGLanguageChinese
}
return options
}
func renderLocalPlanetOccultationSVG(
info moon.PlanetOccultationInfo,
diagram moon.PlanetOccultationDiagramResult,
options LocalPlanetOccultationSVGOptions,
) string {
title := localPlanetOccultationSVGTitle(info, options)
headerLines := localPlanetOccultationSVGHeaderLines(info, options)
headerBottom := 72.0 + float64(len(headerLines))*19
layout := localPlanetOccultationSVGLayoutFor(options, headerBottom)
events := localPlanetOccultationSVGEventFrames(info, diagram.Frames, options.Language)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`<marker id="local-planet-occultation-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9b3f36"/></marker>`)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`, layout.width-44, layout.height-36)
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
for index, line := range headerLines {
fontSize, fill := 13, "#3b4143"
if index == 0 {
fontSize, fill = 14, "#222222"
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="%d" text-anchor="middle">%s</text>`,
layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line))
}
writeLocalPlanetOccultationOverview(&b, diagram, events, layout, options)
writeLocalPlanetOccultationContacts(&b, events, layout, options)
writeLocalPlanetOccultationStages(&b, events, layout, options)
writeLocalPlanetOccultationFooter(&b, info, layout, options)
b.WriteString(`</svg>`)
return b.String()
}
func localPlanetOccultationSVGLayoutFor(
options LocalPlanetOccultationSVGOptions,
headerBottom float64,
) localStarOccultationSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(34, math.Min(44, width*0.045))
gap := math.Max(18, math.Min(24, width*0.025))
panelWidth := math.Max(210, math.Min(258, width*0.25))
overviewLeft := margin
overviewRight := width - margin - panelWidth - gap
footerSpace := 88.0
stageHeight := math.Max(156, math.Min(190, height*0.25))
stageBottom := height - footerSpace
stageTop := stageBottom - stageHeight
overviewTop := headerBottom + 26
overviewBottom := stageTop - 20
return localStarOccultationSVGLayout{
width: width,
height: height,
margin: margin,
overviewLeft: overviewLeft,
overviewRight: overviewRight,
overviewTop: overviewTop,
overviewBottom: overviewBottom,
panelX: overviewRight + gap,
panelWidth: panelWidth,
stageTop: stageTop,
stageBottom: stageBottom,
footerY: height - 62,
}
}
func writeLocalPlanetOccultationOverview(
b *strings.Builder,
diagram moon.PlanetOccultationDiagramResult,
events []localPlanetOccultationEventFrame,
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.overviewLeft, layout.overviewTop-10, html.EscapeString(localPlanetOccultationSVGOverviewTitle(options)))
cx := (layout.overviewLeft + layout.overviewRight) / 2
cy := (layout.overviewTop+layout.overviewBottom)/2 + 4
extent := localPlanetOccultationSVGExtent(diagram.Frames)
availableWidth := layout.overviewRight - layout.overviewLeft - 74
availableHeight := layout.overviewBottom - layout.overviewTop - 50
scale := math.Min(availableWidth/(2*extent), availableHeight/(2*extent))
if !starOccultationFinite(scale) || scale <= 0 {
scale = 1
}
mapX := func(value float64) float64 { return cx - value*scale }
mapY := func(value float64) float64 { return cy - value*scale }
moonRadius := localPlanetOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
writeLocalPlanetOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalPlanetOccultationTrack(b, diagram.Frames, mapX, mapY)
for _, event := range events {
writeLocalPlanetOccultationDisk(b,
mapX(event.frame.PlanetXArcsec), mapY(event.frame.PlanetYArcsec),
event.frame.PlanetRadiusArcsec*scale, false, "overview-planet-disk", event.label,
)
}
writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon")
for _, event := range events {
x := mapX(event.frame.PlanetXArcsec)
y := mapY(event.frame.PlanetYArcsec)
if event.hidden {
writeLocalPlanetOccultationDisk(b, x, y, event.frame.PlanetRadiusArcsec*scale, true, "overview-planet-outline", event.label)
}
if event.label != "C2" && event.label != "C3" {
writeLocalPlanetOccultationOverviewLabel(b, event, x, y, cx, cy)
}
}
}
func localPlanetOccultationSVGExtent(frames []moon.PlanetOccultationDiagramFrame) float64 {
extent := 1.0
for _, frame := range frames {
extent = math.Max(extent, frame.MoonRadiusArcsec)
extent = math.Max(extent, math.Abs(frame.PlanetXArcsec)+frame.PlanetRadiusArcsec)
extent = math.Max(extent, math.Abs(frame.PlanetYArcsec)+frame.PlanetRadiusArcsec)
}
return extent * 1.28
}
func localPlanetOccultationSVGMaximumMoonRadius(frames []moon.PlanetOccultationDiagramFrame) float64 {
radius := 1.0
for _, frame := range frames {
radius = math.Max(radius, frame.MoonRadiusArcsec)
}
return radius
}
func writeLocalPlanetOccultationTrack(
b *strings.Builder,
frames []moon.PlanetOccultationDiagramFrame,
mapX, mapY func(float64) float64,
) {
if len(frames) < 2 {
return
}
var path strings.Builder
for index, frame := range frames {
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(&path, "%s %.3f %.3f ", command, mapX(frame.PlanetXArcsec), mapY(frame.PlanetYArcsec))
}
fmt.Fprintf(b, `<path class="local-planet-track" d="%s" fill="none" stroke="#9b3f36" stroke-width="1.7" stroke-dasharray="5 4" opacity="0.82" marker-end="url(#local-planet-occultation-arrow)"/>`, strings.TrimSpace(path.String()))
}
func writeLocalPlanetOccultationLunarPath(
b *strings.Builder,
frames []moon.PlanetOccultationDiagramFrame,
mapX, mapY func(float64) float64,
extent float64,
language string,
) {
unitX, unitY, ok := localPlanetOccultationSVGLunarPathDirection(frames)
if !ok {
return
}
lineExtent := extent * 0.92
startX := mapX(-unitX * lineExtent)
startY := mapY(-unitY * lineExtent)
endX := mapX(unitX * lineExtent)
endY := mapY(unitY * lineExtent)
if !starOccultationFinite(startX) || !starOccultationFinite(startY) ||
!starOccultationFinite(endX) || !starOccultationFinite(endY) {
return
}
fmt.Fprintf(b, `<line class="lunar-path-line" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#506975" stroke-width="1.1" stroke-dasharray="7 4" opacity="0.86"/>`,
startX, startY, endX, endY)
labelX, labelY := startX, startY
if endX < startX {
labelX, labelY = endX, endY
}
fmt.Fprintf(b, `<text class="lunar-path-label" x="%.3f" y="%.3f" fill="#405966" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Georgia, 'Times New Roman', serif" font-size="12" text-anchor="end">%s</text>`,
labelX-6, labelY-6, html.EscapeString(localStarOccultationSVGLunarPathLabel(language)))
}
func localPlanetOccultationSVGLunarPathDirection(frames []moon.PlanetOccultationDiagramFrame) (float64, float64, bool) {
if len(frames) < 2 {
return 0, 0, false
}
greatest := -1
for index, frame := range frames {
if localPlanetOccultationSVGFrameHasLabel(frame, "Greatest") {
greatest = index
break
}
}
left, right := 0, len(frames)-1
if greatest > 0 && greatest+1 < len(frames) {
left, right = greatest-1, greatest+1
} else if greatest == 0 {
left, right = 0, 1
} else if greatest == len(frames)-1 {
left, right = len(frames)-2, len(frames)-1
}
dx := frames[right].PlanetXArcsec - frames[left].PlanetXArcsec
dy := frames[right].PlanetYArcsec - frames[left].PlanetYArcsec
length := math.Hypot(dx, dy)
if !starOccultationFinite(length) || length == 0 {
return 0, 0, false
}
return dx / length, dy / length, true
}
func writeLocalPlanetOccultationDisk(
b *strings.Builder,
x, y, radius float64,
hidden bool,
class, label string,
) {
fill, stroke, dash := "#d6ad69", "#704b2d", ""
if hidden {
fill, stroke, dash = "none", "#7d3430", ` stroke-dasharray="2 2"`
}
fmt.Fprintf(b, `<circle class="%s" data-label="%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" stroke="%s" stroke-width="1.2"%s/>`,
html.EscapeString(class), html.EscapeString(label), x, y, math.Max(radius, 0.15), fill, stroke, dash)
}
func writeLocalPlanetOccultationOverviewLabel(
b *strings.Builder,
event localPlanetOccultationEventFrame,
x, y, cx, cy float64,
) {
dx, dy, anchor := 8.0, -9.0, "start"
if x > cx {
dx, anchor = -8, "end"
}
if y < cy-20 {
dy = 15
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#792d28" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="%s">%s</text>`,
x+dx, y+dy, anchor, html.EscapeString(event.name))
}
func writeLocalPlanetOccultationContacts(
b *strings.Builder,
events []localPlanetOccultationEventFrame,
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
layout.panelX-12, layout.overviewTop-12, layout.panelX-12, layout.overviewBottom)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.panelX, layout.overviewTop-10, html.EscapeString(localPlanetOccultationSVGContactsTitle(options)))
available := layout.overviewBottom - layout.overviewTop - 10
rowHeight := math.Min(66, available/math.Max(1, float64(len(events))))
for index, event := range events {
y := layout.overviewTop + 16 + float64(index)*rowHeight
if index > 0 {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e0e1df" stroke-width="0.8"/>`,
layout.panelX, y-12, layout.panelX+layout.panelWidth, y-12)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" font-weight="700">%s</text>`,
layout.panelX, y, html.EscapeString(event.name))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10" text-anchor="end">%s</text>`,
layout.panelX+layout.panelWidth, y, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#536063" font-family="Arial, sans-serif" font-size="9.5">PA %.1f° | %s %.1f° | %s %.1f°</text>`,
layout.panelX, y+16, event.frame.PositionAngleDeg,
localStarOccultationSVGAltitudeLabel(options.Language), event.frame.MoonAltitudeDeg,
localStarOccultationSVGAzimuthLabel(options.Language), event.frame.MoonAzimuthDeg)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b7577" font-family="Arial, sans-serif" font-size="9.5">r %.2f″ | %s</text>`,
layout.panelX, y+31, event.frame.PlanetRadiusArcsec,
html.EscapeString(localStarOccultationSVGVisibilityText(event.frame.MoonAltitudeDeg >= 0, options.Language)))
}
}
func writeLocalPlanetOccultationStages(
b *strings.Builder,
events []localPlanetOccultationEventFrame,
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.margin, layout.stageTop+13, html.EscapeString(localPlanetOccultationSVGPhasePanelsTitle(options)))
if len(events) == 0 {
return
}
usableWidth := layout.width - 2*layout.margin
columnWidth := usableWidth / float64(len(events))
maxMoonRadius := localPlanetOccultationSVGMaximumMoonRadiusFromEvents(events)
radius := math.Min(43, math.Max(31, (layout.stageBottom-layout.stageTop-59)/2))
scale := radius / maxMoonRadius
cy := layout.stageTop + 35 + radius
for index, event := range events {
cx := layout.margin + columnWidth*(float64(index)+0.5)
trueRadius := event.frame.PlanetRadiusArcsec * scale
displayRadius := math.Max(trueRadius, 5.5)
magnification := displayRadius / math.Max(trueRadius, 1e-9)
x, y := localPlanetOccultationSVGStageCenter(event, cx, cy, radius, displayRadius, scale)
fmt.Fprintf(b, `<circle class="stage-planet-disk" data-label="%s" data-true-radius="%.3f" data-magnification="%.3f" cx="%.3f" cy="%.3f" r="%.3f" fill="#d6ad69" stroke="#704b2d" stroke-width="1.1"/>`,
html.EscapeString(event.label), trueRadius, magnification, x, y, displayRadius)
writeLocalStarOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon")
if event.hidden {
fmt.Fprintf(b, `<circle class="stage-planet-hidden-outline" cx="%.3f" cy="%.3f" r="%.3f" fill="none" stroke="#7d3430" stroke-width="1.1" stroke-dasharray="2 2"/>`,
x, y, displayRadius)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10.5" font-weight="700" text-anchor="middle">%s</text>`,
cx, cy+radius+18, html.EscapeString(event.name))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#667174" font-family="Arial, sans-serif" font-size="9.5" text-anchor="middle">%s</text>`,
cx, cy+radius+32, html.EscapeString(event.time.In(options.Location).Format("15:04:05.0")))
}
}
// 阶段面板会放大较小的行星盘面以便观察,并在接触时同步移动显示中心,使 C1/C4 仍外切、C2/C3 仍内切。这只影响展示;事件时刻和总览使用未修改的站心几何。
// The stage panels enlarge small planet disks for visibility and move the display center by the same amount at contacts so C1/C4 remain externally tangent and C2/C3 remain internally tangent. This only affects presentation; event times and the overview use the unmodified topocentric geometry.
func localPlanetOccultationSVGStageCenter(
event localPlanetOccultationEventFrame,
cx, cy, moonRadius, displayRadius, scale float64,
) (float64, float64) {
distance := event.frame.SeparationArcsec * scale
switch event.label {
case "C1", "C4":
distance = moonRadius + displayRadius
case "C2", "C3":
distance = math.Max(0, moonRadius-displayRadius)
}
angle := event.frame.PositionAngleDeg * math.Pi / 180
return cx - distance*math.Sin(angle), cy - distance*math.Cos(angle)
}
func localPlanetOccultationSVGMaximumMoonRadiusFromEvents(events []localPlanetOccultationEventFrame) float64 {
radius := 1.0
for _, event := range events {
radius = math.Max(radius, event.frame.MoonRadiusArcsec)
}
return radius
}
func writeLocalPlanetOccultationFooter(
b *strings.Builder,
info moon.PlanetOccultationInfo,
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
directionLines := starOccultationWrapText(localPlanetOccultationSVGDirectionText(options), layout.width-80, 10)
for index, line := range directionLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#4f595b" font-family="Georgia, 'Times New Roman', serif" font-size="10">%s</text>`,
layout.margin, layout.footerY+float64(index)*14-8, html.EscapeString(line))
}
footerLines := starOccultationWrapText(localPlanetOccultationSVGFooterNote(info, options), layout.width-80, 9)
for index, line := range footerLines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#747c7d" font-family="Georgia, 'Times New Roman', serif" font-size="9">%s</text>`,
layout.margin, layout.footerY+float64(len(directionLines))*14+float64(index)*12-6, html.EscapeString(line))
}
}
func localPlanetOccultationSVGEventFrames(
info moon.PlanetOccultationInfo,
frames []moon.PlanetOccultationDiagramFrame,
language string,
) []localPlanetOccultationEventFrame {
times := map[string]time.Time{
"C1": info.ExternalImmersion,
"C2": info.InternalImmersion,
"Greatest": info.Greatest,
"C3": info.InternalEmersion,
"C4": info.ExternalEmersion,
}
labels := []string{"C1", "Greatest", "C4"}
if info.HasInternalContacts {
labels = []string{"C1", "C2", "Greatest", "C3", "C4"}
}
result := make([]localPlanetOccultationEventFrame, 0, len(labels))
for _, label := range labels {
for _, frame := range frames {
if localPlanetOccultationSVGFrameHasLabel(frame, label) {
hidden := frame.FullyOcculted || info.Type == moon.OccultationTotal &&
(label == "C2" || label == "Greatest" || label == "C3")
result = append(result, localPlanetOccultationEventFrame{
label: label,
name: localPlanetOccultationSVGEventName(label, language),
time: times[label],
hidden: hidden,
frame: frame,
})
break
}
}
}
return result
}
func localPlanetOccultationSVGFrameHasLabel(frame moon.PlanetOccultationDiagramFrame, label string) bool {
if frame.Label == label {
return true
}
for _, current := range frame.Labels {
if current == label {
return true
}
}
return false
}
func localPlanetOccultationSVGHeaderLines(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) []string {
lines := make([]string, 0, 4)
for _, value := range []string{
localPlanetOccultationSVGSummaryText(info, options),
localPlanetOccultationSVGGreatestText(info, options),
} {
lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...)
}
return lines
}
func localPlanetOccultationSVGTargetName(
info moon.PlanetOccultationInfo,
language string,
) string {
target := strings.TrimSpace(info.TargetID)
if language == starOccultationSVGLanguageChinese && (target == "" || target == info.Planet.String()) {
return planetOccultationChineseName(info.Planet)
}
if target != "" {
return target
}
if language == starOccultationSVGLanguageEnglish {
return info.Planet.String()
}
return "行星"
}
func localPlanetOccultationSVGTitle(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) string {
if options.Title != "" {
return options.Title
}
date := info.Greatest.In(options.Location).Format("2006-01-02")
target := localPlanetOccultationSVGTargetName(info, options.Language)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("%s Local Lunar Occultation of %s", date, target)
}
return fmt.Sprintf("%s 指定地点月掩%s", date, target)
}
func localPlanetOccultationSVGSummaryText(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) string {
if options.SummaryText != "" {
return options.SummaryText
}
coordinates := starOccultationFormatCoordinates(info.Observer.Longitude, info.Observer.Latitude)
duration := localStarOccultationSVGFormatDuration(info.ExternalEmersion.Sub(info.ExternalImmersion), options.Language)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Site %s | elevation %.0f m | %s | C1-C4 duration %s", coordinates, info.Observer.Height,
localPlanetOccultationSVGTypeName(info.Type, options.Language), duration)
}
return fmt.Sprintf("观测点 %s | 海拔 %.0f 米 | %s | C1-C4 历时 %s", coordinates, info.Observer.Height,
localPlanetOccultationSVGTypeName(info.Type, options.Language), duration)
}
func localPlanetOccultationSVGGreatestText(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) string {
if options.GreatestText != "" {
return options.GreatestText
}
value := info.Greatest.In(options.Location)
zone := starOccultationLocationLabel(value, options.Location)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest %s %s | separation %.2f arcsec | radii Moon %.2f / planet %.2f arcsec | Moon altitude %+.1f°",
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec,
info.MoonSemidiameterArcsec, info.PlanetSemidiameterArcsec, info.MoonAltitudeAtGreatest)
}
return fmt.Sprintf("掩甚 %s %s | 中心角距 %.2f 角秒 | 视半径 月 %.2f / 行星 %.2f 角秒 | 月球高度 %+.1f°",
value.Format("2006-01-02 15:04:05.0"), zone, info.MinimumSeparationArcsec,
info.MoonSemidiameterArcsec, info.PlanetSemidiameterArcsec, info.MoonAltitudeAtGreatest)
}
func localPlanetOccultationSVGOverviewTitle(options LocalPlanetOccultationSVGOptions) string {
if options.OverviewTitle != "" {
return options.OverviewTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Topocentric planet track (true disk scale)"
}
return "站心行星轨迹(圆盘真实比例)"
}
func localPlanetOccultationSVGPhasePanelsTitle(options LocalPlanetOccultationSVGOptions) string {
if options.PhasePanelsTitle != "" {
return options.PhasePanelsTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "C1-C4 contact stages (planet disk enlarged)"
}
return "C1-C4 接触阶段(行星圆盘放大示意)"
}
func localPlanetOccultationSVGContactsTitle(options LocalPlanetOccultationSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Local contacts"
}
return "本地接触时刻"
}
func localPlanetOccultationSVGDirectionText(options LocalPlanetOccultationSVGOptions) string {
if options.DirectionText != "" {
return options.DirectionText
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Moon fixed at center; east is left and north is up. Blue-gray dashed: local lunar path. Red dashed: planet-center track."
}
return "月球固定在中心;图上左东右西,向上为北。灰蓝虚线为掩甚附近的站心白道,红色虚线为行星中心相对月心轨迹。"
}
func localPlanetOccultationSVGFooterNote(
info moon.PlanetOccultationInfo,
options LocalPlanetOccultationSVGOptions,
) string {
if options.FooterNote != "" {
return options.FooterNote
}
ringNote := ""
if info.Planet == moon.OccultationSaturn {
if options.Language == starOccultationSVGLanguageEnglish {
ringNote = " Saturn's rings are excluded from contact calculation and drawing."
} else {
ringNote = "土星环不参与接触计算,也不作为圆盘边界绘制。"
}
}
if options.Language == starOccultationSVGLanguageEnglish {
return "C1/C4 are external contacts; C2/C3 are internal contacts. Stage disks are enlarged for visibility; contact times use true topocentric radii." + ringNote
}
return "C1/C4 为外切,C2/C3 为内切。阶段图中的行星圆盘为可见性放大,接触时刻按真实站心视半径求解。" + ringNote
}
func localPlanetOccultationSVGEventName(label, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch label {
case "C1":
return "C1 external ingress"
case "C2":
return "C2 internal ingress"
case "Greatest":
return "Greatest"
case "C3":
return "C3 internal egress"
case "C4":
return "C4 external egress"
}
}
switch label {
case "C1":
return "C1 外切始"
case "C2":
return "C2 内切始"
case "Greatest":
return "掩甚"
case "C3":
return "C3 内切终"
case "C4":
return "C4 外切终"
default:
return label
}
}
func localPlanetOccultationSVGTypeName(eventType moon.OccultationType, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch eventType {
case moon.OccultationTotal:
return "total occultation"
case moon.OccultationPartial:
return "partial occultation"
default:
return "grazing occultation"
}
}
switch eventType {
case moon.OccultationTotal:
return "行星圆面全掩"
case moon.OccultationPartial:
return "行星圆面部分掩"
default:
return "行星圆面擦边"
}
}
+165
View File
@@ -0,0 +1,165 @@
package svg
import (
"errors"
"math"
"strings"
"testing"
"time"
"b612.me/astro/moon"
)
func TestFindLocalPlanetOccultationSVGsSaturnFiveContacts(t *testing.T) {
location := time.FixedZone("UTC+8", 8*3600)
diagrams, err := FindLocalPlanetOccultationSVGs(
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
moon.OccultationSaturn,
104.52219613, 55.25401991, 0,
moon.OccultationSearchOptions{},
LocalPlanetOccultationSVGOptions{Width: 920, Height: 700, Location: location},
)
if err != nil {
t.Fatalf("FindLocalPlanetOccultationSVGs() error = %v", err)
}
if len(diagrams) != 1 {
t.Fatalf("FindLocalPlanetOccultationSVGs() returned %d diagrams, want 1", len(diagrams))
}
diagram := diagrams[0]
for _, want := range []string{
`<svg`, `width="920"`, `height="700"`, "2025-02-01", "指定地点月掩土星",
"站心行星轨迹", "圆盘真实比例", "C1 外切始", "C2 内切始", "掩甚", "C3 内切终", "C4 外切终",
"C1-C4 接触阶段", "行星圆盘放大示意", "本地接触时刻", "白道", "土星环不参与接触计算",
`class="local-planet-track"`, `class="lunar-path-line"`, `class="overview-moon"`,
`class="overview-planet-disk"`, `<symbol id="le-moon"`, `href="#le-moon"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("local planetary SVG missing %q", want)
}
}
if strings.Contains(diagram, "全球掩带") || strings.Contains(diagram, "全球中心线") {
t.Fatal("local planetary SVG unexpectedly contains global-path labels")
}
if got := strings.Count(diagram, `class="stage-moon"`); got != 5 {
t.Fatalf("stage Moon count = %d, want 5", got)
}
if got := strings.Count(diagram, `class="stage-planet-disk"`); got != 5 {
t.Fatalf("stage planet count = %d, want 5", got)
}
if err := validateXML(diagram); err != nil {
t.Fatalf("generated local planetary SVG is not valid XML: %v", err)
}
}
func TestLocalPlanetOccultationSVGStageCentersPreserveDisplayedTangency(t *testing.T) {
event := localPlanetOccultationEventFrame{
label: "C2",
frame: moon.PlanetOccultationDiagramFrame{
SeparationArcsec: 970,
PositionAngleDeg: 90,
},
}
x, y := localPlanetOccultationSVGStageCenter(event, 100, 100, 40, 6, 0.04)
if math.Abs(math.Hypot(x-100, y-100)-(40-6)) > 1e-12 {
t.Fatalf("C2 display distance = %.12f, want %.12f", math.Hypot(x-100, y-100), float64(40-6))
}
event.label = "C4"
x, y = localPlanetOccultationSVGStageCenter(event, 100, 100, 40, 6, 0.04)
if math.Abs(math.Hypot(x-100, y-100)-(40+6)) > 1e-12 {
t.Fatalf("C4 display distance = %.12f, want %.12f", math.Hypot(x-100, y-100), float64(40+6))
}
}
func TestLocalPlanetOccultationSVGEnglishAndCustomText(t *testing.T) {
info := localSaturnOccultation(t)
diagram, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{
Language: "en",
Location: time.UTC,
Title: "Custom local planet title",
SummaryText: "Custom local planet summary",
GreatestText: "Custom local planet greatest",
OverviewTitle: "Custom local planet overview",
PhasePanelsTitle: "Custom local planet stages",
ContactsTitle: "Custom local planet contacts",
DirectionText: "Custom local planet direction",
FooterNote: "Custom local planet footer",
})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
for _, want := range []string{
"Custom local planet title", "Custom local planet summary", "Custom local planet greatest",
"Custom local planet overview", "Custom local planet stages", "Custom local planet contacts",
"Custom local planet direction", "Custom local planet footer",
"C1 external ingress", "C2 internal ingress", "Greatest", "C3 internal egress", "C4 external egress",
} {
if !strings.Contains(diagram, want) {
t.Fatalf("English local planetary SVG missing %q", want)
}
}
if err := validateXML(diagram); err != nil {
t.Fatalf("English local planetary SVG is not valid XML: %v", err)
}
}
func TestLocalPlanetOccultationSVGRejectsInvalidInput(t *testing.T) {
info := localSaturnOccultation(t)
_, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{Width: 1})
if !errors.Is(err, ErrInvalidLocalPlanetOccultationSVGOptions) {
t.Fatalf("invalid canvas error = %v, want ErrInvalidLocalPlanetOccultationSVGOptions", err)
}
info.ContactsComplete = false
_, err = LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidLocalPlanetOccultationInfo) {
t.Fatalf("incomplete event error = %v, want ErrInvalidLocalPlanetOccultationInfo", err)
}
}
func TestLocalPlanetOccultationSVGRejectsTargetGeometryMismatch(t *testing.T) {
info := localSaturnOccultation(t)
info.Planet = moon.OccultationVenus
info.TargetID = "Venus"
_, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidLocalPlanetOccultationInfo) {
t.Fatalf("mismatched planet error = %v, want ErrInvalidLocalPlanetOccultationInfo", err)
}
}
func TestLocalPlanetOccultationSVGPartialEventUsesThreeStages(t *testing.T) {
events, err := moon.FindPlanetOccultations(
time.Date(2024, time.August, 21, 1, 30, 0, 0, time.UTC),
time.Date(2024, time.August, 21, 4, 0, 0, 0, time.UTC),
moon.OccultationSaturn, -30.072, -6.5, 0, moon.OccultationSearchOptions{},
)
if err != nil || len(events) != 1 {
t.Fatalf("FindPlanetOccultations() = %d events, %v; want one", len(events), err)
}
diagram, err := LocalPlanetOccultationSVG(events[0], LocalPlanetOccultationSVGOptions{Language: "en"})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
if got := strings.Count(diagram, `class="stage-planet-disk"`); got != 3 {
t.Fatalf("partial event stage count = %d, want 3", got)
}
if strings.Contains(diagram, "C2 internal ingress") || strings.Contains(diagram, "C3 internal egress") {
t.Fatal("partial event SVG contains internal contacts")
}
}
func localSaturnOccultation(t *testing.T) moon.PlanetOccultationInfo {
t.Helper()
location := time.FixedZone("UTC+8", 8*3600)
events, err := moon.FindPlanetOccultations(
time.Date(2025, time.February, 1, 0, 0, 0, 0, location),
time.Date(2025, time.February, 2, 0, 0, 0, 0, location),
moon.OccultationSaturn, 104.52219613, 55.25401991, 0, moon.OccultationSearchOptions{},
)
if err != nil {
t.Fatalf("FindPlanetOccultations() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("FindPlanetOccultations() returned %d events, want 1", len(events))
}
return events[0]
}
+253
View File
@@ -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,
}
}
+345
View File
@@ -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 &lt;occultation&gt; &amp; 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
}
}
}
+157
View File
@@ -0,0 +1,157 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/moon"
)
func writeOccultationTimeMarkers(
b *strings.Builder,
points []moon.OccultationPathPoint,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
excluded []time.Time,
greatest time.Time,
) {
if options.TimeLabelStep <= 0 || len(points) < 2 {
return
}
markers := occultationTimeMarkerPoints(points, options.TimeLabelStep, options.Location, excluded)
frame := layout.mapFrame()
projected := make([][2]float64, 0, len(markers))
for _, marker := range markers {
if marker.MoonAltitude < 0 {
continue
}
x, y, visible := frame.Project(marker.Longitude, marker.Latitude)
if !visible || x < frame.X+22 || x > frame.X+frame.Width-22 || y < frame.Y+12 || y > frame.Y+frame.Height-12 {
continue
}
tooClose := false
for _, previous := range projected {
if math.Hypot(x-previous[0], y-previous[1]) < 44 {
tooClose = true
break
}
}
if tooClose {
continue
}
projected = append(projected, [2]float64{x, y})
labelY := y - 8
if occultationTimesNear(marker.Time, greatest, occultationGreatestTimeLabelWindow(options.TimeLabelStep)) {
labelY = y + 15
} else if labelY < frame.Y+10 {
labelY = y + 15
}
fmt.Fprintf(b, `<g class="occultation-time-marker"><circle cx="%.3f" cy="%.3f" r="2.3" fill="#087f8c" stroke="#ffffff" stroke-width="1"/><text x="%.3f" y="%.3f" fill="#075f69" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text></g>`,
x, y, x, labelY, html.EscapeString(marker.Time.In(options.Location).Format("15:04")))
}
}
func occultationGreatestTimeLabelWindow(step time.Duration) time.Duration {
window := step / 3
if window < 10*time.Minute {
return 10 * time.Minute
}
return window
}
func occultationTimeMarkerPoints(
points []moon.OccultationPathPoint,
step time.Duration,
location *time.Location,
excluded []time.Time,
) []moon.OccultationPathPoint {
if len(points) < 2 || step <= 0 {
return nil
}
start := points[0].Time
end := points[len(points)-1].Time
current := firstOccultationTimeLabelAfter(start, step, location)
window := step / 4
if window > 5*time.Minute {
window = 5 * time.Minute
}
if window < 30*time.Second {
window = 30 * time.Second
}
result := make([]moon.OccultationPathPoint, 0)
segment := 1
for current.Before(end) {
for segment < len(points) && points[segment].Time.Before(current) {
segment++
}
if segment >= len(points) {
break
}
if !occultationTimeNearAny(current, excluded, window) {
a, next := points[segment-1], points[segment]
span := next.Time.Sub(a.Time)
if span > 0 {
fraction := float64(current.Sub(a.Time)) / float64(span)
result = append(result, interpolateOccultationTimeMarker(a, next, fraction, current))
}
}
current = current.Add(step)
}
return result
}
func interpolateOccultationTimeMarker(
a, b moon.OccultationPathPoint,
fraction float64,
value time.Time,
) moon.OccultationPathPoint {
deltaLongitude := b.Longitude - a.Longitude
if deltaLongitude > 180 {
deltaLongitude -= 360
} else if deltaLongitude < -180 {
deltaLongitude += 360
}
longitude := a.Longitude + fraction*deltaLongitude
if longitude > 180 {
longitude -= 360
} else if longitude < -180 {
longitude += 360
}
point := starOccultationInterpolatePathPoint(a, b, fraction, longitude)
point.Time = value
return point
}
func firstOccultationTimeLabelAfter(value time.Time, step time.Duration, location *time.Location) time.Time {
local := value.In(location)
dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
elapsed := local.Sub(dayStart)
return dayStart.Add((elapsed/step + 1) * step)
}
func occultationTimeNearAny(value time.Time, excluded []time.Time, window time.Duration) bool {
for _, candidate := range excluded {
if candidate.IsZero() {
continue
}
delta := value.Sub(candidate)
if delta < 0 {
delta = -delta
}
if delta <= window {
return true
}
}
return false
}
func occultationTimesNear(a, b time.Time, window time.Duration) bool {
delta := a.Sub(b)
if delta < 0 {
delta = -delta
}
return delta <= window
}