feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+64 -1
View File
@@ -1,6 +1,10 @@
package moon
import "b612.me/astro/basic"
import (
"time"
"b612.me/astro/basic"
)
// ErrInvalidOccultationInput 表示月掩输入无效。
// ErrInvalidOccultationInput reports invalid lunar-occultation input.
@@ -96,6 +100,16 @@ type OccultationSearchOptions = basic.OccultationSearchOptions
// OccultationPathOptions controls global occultation-path sampling.
type OccultationPathOptions = basic.OccultationPathOptions
// OccultationPathAlgorithm 选择全球月掩路径的优化或原有精确分支。
// OccultationPathAlgorithm selects the optimized or original exact global-path branch.
type OccultationPathAlgorithm = basic.OccultationPathAlgorithm
// 全球月掩路径的两条算法分支取值 / the two global occultation-path algorithm values.
const (
OccultationPathAlgorithmOptimized = basic.OccultationPathAlgorithmOptimized
OccultationPathAlgorithmExact = basic.OccultationPathAlgorithmExact
)
// StarOccultationInfo 描述一次点光源恒星月掩。
// StarOccultationInfo describes one point-source stellar occultation.
type StarOccultationInfo = basic.StarOccultationInfo
@@ -108,10 +122,39 @@ type PlanetOccultationInfo = basic.PlanetOccultationInfo
// OccultationPathPoint is a geographic sample of a global occultation path.
type OccultationPathPoint = basic.OccultationPathPoint
// RiseSetPhase 标识局部月掩阶段。
// RiseSetPhase identifies a local occultation phase.
type RiseSetPhase = basic.RiseSetPhase
// RiseSetDirection 标识月升或月落方向。
// RiseSetDirection identifies moonrise or moonset.
type RiseSetDirection = basic.RiseSetDirection
// 与 basic 同名的阶段与方向常量 / the phase and direction constants re-exported from basic.
const (
RiseSetPhaseStart = basic.RiseSetPhaseStart
RiseSetPhaseGreatest = basic.RiseSetPhaseGreatest
RiseSetPhaseEnd = basic.RiseSetPhaseEnd
RiseSetDirectionRise = basic.RiseSetDirectionRise
RiseSetDirectionSet = basic.RiseSetDirectionSet
)
// OccultationRiseSetCurve 是局部阶段发生在月升或月落时的边界。
// OccultationRiseSetCurve is a boundary where a local phase occurs at moonrise or moonset.
type OccultationRiseSetCurve = basic.OccultationRiseSetCurve
// OccultationGreatestTimeContour 是一个固定地方掩甚时刻的等值线支路集合。
// OccultationGreatestTimeContour contains the continuous branches of one fixed local greatest-occultation time.
type OccultationGreatestTimeContour = basic.OccultationGreatestTimeContour
// StarOccultationPath 包含点光源恒星月掩掩带。
// StarOccultationPath contains a point-source stellar occultation footprint.
type StarOccultationPath = basic.StarOccultationPath
// OccultationFootprint 是一个时刻的可见接触区域。
// OccultationFootprint is one instantaneous visible contact region.
type OccultationFootprint = basic.OccultationFootprint
// PlanetOccultationPath 包含有限盘面行星月掩掩带。
// PlanetOccultationPath contains a finite-disk planetary occultation footprint.
type PlanetOccultationPath = basic.PlanetOccultationPath
@@ -119,3 +162,23 @@ type PlanetOccultationPath = basic.PlanetOccultationPath
// PlanetOccultationFootprint 是一个时刻的可见行星接触区域。
// PlanetOccultationFootprint is one instantaneous visible planetary-contact region.
type PlanetOccultationFootprint = basic.PlanetOccultationFootprint
// StarOccultationInstant 是一个请求时刻的点源恒星月掩可见足迹。
// StarOccultationInstant is the visible point-source footprint at one requested instant.
type StarOccultationInstant = basic.StarOccultationInstant
// PlanetOccultationInstant 是一个请求时刻的行星偏掩和全掩可见足迹。
// PlanetOccultationInstant contains the visible partial and total planetary footprints at one requested instant.
type PlanetOccultationInstant = basic.PlanetOccultationInstant
// StarOccultationFootprintAt 计算指定时刻的精确恒星月掩可见足迹。
// StarOccultationFootprintAt calculates the exact visible stellar-occultation footprint at one instant.
func StarOccultationFootprintAt(at time.Time, star StarCoordinate) (StarOccultationInstant, error) {
return basic.StarOccultationFootprintAt(at, star)
}
// PlanetOccultationFootprintsAt 计算指定时刻的精确行星偏掩和全掩可见足迹。
// PlanetOccultationFootprintsAt calculates the exact visible partial and total planetary footprints at one instant.
func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (PlanetOccultationInstant, error) {
return basic.PlanetOccultationFootprintsAt(at, planet)
}
+34
View File
@@ -5,8 +5,41 @@ import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
func TestStarOccultationBoundaryTimesRemainStrictAfterAdaptiveConvergence(t *testing.T) {
data, err := basic.StarDataByChinese("进贤增九")
if err != nil {
t.Fatal(err)
}
star, err := StarCoordinateFromStarData(data)
if err != nil {
t.Fatal(err)
}
for _, date := range []time.Time{
time.Date(2033, time.March, 17, 0, 0, 0, 0, time.UTC),
time.Date(2050, time.December, 8, 0, 0, 0, 0, time.UTC),
} {
paths, err := FindStarOccultationPaths(date, date.Add(24*time.Hour), star,
OccultationPathOptions{Step: 10 * time.Minute, DisableFootprints: true, DisableRiseSet: true})
if err != nil {
t.Fatalf("%s: FindStarOccultationPaths: %v", date.Format("2006-01-02"), err)
}
if len(paths) == 0 {
t.Fatalf("%s: no occultation path", date.Format("2006-01-02"))
}
for _, points := range [][]OccultationPathPoint{paths[0].NorthernLimit, paths[0].SouthernLimit, paths[0].CenterLine} {
for index := 1; index < len(points); index++ {
if !points[index].Time.After(points[index-1].Time) {
t.Fatalf("%s: non-increasing path time at %d: %s then %s", date.Format("2006-01-02"), index, points[index-1].Time, points[index].Time)
}
}
}
}
}
func TestFindStarOccultationPathsHR4799(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
star := StarCoordinate{
@@ -221,6 +254,7 @@ func BenchmarkFindStarOccultationPathTargetSpacing(b *testing.B) {
star := hr4799CoordinateForTest()
for _, spacing := range []float64{1, 10, 200} {
b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
paths, err := FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
+1
View File
@@ -120,6 +120,7 @@ func TestGlobalPlanetOccultationQueriesSelectByGeometricGreatest(t *testing.T) {
func BenchmarkFindPlanetOccultationPathTargetSpacing(b *testing.B) {
for _, spacing := range []float64{1, 10, 200} {
b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
paths, err := FindPlanetOccultationPaths(
time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
+45
View File
@@ -124,3 +124,48 @@ func TestFindPlanetOccultationsValidatesInputs(t *testing.T) {
})
}
}
func TestFindBestPlanetOccultationsKeepsNonCentralEvents(t *testing.T) {
// 2025-01-05 月掩海王星与 2025-07-28 月掩火星都是月影轴不与地球椭球相交的非中心
// 事件:最佳站心曾取外接触切点,该点位于掩带边缘、落在掩食之外,整场被事件搜索
// 丢弃。事件搜索与路径搜索必须报告同一场事件,掩甚站心必须落在掩食之内。
// The 2025-01-05 Neptune and 2025-07-28 Mars occultations are non-central events
// whose shadow axis misses the ellipsoid. The best station used to be taken at the
// outer contact tangent, which sits on the band edge and outside the occultation, so
// the whole event was dropped from the search. The event and path searches must
// report the same event with a greatest station inside the occultation.
tests := []struct {
name string
planet OccultationPlanet
day time.Time
}{
{name: "2025-01-05 Neptune", planet: OccultationNeptune, day: time.Date(2025, time.January, 5, 0, 0, 0, 0, time.UTC)},
{name: "2025-07-28 Mars", planet: OccultationMars, day: time.Date(2025, time.July, 28, 0, 0, 0, 0, time.UTC)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
start := test.day.Add(-24 * time.Hour)
end := test.day.Add(24 * time.Hour)
events, err := FindBestPlanetOccultations(start, end, test.planet, OccultationSearchOptions{})
if err != nil {
t.Fatalf("FindBestPlanetOccultations() error = %v", err)
}
paths, err := FindPlanetOccultationPaths(start, end, test.planet, OccultationPathOptions{})
if err != nil {
t.Fatalf("FindPlanetOccultationPaths() error = %v", err)
}
if len(events) != 1 || len(paths) != 1 {
t.Fatalf("event search returned %d events, path search %d, want one each", len(events), len(paths))
}
event := events[0]
limit := event.MoonSemidiameterArcsec + event.PlanetSemidiameterArcsec
if event.MinimumSeparationArcsec > limit {
t.Fatalf("greatest station lies outside the occultation: separation=%.3f limit=%.3f",
event.MinimumSeparationArcsec, limit)
}
if delta := math.Abs(event.Greatest.Sub(paths[0].Greatest.Time).Seconds()); delta > 60 {
t.Fatalf("greatest differs from the path search by %.1fs", delta)
}
})
}
}
+43
View File
@@ -196,3 +196,46 @@ func TestFindStarOccultationsValidatesInputs(t *testing.T) {
t.Fatalf("FindStarOccultations() observer error = %v, want ErrInvalidOccultationInput", err)
}
}
func TestFindBestStarOccultationsKeepsNonCentralEvents(t *testing.T) {
// HR 1118 的黄纬为 +5.58°,接近月掩纬度极限,因此 2025-02-06 与 2025-05-26 的月掩
// 都是月影轴不与地球椭球相交的非中心事件;最佳站心曾取外接触切点而整场丢失。
// HR 1118 sits at ecliptic latitude +5.58°, close to the occultation latitude limit,
// so its 2025-02-06 and 2025-05-26 events are non-central: the shadow axis misses the
// ellipsoid and the best station used to be taken at the outer contact tangent, which
// dropped the whole event from the search.
star := StarCoordinate{
ID: "HR 1118",
RA: 55.19291667,
Dec: 25.32944444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
Frame: CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: 8,
ProperMotionDecMasPerYear: -15,
}
for _, day := range []time.Time{
time.Date(2025, time.February, 6, 0, 0, 0, 0, time.UTC),
time.Date(2025, time.May, 26, 0, 0, 0, 0, time.UTC),
} {
t.Run(day.Format("2006-01-02"), func(t *testing.T) {
start := day.Add(-24 * time.Hour)
end := day.Add(24 * time.Hour)
events, err := FindBestStarOccultations(start, end, star, OccultationSearchOptions{})
if err != nil {
t.Fatalf("FindBestStarOccultations() error = %v", err)
}
paths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{})
if err != nil {
t.Fatalf("FindStarOccultationPaths() error = %v", err)
}
if len(events) != 1 || len(paths) != 1 {
t.Fatalf("event search returned %d events, path search %d, want one each", len(events), len(paths))
}
event := events[0]
if event.MinimumSeparationArcsec > event.MoonSemidiameterArcsec {
t.Fatalf("greatest station lies outside the occultation: separation=%.3f moon=%.3f",
event.MinimumSeparationArcsec, event.MoonSemidiameterArcsec)
}
})
}
}
+176
View File
@@ -0,0 +1,176 @@
package svg
import (
"strings"
"testing"
"b612.me/astro/internal/svgchart"
)
// 调用方给图题、摘要、地图标题与页脚传超长文本时,产物必须留在画布内并在截断处给出省略号;
// 判定口径与 internal/svgchart 的 EstimatedTextWidth/EstimatedTextExtents 一致。
func footerFitLongText(prefix string) string {
piece := prefix + "long custom text 自定义长文本,用于验证折行与截断;"
value := ""
for len([]rune(value)) < 640 {
value += piece
}
return value
}
func footerFitViolations(t *testing.T, diagram string, width, height int) []string {
t.Helper()
violations := []string{}
for _, text := range occultationSVGTexts(t, diagram) {
textWidth := svgchart.EstimatedTextWidth(text.value, text.fontSize)
left := text.x
switch text.anchor {
case "middle":
left = text.x - textWidth/2
case "end":
left = text.x - textWidth
}
above, below := svgchart.EstimatedTextExtents(text.fontSize)
if text.x < 0 || text.x > float64(width) || text.y < 0 || text.y > float64(height) {
violations = append(violations, text.value)
continue
}
if left < 0 || left+textWidth > float64(width) || text.y-above < 0 || text.y+below > float64(height) {
violations = append(violations, text.value)
}
}
return violations
}
func footerFitAssert(t *testing.T, name string, diagram string, width, height int) {
t.Helper()
if violations := footerFitViolations(t, diagram, width, height); len(violations) > 0 {
t.Fatalf("%s: %d texts outside the %dx%d canvas, first = %q",
name, len(violations), width, height, violations[0])
}
if !strings.Contains(diagram, "…") {
t.Fatalf("%s: truncated custom text is not marked", name)
}
}
func footerFitStarTexts(long string) StarOccultationSVGOptions {
return StarOccultationSVGOptions{
Title: long, SummaryText: long, GreatestText: long,
MapTitle: long, ContactsTitle: long, FooterNote: long,
}
}
func TestStarOccultationPathSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("star ")
if runes := len([]rune(long)); runes < 300 || runes > 800 {
t.Fatalf("long text runes = %d, want 300..800", runes)
}
path := occultationTestStarPath(t)
for _, size := range [][2]int{{640, 480}, {920, 720}, {1200, 800}} {
options := footerFitStarTexts(long)
options.Width, options.Height = size[0], size[1]
diagram, err := StarOccultationPathSVG(path, options)
if err != nil {
t.Fatalf("%dx%d: %v", size[0], size[1], err)
}
footerFitAssert(t, "star-global", diagram, size[0], size[1])
}
}
func TestPlanetOccultationPathSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("planet ")
path := occultationTestSaturnPath(t)
for _, size := range [][2]int{{640, 480}, {920, 720}, {1200, 800}} {
options := PlanetOccultationSVGOptions(footerFitStarTexts(long))
options.Width, options.Height = size[0], size[1]
diagram, err := PlanetOccultationPathSVG(path, options)
if err != nil {
t.Fatalf("%dx%d: %v", size[0], size[1], err)
}
footerFitAssert(t, "planet-global", diagram, size[0], size[1])
}
}
func TestOccultationDetailedSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("detailed ")
star := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
for _, size := range [][2]int{{700, 990}, {1000, 1414}} {
diagram, err := StarOccultationDetailedSVG(star, hr4799StarCoordinate(), OccultationDetailedSVGOptions{
Width: size[0], Height: size[1], Title: long, FooterNote: long,
})
if err != nil {
t.Fatalf("star %dx%d: %v", size[0], size[1], err)
}
footerFitAssert(t, "star-detailed", diagram, size[0], size[1])
diagram, err = PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{
Width: size[0], Height: size[1], Title: long, FooterNote: long,
})
if err != nil {
t.Fatalf("planet %dx%d: %v", size[0], size[1], err)
}
footerFitAssert(t, "planet-detailed", diagram, size[0], size[1])
}
}
func TestLocalStarOccultationSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("local ")
info := localHR4799Occultation(t)
for _, size := range [][2]int{{640, 520}, {920, 720}} {
diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Width: size[0], Height: size[1], Title: long, SummaryText: long, GreatestText: long,
OverviewTitle: long, PhasePanelsTitle: long, ContactsTitle: long,
DirectionText: long, FooterNote: long,
})
if err != nil {
t.Fatalf("%dx%d: %v", size[0], size[1], err)
}
footerFitAssert(t, "star-local", diagram, size[0], size[1])
}
}
func TestLocalPlanetOccultationSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("local-planet ")
info := localSaturnOccultation(t)
for _, size := range [][2]int{{760, 600}, {920, 720}} {
diagram, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{
Width: size[0], Height: size[1], Title: long, SummaryText: long, GreatestText: long,
OverviewTitle: long, PhasePanelsTitle: long, ContactsTitle: long,
DirectionText: long, FooterNote: long,
})
if err != nil {
t.Fatalf("%dx%d: %v", size[0], size[1], err)
}
footerFitAssert(t, "planet-local", diagram, size[0], size[1])
}
}
// 自动生成的默认文案不得进入截断分支:这些产物在改动前后逐字节一致,出现省略号即说明分流写错。
func TestOccultationSVGDefaultTextIsNotTruncated(t *testing.T) {
star := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
for _, language := range []string{"zh", "en"} {
documents := []string{}
starGlobal, err := StarOccultationPathSVG(star, StarOccultationSVGOptions{Language: language})
if err != nil {
t.Fatalf("star global %s: %v", language, err)
}
documents = append(documents, starGlobal)
planetGlobal, err := PlanetOccultationPathSVG(planetPath, PlanetOccultationSVGOptions{Language: language})
if err != nil {
t.Fatalf("planet global %s: %v", language, err)
}
documents = append(documents, planetGlobal)
detailed, err := StarOccultationDetailedSVG(star, hr4799StarCoordinate(), OccultationDetailedSVGOptions{Language: language})
if err != nil {
t.Fatalf("star detailed %s: %v", language, err)
}
documents = append(documents, detailed)
for _, document := range documents {
if strings.Contains(document, "…") {
t.Fatalf("%s: default text must stay unmarked", language)
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package svg
import (
"testing"
eclipsesvg "b612.me/astro/eclipse/svg"
"b612.me/astro/internal/svgmap"
)
// 两包为同一投影概念各留一套名字:取值字符串与内部 svgmap 映射必须逐对一致。
func TestMapProjectionMatchesEclipseSVGContract(t *testing.T) {
pairs := []struct {
name string
value MapProjection
eclipse eclipsesvg.EclipseMapProjection
text string
}{
{name: "auto", value: MapProjectionAuto, eclipse: eclipsesvg.EclipseMapProjectionAuto, text: ""},
{name: "equirectangular", value: MapProjectionEquirectangular, eclipse: eclipsesvg.EclipseMapProjectionEquirectangular, text: "equirectangular"},
{name: "north-polar", value: MapProjectionNorthPolar, eclipse: eclipsesvg.EclipseMapProjectionNorthPolar, text: "north-polar"},
{name: "south-polar", value: MapProjectionSouthPolar, eclipse: eclipsesvg.EclipseMapProjectionSouthPolar, text: "south-polar"},
{name: "orthographic", value: MapProjectionOrthographic, eclipse: eclipsesvg.EclipseMapProjectionOrthographic, text: "orthographic"},
}
seen := map[svgmap.Projection]string{}
for _, pair := range pairs {
if got := string(pair.value); got != pair.text {
t.Fatalf("moon/svg %s = %q, want %q", pair.name, got, pair.text)
}
if got := string(pair.eclipse); got != pair.text {
t.Fatalf("eclipse/svg %s = %q, want %q", pair.name, got, pair.text)
}
moonProjection := internalMapProjection(pair.value)
if moonProjection != svgmap.Projection(pair.text) {
t.Fatalf("moon/svg %s maps to %q, want %q", pair.name, moonProjection, svgmap.Projection(pair.text))
}
if eclipseProjection := svgmap.Projection(pair.eclipse); eclipseProjection != moonProjection {
t.Fatalf("%s maps to %q in moon/svg and %q in eclipse/svg", pair.name, moonProjection, eclipseProjection)
}
if previous, ok := seen[moonProjection]; ok {
t.Fatalf("%s and %s share the internal projection %q", previous, pair.name, moonProjection)
}
seen[moonProjection] = pair.name
}
}
+348 -85
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
@@ -17,8 +19,8 @@ import (
const (
starOccultationSVGDefaultWidth = 1200
starOccultationSVGDefaultHeight = 800
starOccultationSVGMinimumWidth = 480
starOccultationSVGMinimumHeight = 360
starOccultationSVGMinimumWidth = 640
starOccultationSVGMinimumHeight = 480
starOccultationSVGDefaultZone = 8 * 60 * 60
starOccultationSVGLanguageChinese = "zh"
@@ -32,8 +34,9 @@ var ErrInvalidStarOccultationSVGOptions = errors.New("invalid stellar occultatio
// 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 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值,宽度小于 640 或高度小于 480 时返回 ErrInvalidStarOccultationSVGOptions。
// Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default;
// a width below 640 or a height below 480 returns ErrInvalidStarOccultationSVGOptions.
Width int
Height int
// Title 及后续文本字段覆盖自动生成的标签。
@@ -96,7 +99,12 @@ func StarOccultationPathSVG(
return "", err
}
options = normalizeStarOccultationSVGOptions(options)
return renderStarOccultationPathSVG(path, options), nil
diagram, err := renderStarOccultationPathSVG(path, options)
if err != nil {
// 几何引擎失败不是数据格式错误,不能裹成 ErrInvalidStarOccultationPath 哨兵。
return "", fmt.Errorf("stellar occultation SVG band geometry: %w", err)
}
return diagram, nil
}
func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error {
@@ -107,7 +115,7 @@ func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumHeight)
}
switch options.Projection {
case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar:
case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar, MapProjectionOrthographic:
default:
return fmt.Errorf("%w: unsupported map projection %q", ErrInvalidStarOccultationSVGOptions, options.Projection)
}
@@ -139,21 +147,53 @@ func normalizeStarOccultationSVGOptions(options StarOccultationSVGOptions) StarO
return options
}
func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
return renderOccultationPathSVG(path, nil, options)
func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) (string, error) {
return renderOccultationPathSVG(path, nil, options, starOccultationSVGTextFlags(options))
}
// occultationSVGTextFlags 记录哪些文本字段由调用方显式给出:只有这类文本走保守折行与截断,
// 自动文案必须逐字节保持原样。
type occultationSVGTextFlags struct {
title bool
summaryText bool
greatestText bool
footerNote bool
}
func starOccultationSVGTextFlags(options StarOccultationSVGOptions) occultationSVGTextFlags {
return occultationSVGTextFlags{
title: options.Title != "",
summaryText: options.SummaryText != "",
greatestText: options.GreatestText != "",
footerNote: options.FooterNote != "",
}
}
func renderOccultationPathSVG(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options StarOccultationSVGOptions,
) string {
flags occultationSVGTextFlags,
) (string, error) {
projection := resolveStarOccultationMapProjection(path, options.Projection)
options.Projection = MapProjection(projection)
title := starOccultationSVGTitle(path, options)
headerLines := starOccultationSVGHeaderLines(path, options)
headerLines := starOccultationSVGHeaderLines(path, options, flags)
// 页眉字号 14/13,省略号按较大的 14 估算。
headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14,
starOccultationSVGHeaderLineLimit(options, projection))
headerBottom := 72.0 + float64(len(headerLines))*19
layout := starOccultationSVGLayoutFor(options, headerBottom, projection)
// 只有正射球面需要视点:不给就会退回 (0°,0°) 而完全不对准事件,与日食球面图不一致。
// 等经纬与极区图不受影响,避免改动既有版面。
center := svgmap.GeoPoint{}
if projection == svgmap.ProjectionOrthographic {
if planetPath != nil {
center = planetOccultationOrthographicCentre(*planetPath)
} else {
center = svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}
}
}
layout := starOccultationSVGLayoutFor(options, headerBottom, projection, center)
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">`,
@@ -164,8 +204,13 @@ func renderOccultationPathSVG(
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)
titleSize := starOccultationTitleFontSize(title, layout.width)
titleText := title
if flags.title {
titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize))
}
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))
layout.width/2, titleSize, html.EscapeString(titleText))
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
@@ -178,18 +223,24 @@ func renderOccultationPathSVG(
layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line))
}
mapTitle := occultationTitleText(starOccultationSVGMapTitle(options), options.MapTitle != "",
layout.width-layout.mapX-layout.margin, 14)
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)))
layout.mapX, layout.mapY-10, html.EscapeString(mapTitle))
if planetPath == nil {
writeStarOccultationMap(&b, path, layout, options)
if err := writeStarOccultationMap(&b, path, layout, options); err != nil {
return "", err
}
writeStarOccultationEventsPanel(&b, path, layout, options)
} else {
writePlanetOccultationMap(&b, *planetPath, path, layout, options)
if err := writePlanetOccultationMap(&b, *planetPath, path, layout, options); err != nil {
return "", err
}
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
}
writeStarOccultationFooter(&b, layout, options)
writeStarOccultationFooter(&b, layout, options, flags)
b.WriteString(`</svg>`)
return b.String()
return b.String(), nil
}
func writeStarOccultationMap(
@@ -197,22 +248,69 @@ func writeStarOccultationMap(
path moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
) error {
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")
band := occultationBandDraw{}
footprints := path.BandFootprints
compact := len(footprints) > 0
if len(footprints) == 0 {
footprints = path.Footprints
}
if len(footprints) > 0 {
draw, err := (occultationFootprintSweep{
footprints: footprints,
contours: path.BandContours,
visibilityContours: path.VisibilityContours,
northern: path.NorthernLimit,
southern: path.SouthernLimit,
curves: path.RiseSetCurves,
layout: layout,
layerClass: "occultation-band-layer",
pathClass: "occultation-band",
color: "#e0ae43",
opacity: 0.28,
compactBand: compact,
kind: starOccultationFootprintSweep,
}).write(b)
if err != nil {
return err
}
band = draw
} else {
writeStarOccultationBand(b, path, layout)
}
if !band.compact {
writeStarOccultationGeoLine(b, path.NorthernLimit, layout,
occultationLineStyle{className: "northern-limit", color: "#a66f18", strokeWidth: 1.35, boundary: true})
writeStarOccultationGeoLine(b, path.SouthernLimit, layout,
occultationLineStyle{className: "southern-limit", color: "#a66f18", strokeWidth: 1.35, boundary: true})
}
// Draw phase curves after the translucent band so the physical rise/set
// lines remain visible across the filled area. These are open phase curves,
// not an artificial outline of the static band.
writeOccultationRiseSetCurves(b, path.RiseSetCurves, layout, "occultation")
writeOccultationHorizonConnectors(
b, footprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, layout,
true,
)
// 事件标记先占位、后绘制:三族标签共用一张占位表,后一族必须绕开先放置的标签。
placer := &occultationLabelPlacer{}
eventMarkers := starOccultationEventMarkerDraws(path, layout, options.Language, placer)
writeOccultationGreatestTimeContours(b, path.GreatestTimeContours, layout, options, placer)
writeStarOccultationGeoLine(b, path.CenterLine, layout,
occultationLineStyle{className: "center-line", color: "#59676b", strokeWidth: 1.6, dash: "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")
[]time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time, placer)
for _, marker := range eventMarkers {
marker.write(b)
}
layout.mapFrame().WriteFrame(b)
writeStarOccultationLegend(b, layout, options.Language)
writeStarOccultationLegend(b, layout, options.Language, band, len(path.RiseSetCurves) > 0,
len(path.GreatestTimeContours) > 0)
return nil
}
func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVGLayout) {
@@ -227,8 +325,14 @@ func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVG
}
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)
text := fmt.Sprintf("%.0f°", latitude)
// 图框左侧放不下刻度时改放框内左侧,避免负 x 越出画布。
x, anchor := layout.mapX-5, "end"
if x-occultationLabelTextWidth(text, 9) < 3 {
x, anchor = layout.mapX+4, "start"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="%s">%s</text>`,
x, y+3, anchor, text)
}
}
@@ -236,6 +340,25 @@ func writeStarOccultationLand(b *strings.Builder, layout starOccultationSVGLayou
layout.mapFrame().WriteLand(b, "occultation-map-clip")
}
// writeOccultationRiseSetCurves 绘制一组升落阶段线;prefix 区分外接触与内接触曲线。
func writeOccultationRiseSetCurves(
b *strings.Builder,
curves []moon.OccultationRiseSetCurve,
layout starOccultationSVGLayout,
prefix string,
) {
curves = occultationgeo.DensifyRiseSetCurves(curves, 35)
for _, curve := range curves {
className := fmt.Sprintf("%s-rise-set-boundary %s-%s-%s", prefix, prefix, curve.Phase, curve.Direction)
for _, segment := range occultationgeo.StitchedRiseSetCurveSegments(curve) {
writeStarOccultationGeoLine(
b, segment, layout,
occultationLineStyle{className: className, color: "#d97706", strokeWidth: 1.35},
)
}
}
}
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)
@@ -248,51 +371,61 @@ func writeOccultationBand(
layerClass, pathClass, color string,
opacity float64,
) {
segments := starOccultationBandFragments(northern, southern, layout.projection)
if len(segments) == 0 {
segments := starOccultationBandFragments(northern, southern, layout.mapFrame().Clip())
sections := starOccultationBandEndpointSections(northern, southern, layout.mapFrame().Clip())
if len(segments) == 0 && len(sections) == 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)
}
writeOccultationPathCommands(b, layout.projectOccultationRing(segment))
b.WriteString(`Z"/>`)
}
for _, section := range sections {
fmt.Fprintf(b, `<path class="%s" d="`, pathClass)
writeOccultationPathCommands(b, layout.projectOccultationPolyline(section))
fmt.Fprintf(b, `" fill="none" stroke="%s" stroke-opacity="%.2f" stroke-width="1.25" stroke-linecap="round"/>`,
color, math.Min(1, opacity+0.35))
}
b.WriteString(`</g>`)
}
// occultationLineStyle 描述掩带折线的画布样式。
type occultationLineStyle struct {
className string
color string
strokeWidth float64
dash string
// boundary 为 true 时按连续边界分段(掩带限线),否则按反经线分段。
boundary bool
}
func writeStarOccultationGeoLine(
b *strings.Builder,
points []moon.OccultationPathPoint,
layout starOccultationSVGLayout,
className, color string,
strokeWidth float64,
dash string,
style occultationLineStyle,
) {
for _, segment := range starOccultationPathSegmentsForProjection(points, layout.projection) {
segments := starOccultationPathSegmentsForProjection(points, layout.mapFrame().Clip())
if style.boundary {
segments = starOccultationBoundarySegmentsForProjection(points, layout.mapFrame().Clip())
}
for _, segment := range segments {
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)
screen := layout.projectOccultationPathLine(segment)
if len(screen) < 2 {
continue
}
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)
fmt.Fprintf(b, `<path class="%s" d="`, style.className)
writeOccultationPathCommands(b, screen)
fmt.Fprintf(b, `" clip-path="url(#occultation-map-clip)" fill="none" stroke="%s" stroke-width="%.2f"`,
style.color, style.strokeWidth)
if style.dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, style.dash)
}
b.WriteString(` stroke-linecap="round" stroke-linejoin="round"/>`)
}
@@ -305,7 +438,8 @@ func writeStarOccultationVisibleCenterLine(
) {
visible := make([]moon.OccultationPathPoint, 0, len(points))
flush := func() {
writeStarOccultationGeoLine(b, visible, layout, "visible-center-line", "#087f8c", 2.8, "")
writeStarOccultationGeoLine(b, visible, layout,
occultationLineStyle{className: "visible-center-line", color: "#087f8c", strokeWidth: 2.8})
visible = visible[:0]
}
for _, point := range points {
@@ -321,18 +455,44 @@ func writeStarOccultationVisibleCenterLine(
flush()
}
func writeStarOccultationEventMarker(
b *strings.Builder,
point moon.OccultationPathPoint,
type starOccultationEventMarkerDraw struct {
x, y float64
label, kind string
placement starOccultationEventMarkerPlacement
}
func (draw starOccultationEventMarkerDraw) write(b *strings.Builder) {
writeStarOccultationProjectedEventMarker(b, draw.x, draw.y, draw.label, draw.kind, draw.placement)
}
// starOccultationEventMarkerDraws 计算起点、掩甚、终点三个事件标记及其标签占位。
func starOccultationEventMarkerDraws(
path moon.StarOccultationPath,
layout starOccultationSVGLayout,
label, kind string,
) {
x, y, visible := layout.mapFrame().Project(point.Longitude, point.Latitude)
if !visible {
return
language string,
placer *occultationLabelPlacer,
) []starOccultationEventMarkerDraw {
markers := []struct {
point moon.OccultationPathPoint
label, kind string
}{
{point: path.Start, label: starOccultationEventLabel("start", language), kind: "start"},
{point: path.Greatest, label: starOccultationEventLabel("greatest", language), kind: "greatest"},
{point: path.End, label: starOccultationEventLabel("end", language), kind: "end"},
}
placement := starOccultationDefaultEventMarkerPlacement(x, y, kind, layout)
writeStarOccultationProjectedEventMarker(b, x, y, label, kind, placement)
draws := make([]starOccultationEventMarkerDraw, 0, len(markers))
for _, marker := range markers {
x, y, visible := layout.mapFrame().Project(marker.point.Longitude, marker.point.Latitude)
if !visible {
continue
}
draws = append(draws, starOccultationEventMarkerDraw{
x: x, y: y, label: marker.label, kind: marker.kind,
placement: starOccultationDefaultEventMarkerPlacement(x, y, marker.kind, layout),
})
}
reserveOccultationEventMarkerLabels(draws, layout, placer)
return draws
}
type starOccultationEventMarkerPlacement struct {
@@ -341,6 +501,69 @@ type starOccultationEventMarkerPlacement struct {
leader bool
}
// occultationEventMarkerShifts 是事件标签在默认位置之外的候选位移,按优先级排列。
var occultationEventMarkerShifts = [][2]float64{
{0, 0}, {0, -14}, {0, 16}, {-12, 0}, {12, 0},
{-12, -14}, {12, -14}, {-12, 16}, {12, 16},
{0, -28}, {0, 30}, {-24, 0}, {24, 0}, {0, 44}, {0, -42},
}
// reserveOccultationEventMarkerLabels 把事件标签逐个登记到共用占位表。候选位置依次为默认位置、
// 上下左右位移与左右镜像;全部被占用时退回框内居中的兜底位置,避免标签压到右侧事件面板上。
func reserveOccultationEventMarkerLabels(draws []starOccultationEventMarkerDraw, layout starOccultationSVGLayout, placer *occultationLabelPlacer) {
for index := range draws {
draw := &draws[index]
base := draw.placement
found := false
for _, mirror := range []bool{false, true} {
for _, shift := range occultationEventMarkerShifts {
if mirror && base.anchor == "middle" {
continue
}
candidate := base
if mirror {
candidate.anchor = "start"
candidate.labelX = draw.x + 7 + shift[0]
if base.anchor == "start" {
candidate.anchor, candidate.labelX = "end", draw.x-7+shift[0]
}
} else {
candidate.labelX = base.labelX + shift[0]
}
candidate.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, base.labelY+shift[1]))
if !occultationLabelInsideFrame(layout, candidate.labelX, candidate.labelY, candidate.anchor, draw.label, 11) {
continue
}
if placer.place(candidate.labelX, candidate.labelY, candidate.anchor, draw.label, 11, 2) {
draw.placement = candidate
found = true
break
}
}
if found {
break
}
}
if found {
continue
}
fallback := base
fallback.anchor = "middle"
width := occultationLabelTextWidth(draw.label, 11)
fallback.labelX = math.Max(layout.mapX+width/2+2, math.Min(layout.mapX+layout.mapWidth-width/2-2, draw.x))
fallback.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, base.labelY))
draw.placement = fallback
placer.reserve(occultationLabelRectFor(fallback.labelX, fallback.labelY, fallback.anchor, draw.label, 11).expanded(2))
}
}
// occultationLabelInsideFrame 报告标签占位是否完全落在地图框内。
func occultationLabelInsideFrame(layout starOccultationSVGLayout, x, y float64, anchor, text string, fontSize float64) bool {
rect := occultationLabelRectFor(x, y, anchor, text, fontSize)
return rect.left >= layout.mapX+2 && rect.right <= layout.mapX+layout.mapWidth-2 &&
rect.top >= layout.mapY+2 && rect.bottom <= layout.mapY+layout.mapHeight-2
}
func starOccultationDefaultEventMarkerPlacement(
x, y float64,
kind string,
@@ -402,31 +625,66 @@ func writeStarOccultationProjectedEventMarker(
placement.labelX, placement.labelY, placement.anchor, html.EscapeString(label))
}
func writeStarOccultationLegend(b *strings.Builder, layout starOccultationSVGLayout, language string) {
func writeStarOccultationLegend(
b *strings.Builder,
layout starOccultationSVGLayout,
language string,
band occultationBandDraw,
hasRiseSet bool,
hasIsochrones bool,
) {
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)
if band.drawn() {
labels[2] = "掩带范围"
if language == starOccultationSVGLanguageEnglish {
labels[2] = "Band extent"
}
if band.claimBoundary() {
labels[2] = "掩带范围与边界"
if language == starOccultationSVGLanguageEnglish {
labels[2] = "Band and limits"
}
}
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))
}
if hasRiseSet {
label := "初掩/掩甚/终掩月升月落线"
if language == starOccultationSVGLanguageEnglish {
label = "Rise/set phase lines"
}
labels = append(labels, label)
}
if hasIsochrones {
label := "掩甚时刻等时线"
if language == starOccultationSVGLanguageEnglish {
label = "Greatest-time isochrones"
}
labels = append(labels, label)
}
colors := []string{"#087f8c", "#59676b", "#a66f18"}
dashes := []string{"", "5 4", ""}
if hasRiseSet {
colors = append(colors, "#d97706")
dashes = append(dashes, "")
}
if hasIsochrones {
colors = append(colors, "#1f6fb2")
dashes = append(dashes, "")
}
items := make([]occultationLegendItem, 0, len(labels))
for index, label := range labels {
item := occultationLegendItem{
label: label, color: colors[index], dash: dashes[index], markerWidth: 22,
}
if band.drawn() && index == 2 {
item.fill, item.fillOpacity = "#e0ae43", 0.45
}
items = append(items, item)
}
writeOccultationLegendItems(b, y, layout, items, 10)
}
func writeStarOccultationEventsPanel(
@@ -446,8 +704,10 @@ func writeOccultationEventsPanel(
) {
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)
contactsTitle := occultationTitleText(starOccultationSVGContactsTitle(options), options.ContactsTitle != "",
layout.width-layout.panelX-layout.margin, 14)
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)))
layout.panelX, layout.panelY+13, html.EscapeString(contactsTitle))
rowTop := layout.panelY + 27
rowHeight := math.Max(34, (layout.mapHeight-27)/float64(len(rows)))
for index, row := range rows {
@@ -478,8 +738,11 @@ func writeStarOccultationFooter(
b *strings.Builder,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
) {
lines := starOccultationWrapText(starOccultationSVGFooter(options), layout.width-80, 11)
lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, layout.width-80, 11)
lines = svgchart.TruncateTextLines(lines, layout.width-80, 11,
svgchart.BaselineLineLimit(11, 15, layout.footerY, layout.height))
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))
+120
View File
@@ -0,0 +1,120 @@
package svg
import (
"math"
"sync"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon"
)
// occultationBandPolygons 是一次紧凑掩带合并的只读结果。
type occultationBandPolygons struct {
polygons [][]geodata.GeoPoint
authoritative bool
}
type occultationBandCacheKey struct {
kind occultationFootprintSweepKind
digest [2]uint64
}
// occultationBandCacheCapacity 限制常驻的合并结果数量;掩带多边形可能上万点。
const occultationBandCacheCapacity = 6
var occultationBandCache = struct {
sync.RWMutex
entries map[occultationBandCacheKey]occultationBandPolygons
order []occultationBandCacheKey
}{entries: make(map[occultationBandCacheKey]occultationBandPolygons, occultationBandCacheCapacity)}
func lookupOccultationBandPolygons(key occultationBandCacheKey) (occultationBandPolygons, bool) {
occultationBandCache.RLock()
value, ok := occultationBandCache.entries[key]
occultationBandCache.RUnlock()
return value, ok
}
func storeOccultationBandPolygons(key occultationBandCacheKey, value occultationBandPolygons) {
occultationBandCache.Lock()
defer occultationBandCache.Unlock()
if _, exists := occultationBandCache.entries[key]; exists {
return
}
if len(occultationBandCache.order) >= occultationBandCacheCapacity {
delete(occultationBandCache.entries, occultationBandCache.order[0])
occultationBandCache.order = occultationBandCache.order[1:]
}
occultationBandCache.entries[key] = value
occultationBandCache.order = append(occultationBandCache.order, key)
}
// occultationBandDigest 汇总合并的全部输入;按 64 位字混合而不是逐字节,摘要成本必须远低于合并本身。
func occultationBandDigest(s occultationFootprintSweep) [2]uint64 {
const (
seed1 = 0x9e3779b97f4a7c15
seed2 = 0xc2b2ae3d27d4eb4f
prime = 0x100000001b3
)
first, second := uint64(seed1), uint64(seed2)
write := func(value uint64) {
first = (first ^ value) * prime
first ^= first >> 31
second = (second + value) * prime
second ^= second >> 33
}
writeFloat := func(value float64) { write(math.Float64bits(value)) }
writeTime := func(value int64) { write(uint64(value)) }
writePoint := func(point moon.OccultationPathPoint) {
writeTime(point.Time.UnixNano())
writeFloat(point.Longitude)
writeFloat(point.Latitude)
writeFloat(point.MoonAltitude)
writeFloat(point.WidthKM)
writeFloat(point.LimitSeparationKM)
}
writePolygons := func(polygons [][]moon.OccultationPathPoint) {
write(uint64(len(polygons)))
for _, polygon := range polygons {
write(uint64(len(polygon)))
for _, point := range polygon {
writePoint(point)
}
}
}
writeString := func(value string) {
write(uint64(len(value)))
for index := 0; index < len(value); index++ {
write(uint64(value[index]))
}
}
writeFootprints := func(footprints []moon.PlanetOccultationFootprint) {
write(uint64(len(footprints)))
for _, footprint := range footprints {
writeTime(footprint.Time.UnixNano())
writePolygons(footprint.Polygons)
writePolygons(footprint.InteriorPolygons)
writePolygons(footprint.Boundaries)
if footprint.Closed {
write(1)
} else {
write(0)
}
}
}
writeCurves := func(curves []moon.OccultationRiseSetCurve) {
write(uint64(len(curves)))
for _, curve := range curves {
writeString(string(curve.Phase))
writeString(string(curve.Direction))
writePolygons(curve.Segments)
}
}
write(uint64(s.kind))
writeFootprints(s.footprints)
writePolygons(s.contours)
writePolygons(s.visibilityContours)
writePolygons([][]moon.OccultationPathPoint{s.northern, s.southern})
writeCurves(s.curves)
return [2]uint64{first, second}
}
+90
View File
@@ -0,0 +1,90 @@
package svg
import (
"testing"
"time"
"b612.me/astro/moon"
)
// 本文件钉住紧凑掩带合并缓存的契约:同输入复用只读结果、不同几何不会串味、产物逐字节确定。
func occultationTestCompactSweep(t *testing.T) occultationFootprintSweep {
t.Helper()
path := occultationTestSaturnPath(t)
if len(path.PartialBandFootprints) == 0 {
t.Fatal("fixture carries no compact band footprints")
}
return occultationFootprintSweep{
footprints: path.PartialBandFootprints,
contours: path.PartialBandContours,
visibilityContours: path.PartialVisibilityContours,
northern: path.NorthernLimit,
southern: path.SouthernLimit,
curves: path.RiseSetCurves,
compactBand: true,
kind: partialOccultationFootprintSweep,
}
}
func TestOccultationBandCacheReusesIdenticalGeometry(t *testing.T) {
sweep := occultationTestCompactSweep(t)
first, firstAuthoritative, err := sweep.mergedBandPolygons()
if err != nil {
t.Fatalf("first mergedBandPolygons() error = %v", err)
}
if len(first) == 0 {
t.Fatal("compact band merge produced no polygon")
}
second, secondAuthoritative, err := sweep.mergedBandPolygons()
if err != nil {
t.Fatalf("second mergedBandPolygons() error = %v", err)
}
if len(second) != len(first) || &second[0][0] != &first[0][0] {
t.Fatal("identical band geometry was merged again instead of reusing the cached polygons")
}
if secondAuthoritative != firstAuthoritative {
t.Fatalf("cached authoritative flag = %v, want %v", secondAuthoritative, firstAuthoritative)
}
}
func TestOccultationBandCacheSeparatesDifferentGeometry(t *testing.T) {
sweep := occultationTestCompactSweep(t)
first, _, err := sweep.mergedBandPolygons()
if err != nil {
t.Fatalf("first mergedBandPolygons() error = %v", err)
}
mutated := sweep
mutated.footprints = append([]moon.PlanetOccultationFootprint(nil), sweep.footprints...)
last := len(mutated.footprints) - 1
mutated.footprints[last].Polygons = append([][]moon.OccultationPathPoint(nil), mutated.footprints[last].Polygons...)
polygon := append([]moon.OccultationPathPoint(nil), mutated.footprints[last].Polygons[0]...)
polygon[0].Longitude += 1.5
mutated.footprints[last].Polygons[0] = polygon
second, _, err := mutated.mergedBandPolygons()
if err != nil {
t.Fatalf("mutated mergedBandPolygons() error = %v", err)
}
if len(second) > 0 && len(first) > 0 && &second[0][0] == &first[0][0] {
t.Fatal("a shifted footprint reused the cached band polygons")
}
}
func TestCompactBandRenderIsDeterministic(t *testing.T) {
path := occultationTestSaturnPath(t)
options := StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC}
first, err := PlanetOccultationPathSVG(path, options)
if err != nil {
t.Fatalf("first render: %v", err)
}
second, err := PlanetOccultationPathSVG(path, options)
if err != nil {
t.Fatalf("second render: %v", err)
}
if first != second {
t.Fatal("compact-band SVG changed between renders after the merge was cached")
}
if len(first) == 0 {
t.Fatal("compact-band render is empty")
}
}
+77
View File
@@ -0,0 +1,77 @@
package svg
import (
"fmt"
"math"
"strings"
"testing"
"time"
)
// 本文件钉住星掩图的"掩带宽"口径与行星图、详细版一致:取南北限地面间距,
// 不得与 Greatest.WidthKM 互相换算。
func TestStarOccultationSVGUsesLimitSeparationBandWidth(t *testing.T) {
path := sampleStarOccultationPath()
path.GreatestLimitSeparationKM = 3123.4
path.Greatest.WidthKM = 3456.7
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC})
if err != nil {
t.Fatalf("StarOccultationPathSVG() error = %v", err)
}
if !strings.Contains(diagram, "3123.4 km") {
t.Fatal("stellar SVG band width does not use GreatestLimitSeparationKM")
}
if strings.Contains(diagram, "3456.7 km") {
t.Fatal("stellar SVG band width still uses Greatest.WidthKM")
}
}
func TestStarOccultationSVGBandWidthFallsBackToGreatestWidth(t *testing.T) {
path := sampleStarOccultationPath()
path.GreatestLimitSeparationKM = 0
path.Greatest.WidthKM = 3456.7
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC})
if err != nil {
t.Fatalf("StarOccultationPathSVG() error = %v", err)
}
if !strings.Contains(diagram, "3456.7 km") {
t.Fatal("stellar SVG band width lost the Greatest.WidthKM fallback for paths without limit separations")
}
}
func TestOccultationBandWidthSourcesAreNotInterchangeable(t *testing.T) {
starPath := occultationTestStarPath(t)
if starPath.GreatestLimitSeparationKM <= 0 || starPath.Greatest.WidthKM <= 0 {
t.Fatalf("fixture widths = %.3f / %.3f, want both populated",
starPath.GreatestLimitSeparationKM, starPath.Greatest.WidthKM)
}
if math.Abs(starPath.GreatestLimitSeparationKM-starPath.Greatest.WidthKM) < 1 {
t.Fatalf("fixture widths %.3f / %.3f are equal; the semantics check would be vacuous",
starPath.GreatestLimitSeparationKM, starPath.Greatest.WidthKM)
}
diagram, err := StarOccultationPathSVG(starPath, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC})
if err != nil {
t.Fatalf("StarOccultationPathSVG() error = %v", err)
}
expected := occultationTestWidthText(starPath.GreatestLimitSeparationKM)
if !strings.Contains(diagram, expected) {
t.Fatalf("stellar SVG is missing the limit-separation width %q", expected)
}
if other := occultationTestWidthText(starPath.Greatest.WidthKM); strings.Contains(diagram, other) {
t.Fatalf("stellar SVG reports the non-interchangeable Greatest.WidthKM %q", other)
}
planetPath := occultationTestSaturnPath(t)
planetDiagram, err := PlanetOccultationPathSVG(planetPath, StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG() error = %v", err)
}
if expected := occultationTestWidthText(planetPath.GreatestLimitSeparationKM); !strings.Contains(planetDiagram, expected) {
t.Fatalf("planetary SVG is missing the limit-separation width %q", expected)
}
}
func occultationTestWidthText(width float64) string {
return fmt.Sprintf("%.1f km", width)
}
+117
View File
@@ -0,0 +1,117 @@
package svg
import (
"errors"
"strconv"
"testing"
"time"
)
// 本文件钉住全球掩带图的画布下限与"所有文本都在画布内":480x360、600x400 曾因图例压页脚、经纬刻度出框而被放行。
func TestOccultationMapRejectsCanvasBelowMinimum(t *testing.T) {
tests := []struct {
name string
width, height int
}{
{name: "reference too small", width: 480, height: 360},
{name: "legend footer collision", width: 600, height: 400},
{name: "width just below", width: 639, height: 480},
{name: "height just below", width: 640, height: 479},
}
starPath := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
options := StarOccultationSVGOptions{Width: test.width, Height: test.height}
if _, err := StarOccultationPathSVG(starPath, options); !errors.Is(err, ErrInvalidStarOccultationSVGOptions) {
t.Fatalf("stellar %dx%d error = %v, want ErrInvalidStarOccultationSVGOptions", test.width, test.height, err)
}
if _, err := PlanetOccultationPathSVG(planetPath, options); !errors.Is(err, ErrInvalidPlanetOccultationSVGOptions) {
t.Fatalf("planetary %dx%d error = %v, want ErrInvalidPlanetOccultationSVGOptions", test.width, test.height, err)
}
})
}
}
func TestOccultationMapKeepsEveryTextInsideCanvas(t *testing.T) {
sizes := [][2]int{{640, 480}, {800, 600}, {1200, 800}, {1000, 1414}}
projections := []MapProjection{MapProjectionAuto, MapProjectionNorthPolar, MapProjectionOrthographic}
starPath := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
for _, size := range sizes {
for _, projection := range projections {
name := strconv.Itoa(size[0]) + "x" + strconv.Itoa(size[1]) + "-" + string(projection)
t.Run(name, func(t *testing.T) {
options := StarOccultationSVGOptions{
Width: size[0], Height: size[1], Location: time.UTC, Projection: projection,
}
diagrams := map[string]string{}
star, err := StarOccultationPathSVG(starPath, options)
if err != nil {
t.Fatalf("stellar render: %v", err)
}
diagrams["stellar"] = star
planet, err := PlanetOccultationPathSVG(planetPath, options)
if err != nil {
t.Fatalf("planetary render: %v", err)
}
diagrams["planetary"] = planet
for label, diagram := range diagrams {
texts := occultationSVGTexts(t, diagram)
if len(texts) == 0 {
t.Fatalf("%s SVG has no text", label)
}
if outside := occultationSVGTextsOutsideCanvas(texts, float64(size[0]), float64(size[1])); len(outside) > 0 {
t.Fatalf("%s SVG has %d texts outside %s: %v", label, len(outside), name, outside)
}
}
})
}
}
}
func TestOccultationMapLegendDoesNotOverlapFooter(t *testing.T) {
sizes := [][2]int{{640, 480}, {800, 600}, {1200, 800}}
starPath := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
for _, size := range sizes {
t.Run(strconv.Itoa(size[0])+"x"+strconv.Itoa(size[1]), func(t *testing.T) {
options := StarOccultationSVGOptions{Width: size[0], Height: size[1], Location: time.UTC}
for _, item := range []struct {
label string
path func() (string, error)
}{
{label: "stellar", path: func() (string, error) { return StarOccultationPathSVG(starPath, options) }},
{label: "planetary", path: func() (string, error) { return PlanetOccultationPathSVG(planetPath, options) }},
} {
diagram, err := item.path()
if err != nil {
t.Fatalf("%s render: %v", item.label, err)
}
texts := occultationSVGTexts(t, diagram)
legend := []occultationSVGText{}
footer := []occultationSVGText{}
for _, text := range texts {
if text.class == "occultation-legend-text" {
legend = append(legend, text)
}
if text.parent == "svg" && text.class == "" && text.fontSize == 11 && text.x == 40 {
footer = append(footer, text)
}
}
if len(legend) < 3 || len(footer) == 0 {
t.Fatalf("%s SVG legend=%d footer=%d, want both present", item.label, len(legend), len(footer))
}
for _, legendText := range legend {
for _, footerText := range footer {
if occultationSVGTextBox(legendText)[3] > occultationSVGTextBox(footerText)[1] {
t.Fatalf("%s legend %q overlaps footer %q at %dx%d",
item.label, legendText.value, footerText.value, size[0], size[1])
}
}
}
}
})
}
}
+590
View File
@@ -0,0 +1,590 @@
package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
// ErrInvalidOccultationDetailedSVGOptions 表示详细版式画布装不下地图、数据块与页脚。
// ErrInvalidOccultationDetailedSVGOptions reports a detailed-layout canvas that cannot hold the map, data blocks, and footer.
var ErrInvalidOccultationDetailedSVGOptions = errors.New("invalid occultation detailed SVG options")
const (
occultationDetailedMinimumWidth = 480
occultationDetailedMinimumHeight = 320
// occultationDetailedMinimumMap 与 occultationDetailedMinimumCell 是球面与数据格仍可辨认的最小尺寸。
occultationDetailedMinimumMap = 120
occultationDetailedMinimumCell = 120
)
// OccultationDetailedSVGOptions 控制详细版式的月掩星组合图。
type OccultationDetailedSVGOptions struct {
// Width 与 Height 是画布尺寸;<=0 使用 1000x1414 的整页开本。
// 版式必须容得下地图与数据块,否则返回 ErrInvalidOccultationDetailedSVGOptions。
Width int
Height int
// Language 为 "en" 时使用英文,其他值使用中文。
Language string
// Location 控制显示时刻的时区;nil 使用 UTC+8。
Location *time.Location
// TimeLabelStep 控制中心线上的时刻标签;零值用 30 分钟,负值禁用。
TimeLabelStep time.Duration
// Title 与 FooterNote 为空时自动生成。
Title string
FooterNote string
}
// occultationDetailedView 汇总详细版式各区块的位置。
type occultationDetailedView struct {
width, height float64
margin float64
mapX, mapY float64
mapSize float64
gridX, gridY float64
cellWidth float64
cellHeight float64
gapX, gapY float64
footerY float64
landscape bool
}
func normalizeOccultationDetailedSVGOptions(options OccultationDetailedSVGOptions) OccultationDetailedSVGOptions {
if options.Width <= 0 {
options.Width = 1000
}
if options.Height <= 0 {
options.Height = 1414
}
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 = 30 * time.Minute
}
return options
}
func validateOccultationDetailedSVGOptions(options OccultationDetailedSVGOptions) error {
if options.Width > 0 && options.Width < occultationDetailedMinimumWidth {
return fmt.Errorf("%w: width must be zero or at least %d",
ErrInvalidOccultationDetailedSVGOptions, occultationDetailedMinimumWidth)
}
if options.Height > 0 && options.Height < occultationDetailedMinimumHeight {
return fmt.Errorf("%w: height must be zero or at least %d",
ErrInvalidOccultationDetailedSVGOptions, occultationDetailedMinimumHeight)
}
return nil
}
func occultationDetailedMargin(width float64) float64 {
return math.Max(30, math.Min(48, width*0.044))
}
func occultationDetailedViewFor(options OccultationDetailedSVGOptions, summaryLines int) (occultationDetailedView, error) {
width, height := float64(options.Width), float64(options.Height)
margin := occultationDetailedMargin(width)
// 摘要折行数随画布宽度变化,标题区必须跟着长,否则窄画布上摘压到地图或数据块。
headerBottom := math.Max(150, math.Max(74+float64(summaryLines)*20+16, height*0.115))
footerHeight := math.Max(56, height*0.05)
legendHeight := math.Max(42, height*0.04)
gap := math.Max(14, height*0.014)
view := occultationDetailedView{
width: width, height: height, margin: margin,
gapX: math.Max(14, width*0.014), gapY: math.Max(12, height*0.013),
}
legendY := height - footerHeight - legendHeight
view.footerY = height - 42
blockHeight := math.Max(74, height*0.055)
panelHeight := math.Max(96, height*0.10)
if width >= height {
// 横版:地图在左,数据块两栏三行在右。
view.landscape = true
gridWidth := 2*230 + view.gapX
view.gridX = width - margin - gridWidth
view.cellWidth = 230
bandTop := headerBottom + gap
bandBottom := legendY - gap
view.gridY = bandTop
view.cellHeight = (bandBottom - bandTop - 2*view.gapY) / 3
view.mapX = margin
view.mapY = bandTop
view.mapSize = math.Min(view.gridX-gap-margin, bandBottom-bandTop)
if view.mapSize < occultationDetailedMinimumMap || view.cellHeight <= 0 || view.cellWidth <= 0 {
return view, detailedCanvasError(options)
}
return view, nil
}
// 竖版:日月块在地图上方两侧,地图居中,两行三栏面板在地图下方。
view.mapY = headerBottom + blockHeight + gap
// 球面下方先留图例的位置,再排两行面板;地图边长取剩余高度,不足则拒绝而不是按宽度放大。
const legendSpace = 56.0
view.mapSize = math.Min(width-2*margin,
height-view.mapY-legendSpace-2*panelHeight-3*gap-legendHeight-footerHeight)
view.cellWidth = (width - 2*margin - 2*view.gapX) / 3
if view.mapSize < occultationDetailedMinimumMap || view.cellWidth < occultationDetailedMinimumCell {
return view, detailedCanvasError(options)
}
view.mapX = (width - view.mapSize) / 2
panelY := view.mapY + view.mapSize + legendSpace
view.gridX = margin
view.gridY = panelY
view.cellHeight = panelHeight
return view, nil
}
func detailedCanvasError(options OccultationDetailedSVGOptions) error {
return fmt.Errorf("%w: canvas %dx%d cannot hold the map, data blocks, and footer",
ErrInvalidOccultationDetailedSVGOptions, options.Width, options.Height)
}
func (view occultationDetailedView) mapFrame(projection svgmap.Projection, center svgmap.GeoPoint) svgmap.Frame {
return svgmap.Frame{
X: view.mapX, Y: view.mapY, Width: view.mapSize, Height: view.mapSize,
Projection: projection, CenterLongitude: center.Longitude, CenterLatitude: center.Latitude,
}
}
// validateOccultationDetailedBlocks 逐块检查等分行高下文字是否压叠;太矮的画布必须拒绝而不是画出压叠的面板。
func validateOccultationDetailedBlocks(view occultationDetailedView, blocks []svgchart.PanelBlock) error {
columns := 3
if view.landscape {
columns = 2
}
for index, block := range blocks {
if len(block.Rows) == 0 {
continue
}
column, row := index%columns, index/columns
x := view.gridX + float64(column)*(view.cellWidth+view.gapX)
y := view.gridY + float64(row)*(view.cellHeight+view.gapY)
if svgchart.PanelBoxOverlaps(x, y, view.cellWidth, view.cellHeight, block.Title, block.Rows) {
return fmt.Errorf("%w: canvas %gx%g cannot keep data block %q rows apart",
ErrInvalidOccultationDetailedSVGOptions, view.width, view.height, block.Title)
}
}
return nil
}
// writeOccultationDetailedGrid 按两栏三行(横版)或三栏两行(竖版)画数据块。
func writeOccultationDetailedGrid(b *strings.Builder, view occultationDetailedView, blocks []svgchart.PanelBlock) {
columns := 3
if view.landscape {
columns = 2
}
for index, block := range blocks {
if len(block.Rows) == 0 {
continue
}
column := index % columns
row := index / columns
x := view.gridX + float64(column)*(view.cellWidth+view.gapX)
y := view.gridY + float64(row)*(view.cellHeight+view.gapY)
svgchart.WritePanelBox(b, "occultation-detailed-panel", x, y, view.cellWidth, view.cellHeight, block.Title, block.Rows)
}
}
// occultationDetailedMoonRows 月亮的地心坐标;S.D./H.P. 由地心月距换算,与日食月食同口径。
func occultationDetailedMoonRows(at time.Time, language string) []svgchart.PanelRow {
jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true)
ra, dec := basic.HMoonTrueRaDec(jde)
sd := basic.MoonSemidiameter(jde)
hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*6378.137/1737.4) * 180 / math.Pi * 3600
rows := []svgchart.PanelRow{
{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)},
{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)},
{Label: "视半径 S.D.", Value: occultationDetailedArcsec(sd)},
{Label: "地平视差 H.P.", Value: occultationDetailedArcsec(hp)},
}
if language == starOccultationSVGLanguageEnglish {
rows[0].Label, rows[1].Label = "R.A.", "Dec."
rows[2].Label, rows[3].Label = "S.D.", "H.P."
}
return rows
}
func occultationDetailedRA(degrees float64) string {
total := math.Mod(degrees, 360) / 15
hours := math.Floor(total)
minutes := math.Floor((total - hours) * 60)
return fmt.Sprintf("%02.0fh%02.0fm%04.1fs", hours, minutes, ((total-hours)*60-minutes)*60)
}
func occultationDetailedDec(degrees float64) string {
sign := "+"
if degrees < 0 {
sign, degrees = "-", -degrees
}
whole := math.Floor(degrees)
minutes := math.Floor((degrees - whole) * 60)
return fmt.Sprintf("%s%02.0f°%02.0f'%04.1f\"", sign, whole, minutes, ((degrees-whole)*60-minutes)*60)
}
func occultationDetailedArcsec(arcsec float64) string {
whole := math.Floor(arcsec / 3600)
minutes := math.Floor((arcsec/3600 - whole) * 60)
return fmt.Sprintf("%02.0f°%02.0f'%04.1f\"", whole, minutes, ((arcsec/3600-whole)*60-minutes)*60)
}
// StarOccultationDetailedSVG 生成详细版式的恒星月掩星图。
// StarOccultationDetailedSVG renders a detailed stellar-occultation diagram.
func StarOccultationDetailedSVG(
path moon.StarOccultationPath,
star moon.StarCoordinate,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validateStarOccultationPath(path); err != nil {
return "", err
}
return renderOccultationDetailedSVG(path, star, nil, options)
}
// PlanetOccultationDetailedSVG 生成详细版式的行星月掩星图。
// PlanetOccultationDetailedSVG renders a detailed planetary-occultation diagram.
func PlanetOccultationDetailedSVG(
path moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validatePlanetOccultationPath(path); err != nil {
return "", err
}
// 中文标题与目标块要写"木星"这类中文行星名,不能直接用 TargetID 里的英文。
shape := planetOccultationStarShape(path, path.TargetID)
if options.Language == starOccultationSVGLanguageChinese {
shape.TargetID = planetOccultationChineseName(path.Planet)
}
return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, options)
}
func renderOccultationDetailedSVG(
path moon.StarOccultationPath,
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validateOccultationDetailedSVGOptions(options); err != nil {
return "", err
}
options = normalizeOccultationDetailedSVGOptions(options)
summaryLines := occultationDetailedSummaryLines(path, planetPath, options)
view, err := occultationDetailedViewFor(options, len(summaryLines))
if err != nil {
return "", err
}
center := svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}
layout := starOccultationSVGLayout{
width: view.width, height: view.height, margin: view.margin,
mapX: view.mapX, mapY: view.mapY, mapWidth: view.mapSize, mapHeight: view.mapSize,
panelX: view.gridX, panelY: view.gridY, panelWidth: view.cellWidth,
footerY: view.footerY, projection: svgmap.ProjectionOrthographic, center: center,
}
svgOptions := StarOccultationSVGOptions{
Width: options.Width, Height: options.Height, Language: options.Language,
Location: options.Location, TimeLabelStep: options.TimeLabelStep,
Projection: MapProjectionOrthographic, Title: options.Title,
}
title := starOccultationSVGTitle(path, svgOptions)
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(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"/>`,
view.width-44, view.height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, view.width-80, 25)
}
fmt.Fprintf(&b, `<text x="%.3f" y="46" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="25" font-weight="700" text-anchor="middle">%s</text>`,
view.width/2, html.EscapeString(titleText))
writeOccultationDetailedSummary(&b, view, summaryLines)
// 几何引擎失败不是数据格式错误,不能裹成 ErrInvalid…Path 哨兵。
if planetPath == nil {
if err := writeStarOccultationMap(&b, path, layout, svgOptions); err != nil {
return "", fmt.Errorf("stellar occultation detailed band geometry: %w", err)
}
} else {
if err := writePlanetOccultationMap(&b, *planetPath, path, layout, svgOptions); err != nil {
return "", fmt.Errorf("planetary occultation detailed band geometry: %w", err)
}
}
blocks := occultationDetailedBlocks(path, star, planetPath, options)
if err := validateOccultationDetailedBlocks(view, blocks); err != nil {
return "", err
}
writeOccultationDetailedGrid(&b, view, blocks)
writeOccultationDetailedFooter(&b, view, options)
b.WriteString(`</svg>`)
return b.String(), nil
}
// occultationDetailedSummaryLines 汇总标题下的摘要行,并按画布可用宽度折行。
func occultationDetailedSummaryLines(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) []string {
available := float64(options.Width) - 2*occultationDetailedMargin(float64(options.Width))
lines := []string{}
for _, line := range occultationDetailedSummaryText(path, planetPath, options) {
lines = append(lines, starOccultationWrapText(line, available, 12.5)...)
}
return lines
}
func occultationDetailedSummaryText(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) []string {
zone := starOccultationLocationLabel(path.Greatest.Time.In(options.Location), options.Location)
english := options.Language == starOccultationSVGLanguageEnglish
lines := []string{}
format := func(value time.Time) string {
return value.In(options.Location).Format("15:04:05")
}
name := func(zh, en string) string {
if english {
return en
}
return zh
}
if planetPath != nil && planetPath.HasTotalBand {
if english {
lines = append(lines, fmt.Sprintf("Partial begins %s | Total begins %s | Greatest %s | Total ends %s | Partial ends %s (%s)",
format(path.Start.Time), format(planetPath.TotalStart.Time), format(path.Greatest.Time),
format(planetPath.TotalEnd.Time), format(path.End.Time), zone))
} else {
lines = append(lines, fmt.Sprintf("外掩始 %s | 全掩始 %s | 掩甚 %s | 全掩终 %s | 外掩终 %s(%s)",
format(path.Start.Time), format(planetPath.TotalStart.Time), format(path.Greatest.Time),
format(planetPath.TotalEnd.Time), format(path.End.Time), zone))
}
} else if english {
lines = append(lines, fmt.Sprintf("Occultation begins %s | Greatest %s | Occultation ends %s (%s)",
format(path.Start.Time), format(path.Greatest.Time), format(path.End.Time), zone))
} else {
lines = append(lines, fmt.Sprintf("掩始 %s | 掩甚 %s | 掩终 %s(%s)",
format(path.Start.Time), format(path.Greatest.Time), format(path.End.Time), zone))
}
lines = append(lines, fmt.Sprintf("%s %s | %s", name("掩甚点", "Greatest point"),
starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude),
name("月球高度", "Moon altitude")+" "+starOccultationFormatSignedDegree(path.Greatest.MoonAltitude)))
return lines
}
func writeOccultationDetailedSummary(
b *strings.Builder,
view occultationDetailedView,
lines []string,
) {
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Arial, sans-serif" font-size="12.5" text-anchor="middle">%s</text>`,
view.width/2, 74+float64(index)*20, html.EscapeString(line))
}
}
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions) {
text := options.FooterNote
if text == "" {
if options.Language == starOccultationSVGLanguageEnglish {
text = "Orthographic globe centred on the greatest occultation; Natural Earth 1:50m physical land, no administrative boundaries."
} else {
text = "正射球面投影,视点取掩甚点;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
maxWidth := view.width - 2*view.margin
// 默认说明本来就是单行,只有调用方文本才折行截断。
lines := []string{text}
if options.FooterNote != "" {
lines = svgchart.TruncateTextLines(svgchart.WrapText(text, maxWidth, 11), maxWidth, 11,
svgchart.BaselineLineLimit(11, 15, view.footerY, view.height))
}
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
view.margin, view.footerY+float64(index)*15, html.EscapeString(line))
}
}
// occultationDetailedTargetRows 目标天体的坐标与视半径;恒星没有视圆面,相关行留空。
func occultationDetailedTargetRows(
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
at time.Time,
language string,
) []svgchart.PanelRow {
english := language == starOccultationSVGLanguageEnglish
jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true)
rows := []svgchart.PanelRow{{Label: "目标", Value: star.ID}}
if english {
rows[0].Label = "Target"
}
if planetPath != nil {
rows[0].Value = planetPath.TargetID
if language == starOccultationSVGLanguageChinese {
rows[0].Value = planetOccultationChineseName(planetPath.Planet)
}
ra, dec := occultationDetailedPlanetRaDec(planetPath.Planet, jde)
if math.IsNaN(ra) {
rows = append(rows, svgchart.PanelRow{Label: "赤经 R.A.", Value: "—"},
svgchart.PanelRow{Label: "赤纬 Dec.", Value: "—"})
} else {
rows = append(rows, svgchart.PanelRow{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)},
svgchart.PanelRow{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)})
}
rows = append(rows, svgchart.PanelRow{Label: "视半径 S.D.",
Value: occultationDetailedArcsec(occultationDetailedPlanetSemidiameter(planetPath.Planet, jde))})
if english {
rows[1].Label, rows[2].Label, rows[3].Label = "R.A.", "Dec.", "S.D."
}
return rows
}
rows = append(rows,
svgchart.PanelRow{Label: "赤经 R.A.", Value: occultationDetailedRA(star.RA)},
svgchart.PanelRow{Label: "赤纬 Dec.", Value: occultationDetailedDec(star.Dec)},
svgchart.PanelRow{Label: "视半径 S.D.", Value: "—"})
if english {
rows[1].Label, rows[2].Label, rows[3].Label = "R.A.", "Dec.", "S.D."
}
return rows
}
// occultationDetailedPlanetRaDec 空值表示该行星没有可用的赤经入口。
func occultationDetailedPlanetRaDec(planet moon.OccultationPlanet, jde float64) (float64, float64) {
switch planet {
case moon.OccultationMercury:
return basic.MercuryApparentRaDec(jde)
case moon.OccultationVenus:
return basic.VenusApparentRaDec(jde)
case moon.OccultationMars:
return basic.MarsApparentRaDec(jde)
case moon.OccultationJupiter:
return basic.JupiterApparentRaDec(jde)
case moon.OccultationSaturn:
return basic.SaturnApparentRaDec(jde)
case moon.OccultationUranus:
return basic.UranusApparentRaDec(jde)
case moon.OccultationNeptune:
return basic.NeptuneApparentRaDec(jde)
}
return math.NaN(), math.NaN()
}
func occultationDetailedPlanetSemidiameter(planet moon.OccultationPlanet, jde float64) float64 {
switch planet {
case moon.OccultationMercury:
return basic.MercurySemidiameter(jde)
case moon.OccultationVenus:
return basic.VenusSemidiameter(jde)
case moon.OccultationMars:
return basic.MarsSemidiameter(jde)
case moon.OccultationJupiter:
return basic.JupiterSemidiameter(jde)
case moon.OccultationSaturn:
return basic.SaturnSemidiameter(jde)
case moon.OccultationUranus:
return basic.UranusSemidiameter(jde)
case moon.OccultationNeptune:
return basic.NeptuneSemidiameter(jde)
}
return math.NaN()
}
func occultationDetailedBlocks(
path moon.StarOccultationPath,
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) []svgchart.PanelBlock {
english := options.Language == starOccultationSVGLanguageEnglish
name := func(zh, en string) string {
if english {
return en
}
return zh
}
format := func(value time.Time) string {
if value.IsZero() {
return "—"
}
return value.In(options.Location).Format("15:04:05")
}
point := func(label string, value moon.OccultationPathPoint) svgchart.PanelRow {
if value.Time.IsZero() {
return svgchart.PanelRow{}
}
return svgchart.PanelRow{Label: label, Value: format(value.Time),
Detail: starOccultationFormatCoordinates(value.Longitude, value.Latitude)}
}
pathRows := []svgchart.PanelRow{
point(name("外掩始", "Partial begins"), path.Start),
point(name("掩甚", "Greatest"), path.Greatest),
point(name("外掩终", "Partial ends"), path.End),
}
// 行星图传入的 path 是由行星路径合成的恒星壳,带宽口径要从原始行星路径取。
widthSource := path
if planetPath != nil {
widthSource = moon.StarOccultationPath{Greatest: planetPath.Greatest,
GreatestLimitSeparationKM: planetPath.GreatestLimitSeparationKM}
}
partialWidth := widthSource.GreatestLimitSeparationKM
if partialWidth <= 0 {
partialWidth = widthSource.Greatest.WidthKM
}
ephemeris := []svgchart.PanelRow{
{Label: name("投影", "Projection"), Value: name("正射球面", "Orthographic")},
{Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true), true))},
{Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)))},
{Label: name("部分掩带宽", "Partial-band width"), Value: fmt.Sprintf("%.1f km", partialWidth)},
}
if planetPath != nil && planetPath.HasTotalBand {
ephemeris = append(ephemeris, svgchart.PanelRow{Label: name("全掩带宽", "Total-band width"),
Value: fmt.Sprintf("%.1f km", planetPath.GreatestTotalWidthKM)})
}
jde := basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)
physical := basic.MoonPhysical(jde)
libration := []svgchart.PanelRow{
{Label: name("经天平动 l", "Libration l"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLongitude)},
{Label: name("纬天平动 b", "Libration b"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLatitude)},
{Label: name("自转轴位置角 c", "Axis position angle c"), Value: fmt.Sprintf("%.2f°", physical.PositionAngle)},
}
contacts := []svgchart.PanelRow{
{Label: name("外掩始", "Partial begins"), Value: format(path.Start.Time)},
}
if planetPath != nil && planetPath.HasTotalBand {
contacts = append(contacts,
svgchart.PanelRow{Label: name("全掩始", "Total begins"), Value: format(planetPath.TotalStart.Time)})
}
contacts = append(contacts, svgchart.PanelRow{Label: name("掩甚", "Greatest"), Value: format(path.Greatest.Time)})
if planetPath != nil && planetPath.HasTotalBand {
contacts = append(contacts,
svgchart.PanelRow{Label: name("全掩终", "Total ends"), Value: format(planetPath.TotalEnd.Time)})
}
contacts = append(contacts, svgchart.PanelRow{Label: name("外掩终", "Partial ends"), Value: format(path.End.Time)})
return []svgchart.PanelBlock{
{Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(path.Greatest.Time, options.Language)},
{Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, path.Greatest.Time, options.Language)},
{Title: name("掩带路径点", "Band path points"), Rows: pathRows},
{Title: name("接触时刻", "Contacts"), Rows: contacts},
{Title: name("历表与常数", "Ephemeris and constants"), Rows: ephemeris},
{Title: name("天平动", "Libration"), Rows: libration},
}
}
@@ -0,0 +1,79 @@
package svg
import (
"errors"
"strconv"
"testing"
)
// 详细版式的文本盒估算沿用测试工具:ASCII 0.62em、非 ASCII 1.05em,纵向 y-0.85em 到 y+0.3em。
func occultationDetailedTextOverlapPairs(texts []occultationSVGText) [][2]occultationSVGText {
pairs := [][2]occultationSVGText{}
for first := 0; first < len(texts); first++ {
for second := first + 1; second < len(texts); second++ {
a, b := occultationSVGTextBox(texts[first]), occultationSVGTextBox(texts[second])
if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] {
pairs = append(pairs, [2]occultationSVGText{texts[first], texts[second]})
}
}
}
return pairs
}
// 详细版式在能出图的画布上不得有互相压叠的文本;装不下的画布必须返回 ErrInvalidOccultationDetailedSVGOptions。
func TestOccultationDetailedSVGTextOverlapContract(t *testing.T) {
// 竖版最小画布 600x800 的已知压叠总对数:数量变化即视为回归,必须重新核对而不是继续跳过。
knownOverlaps := map[[2]int]int{{600, 800}: 10}
sizes := [][2]int{
{1000, 1414}, {1414, 1000}, {1200, 800}, {900, 560}, {900, 400},
{800, 600}, {640, 480}, {600, 800}, {480, 320},
}
starPath := occultationTestStarPath(t)
planetPath := occultationTestSaturnPath(t)
for _, size := range sizes {
size := size
t.Run(strconv.Itoa(size[0])+"x"+strconv.Itoa(size[1]), func(t *testing.T) {
options := OccultationDetailedSVGOptions{Width: size[0], Height: size[1]}
overlapping := 0
for _, item := range []struct {
label string
render func() (string, error)
}{
{label: "stellar", render: func() (string, error) {
return StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), options)
}},
{label: "planetary", render: func() (string, error) {
return PlanetOccultationDetailedSVG(planetPath, options)
}},
} {
diagram, err := item.render()
if err != nil {
if !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) {
t.Fatalf("%s %dx%d: %v", item.label, size[0], size[1], err)
}
t.Logf("%s %dx%d rejected: %v", item.label, size[0], size[1], err)
continue
}
texts := occultationSVGTexts(t, diagram)
pairs := occultationDetailedTextOverlapPairs(texts)
overlapping += len(pairs)
t.Logf("%s %dx%d rendered: %d texts, %d overlapping pairs", item.label, size[0], size[1], len(texts), len(pairs))
for _, pair := range pairs {
t.Logf("%s %dx%d overlaps: %s %v vs %s %v", item.label, size[0], size[1],
pair[0].value, occultationSVGTextBox(pair[0]), pair[1].value, occultationSVGTextBox(pair[1]))
}
}
if overlapping == 0 {
return
}
if expected, known := knownOverlaps[size]; known && expected == overlapping {
t.Skipf("竖版最小画布 %dx%d 仍有 %d 对文本压叠(stellar 与 planetary 各 %d 对):月亮数据块行内 "+
"%q 与其值横向压叠约 6.1 px,掩甚/外掩终标记与其上方经纬读数纵向压叠约 0.63 px;"+
"块内行距守卫的估算宽度(ASCII 0.58em/非 ASCII 1em)比本测试(0.62em/1.05em)宽松,故未拒绝该画布。",
size[0], size[1], overlapping, overlapping/len([]string{"stellar", "planetary"}), "地平视差 H.P.")
}
t.Errorf("%dx%d renders %d overlapping text pairs", size[0], size[1], overlapping)
})
}
}
+229
View File
@@ -0,0 +1,229 @@
package svg
import (
"errors"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/moon"
)
// 本文件钉住详细版式三入口的契约:确定性、画布边界、面板数值与选项校验。
func TestStarOccultationDetailedSVGDeterministicXML(t *testing.T) {
path := occultationTestStarPath(t)
options := OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC}
first, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), options)
if err != nil {
t.Fatalf("StarOccultationDetailedSVG() error = %v", err)
}
if len(first) == 0 {
t.Fatal("StarOccultationDetailedSVG() returned an empty diagram")
}
second, err := StarOccultationDetailedSVG(path, hr4799StarCoordinate(), options)
if err != nil {
t.Fatalf("second StarOccultationDetailedSVG() error = %v", err)
}
if first != second {
t.Fatal("detailed stellar SVG is not byte-identical across renders")
}
if err := validateXML(first); err != nil {
t.Fatalf("detailed stellar SVG is not valid XML: %v", err)
}
for _, want := range []string{`class="occultation-detailed-panel"`, "月亮(地心坐标)", "天平动"} {
if !strings.Contains(first, want) {
t.Fatalf("detailed stellar SVG is missing %q", want)
}
}
}
func TestPlanetOccultationDetailedSVGDeterministicXML(t *testing.T) {
path := samplePlanetOccultationPath()
options := OccultationDetailedSVGOptions{Width: 1414, Height: 1000, Location: time.UTC}
first, err := PlanetOccultationDetailedSVG(path, options)
if err != nil {
t.Fatalf("PlanetOccultationDetailedSVG() error = %v", err)
}
if len(first) == 0 {
t.Fatal("PlanetOccultationDetailedSVG() returned an empty diagram")
}
second, err := PlanetOccultationDetailedSVG(path, options)
if err != nil {
t.Fatalf("second PlanetOccultationDetailedSVG() error = %v", err)
}
if first != second {
t.Fatal("detailed planetary SVG is not byte-identical across renders")
}
if err := validateXML(first); err != nil {
t.Fatalf("detailed planetary SVG is not valid XML: %v", err)
}
if !strings.Contains(first, "全掩始") || !strings.Contains(first, "全掩带宽") {
t.Fatal("detailed planetary SVG dropped total-band content")
}
}
func TestOccultationDetailedSVGKeepsContentInsideCanvas(t *testing.T) {
sizes := [][2]int{{1000, 1414}, {1414, 1000}, {800, 600}}
starPath := occultationTestStarPath(t)
for _, size := range sizes {
size := size
t.Run(strconv.Itoa(size[0])+"x"+strconv.Itoa(size[1]), func(t *testing.T) {
options := OccultationDetailedSVGOptions{Width: size[0], Height: size[1], Location: time.UTC}
diagrams := map[string]string{}
star, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), options)
if err != nil {
t.Fatalf("stellar detailed render: %v", err)
}
diagrams["stellar"] = star
planet, err := PlanetOccultationDetailedSVG(samplePlanetOccultationPath(), options)
if err != nil {
t.Fatalf("planetary detailed render: %v", err)
}
diagrams["planetary"] = planet
for name, diagram := range diagrams {
texts := occultationSVGTexts(t, diagram)
if len(texts) == 0 {
t.Fatalf("%s detailed SVG has no text", name)
}
if outside := occultationSVGTextsOutsideCanvas(texts, float64(size[0]), float64(size[1])); len(outside) > 0 {
t.Fatalf("%s detailed SVG has %d texts outside %dx%d: %v", name, len(outside), size[0], size[1], outside)
}
for _, rect := range occultationSVGPanelRects(t, diagram) {
if rect[0] < 0 || rect[1] < 0 || rect[0]+rect[2] > float64(size[0]) || rect[1]+rect[3] > float64(size[1]) {
t.Fatalf("%s detailed SVG panel rect %.1f,%.1f %.1fx%.1f leaves %dx%d",
name, rect[0], rect[1], rect[2], rect[3], size[0], size[1])
}
}
}
})
}
}
func TestOccultationDetailedSVGRejectsCanvasTooSmall(t *testing.T) {
starPath := sampleStarOccultationPath()
planetPath := samplePlanetOccultationPath()
tests := []struct {
name string
width, height int
}{
{name: "reference overflow", width: 400, height: 300},
{name: "narrow", width: 479, height: 1414},
{name: "short", width: 1000, height: 319},
{name: "single pixel", width: 1, height: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
options := OccultationDetailedSVGOptions{Width: test.width, Height: test.height}
if _, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), options); !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) {
t.Fatalf("stellar detailed %dx%d error = %v, want ErrInvalidOccultationDetailedSVGOptions", test.width, test.height, err)
}
if _, err := PlanetOccultationDetailedSVG(planetPath, options); !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) {
t.Fatalf("planetary detailed %dx%d error = %v, want ErrInvalidOccultationDetailedSVGOptions", test.width, test.height, err)
}
})
}
}
func TestOccultationDetailedSVGRejectsInvalidPathOnce(t *testing.T) {
starPath := sampleStarOccultationPath()
starPath.Greatest.Longitude = math.NaN()
_, err := StarOccultationDetailedSVG(starPath, hr4799StarCoordinate(), OccultationDetailedSVGOptions{})
if !errors.Is(err, ErrInvalidStarOccultationPath) {
t.Fatalf("stellar detailed invalid path error = %v, want ErrInvalidStarOccultationPath", err)
}
if count := strings.Count(err.Error(), "invalid stellar occultation path"); count != 1 {
t.Fatalf("stellar detailed error = %q, sentinel prefix appears %d times", err, count)
}
planetPath := samplePlanetOccultationPath()
planetPath.TotalBandFootprints = []moon.PlanetOccultationFootprint{{Time: planetPath.TotalStart.Time}}
_, err = PlanetOccultationDetailedSVG(planetPath, OccultationDetailedSVGOptions{})
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
t.Fatalf("planetary detailed invalid path error = %v, want ErrInvalidPlanetOccultationPath", err)
}
if count := strings.Count(err.Error(), "invalid planetary occultation path"); count != 1 {
t.Fatalf("planetary detailed error = %q, sentinel prefix appears %d times", err, count)
}
}
func TestOccultationDetailedSVGPanelValuesGolden(t *testing.T) {
options := OccultationDetailedSVGOptions{Width: 1000, Height: 1414, Location: time.UTC}
starDiagram, err := StarOccultationDetailedSVG(sampleStarOccultationPath(), hr4799StarCoordinate(), options)
if err != nil {
t.Fatalf("StarOccultationDetailedSVG() error = %v", err)
}
star := occultationSVGDetailedPanelRows(t, starDiagram)
for _, expected := range []struct {
title, label, value string
}{
{"月亮(地心坐标)", "赤经 R.A.", "23h37m22.4s"},
{"月亮(地心坐标)", "赤纬 Dec.", "-00°01'34.0\""},
{"月亮(地心坐标)", "视半径 S.D.", "00°15'25.5\""},
{"月亮(地心坐标)", "地平视差 H.P.", "00°56'37.7\""},
{"目标天体", "目标", "HR 4799"},
{"目标天体", "赤经 R.A.", "12h36m47.4s"},
{"目标天体", "赤纬 Dec.", "-05°49'55.0\""},
{"历表与常数", "ΔT", "69.2 s"},
{"历表与常数", "月距", "387216 km"},
{"历表与常数", "部分掩带宽", "3300.0 km"},
{"天平动", "经天平动 l", "-5.86°"},
{"天平动", "纬天平动 b", "-2.79°"},
{"天平动", "自转轴位置角 c", "-21.92°"},
} {
if got := occultationSVGPanelValue(t, star, expected.title, expected.label); got != expected.value {
t.Fatalf("stellar panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value)
}
}
planetDiagram, err := PlanetOccultationDetailedSVG(samplePlanetOccultationPath(), options)
if err != nil {
t.Fatalf("PlanetOccultationDetailedSVG() error = %v", err)
}
planet := occultationSVGDetailedPanelRows(t, planetDiagram)
for _, expected := range []struct {
title, label, value string
}{
{"目标天体", "目标", "土星"},
{"目标天体", "赤经 R.A.", "00h58m06.2s"},
{"目标天体", "赤纬 Dec.", "+03°27'41.4\""},
{"目标天体", "视半径 S.D.", "00°00'09.3\""},
{"历表与常数", "ΔT", "69.2 s"},
{"历表与常数", "部分掩带宽", "3300.0 km"},
{"历表与常数", "全掩带宽", "3100.0 km"},
{"接触时刻", "全掩始", "10:30:00"},
{"接触时刻", "全掩终", "12:30:00"},
} {
if got := occultationSVGPanelValue(t, planet, expected.title, expected.label); got != expected.value {
t.Fatalf("planetary panel %q row %q = %q, want %q", expected.title, expected.label, got, expected.value)
}
}
}
// TestOccultationDetailedSVGMoonRowsFollowGeocentricEphemeris 卡住月心地心量的量级,
// 避免把 ΔT、视差常数抄错却仍然画出"看起来正常"的面板。
func TestOccultationDetailedSVGMoonRowsFollowGeocentricEphemeris(t *testing.T) {
diagram, err := StarOccultationDetailedSVG(sampleStarOccultationPath(), hr4799StarCoordinate(), OccultationDetailedSVGOptions{
Width: 1000, Height: 1414, Location: time.UTC,
})
if err != nil {
t.Fatalf("StarOccultationDetailedSVG() error = %v", err)
}
panels := occultationSVGDetailedPanelRows(t, diagram)
if deltaT := occultationSVGNumericPrefix(t, occultationSVGPanelValue(t, panels, "历表与常数", "ΔT")); deltaT < 40 || deltaT > 110 {
t.Fatalf("panel ΔT = %.1f s, want a 2026 value between 40 and 110 s", deltaT)
}
if distance := occultationSVGNumericPrefix(t, occultationSVGPanelValue(t, panels, "历表与常数", "月距")); distance < 350000 || distance > 410000 {
t.Fatalf("panel Moon distance = %.0f km, want a physical geocentric distance", distance)
}
if parallax := occultationSVGSexagesimal(t, occultationSVGPanelValue(t, panels, "月亮(地心坐标)", "地平视差 H.P.")); parallax < 0.9 || parallax > 1.02 {
t.Fatalf("panel horizontal parallax = %.4f°, want about 1° from the geocentric distance", parallax)
}
if semidiameter := occultationSVGSexagesimal(t, occultationSVGPanelValue(t, panels, "月亮(地心坐标)", "视半径 S.D.")); semidiameter < 0.23 || semidiameter > 0.28 {
t.Fatalf("panel semidiameter = %.4f°, want about 0.25°", semidiameter)
}
if rightAscension := occultationSVGSexagesimal(t, occultationSVGPanelValue(t, panels, "月亮(地心坐标)", "赤经 R.A.")); rightAscension < 350 || rightAscension > 360 {
t.Fatalf("panel Moon R.A. = %.3f°, want the same geocentric direction as the target star", rightAscension)
}
}
+83
View File
@@ -0,0 +1,83 @@
package svg
import (
"fmt"
"html"
"strings"
"b612.me/astro/moon"
)
// writeOccultationGreatestTimeContours 绘制地方掩甚时刻等值线及其 HH:MM 标注。
func writeOccultationGreatestTimeContours(
b *strings.Builder,
contours []moon.OccultationGreatestTimeContour,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
placer *occultationLabelPlacer,
) {
if len(contours) == 0 {
return
}
type isochroneLabel struct {
x, y float64
text string
}
labels := make([]isochroneLabel, 0, len(contours))
for _, contour := range contours {
for _, segment := range contour.Segments {
writeStarOccultationGeoLine(b, segment, layout,
occultationLineStyle{className: "greatest-time-isoline", color: "#1f6fb2", strokeWidth: 1.1})
}
text := contour.Time.In(options.Location).Format("15:04")
if x, y, ok := occultationGreatestTimeIsolineLabelPlacement(contour, text, layout, placer); ok {
labels = append(labels, isochroneLabel{x: x, y: y, text: text})
}
}
for _, label := range labels {
fmt.Fprintf(b, `<text class="greatest-time-isoline-label" x="%.3f" y="%.3f" fill="#1a5c96" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text>`,
label.x, label.y, html.EscapeString(label.text))
}
}
// occultationGreatestTimeIsolineLabelPlacement 在最长的支路上取首个未被其它标签占用的标注点。
func occultationGreatestTimeIsolineLabelPlacement(
contour moon.OccultationGreatestTimeContour,
text string,
layout starOccultationSVGLayout,
placer *occultationLabelPlacer,
) (float64, float64, bool) {
longest := -1
for index, segment := range contour.Segments {
if longest < 0 || len(segment) > len(contour.Segments[longest]) {
longest = index
}
}
if longest < 0 {
return 0, 0, false
}
segment := contour.Segments[longest]
frame := layout.mapFrame()
middle := len(segment) / 2
for offset := 0; offset <= middle; offset++ {
for _, index := range []int{middle + offset, middle - offset} {
if index < 0 || index >= len(segment) {
continue
}
point := segment[index]
x, y, visible := frame.Project(point.Longitude, point.Latitude)
if !visible ||
x < frame.X+30 || x > frame.X+frame.Width-30 ||
y < frame.Y+18 || y > frame.Y+frame.Height-18 {
continue
}
// 优先压在等时线上方,其次下方;两者都被占用就换下一个候选点。
for _, baseline := range []float64{y - 6, y + 13} {
if placer.place(x, baseline, "middle", text, 9, 2) {
return x, baseline, true
}
}
}
}
return 0, 0, false
}
+89
View File
@@ -0,0 +1,89 @@
package svg
import (
"math"
"strings"
"testing"
"time"
"b612.me/astro/moon"
)
// 掩甚时刻等时线由路径选项请求,渲染器只在路径携带时绘制。
func TestPlanetOccultationSVGDrawsGreatestTimeIsochrones(t *testing.T) {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC)
render := func(step time.Duration) string {
t.Helper()
paths, err := moon.FindPlanetOccultationPaths(start, end, moon.OccultationSaturn, moon.OccultationPathOptions{
Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: step,
})
if err != nil || len(paths) == 0 {
t.Fatalf("no occultation path: %v", err)
}
if step > 0 && len(paths[0].GreatestTimeContours) == 0 {
t.Fatal("path carries no isochrones")
}
rendered, renderErr := PlanetOccultationPathSVG(paths[0], StarOccultationSVGOptions{
Width: 1200, Height: 800, Location: time.UTC,
})
if renderErr != nil {
t.Fatalf("render: %v", renderErr)
}
return rendered
}
withIsochrones := render(30 * time.Minute)
if !strings.Contains(withIsochrones, `class="greatest-time-isoline"`) {
t.Fatal("isochrone paths missing")
}
if !strings.Contains(withIsochrones, `class="greatest-time-isoline-label"`) {
t.Fatal("isochrone labels missing")
}
if strings.Contains(render(0), "greatest-time-isoline") {
t.Fatal("a zero step must not draw isochrones")
}
}
// 等时线也要走路径校验:坏支路必须在渲染前被拦住,而不是画出 NaN 折线。
func TestPlanetOccultationSVGRejectsBrokenGreatestTimeIsochrones(t *testing.T) {
paths, err := moon.FindPlanetOccultationPaths(
time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
time.Date(2025, time.February, 2, 0, 0, 0, 0, time.UTC), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute},
)
if err != nil || len(paths) == 0 || len(paths[0].GreatestTimeContours) == 0 {
t.Fatalf("no isochrone path: %v", err)
}
options := StarOccultationSVGOptions{Width: 1200, Height: 800, Location: time.UTC}
render := func(mutate func(path *moon.PlanetOccultationPath)) error {
path := paths[0]
path.GreatestTimeContours = append([]moon.OccultationGreatestTimeContour(nil), path.GreatestTimeContours...)
mutate(&path)
_, renderErr := PlanetOccultationPathSVG(path, options)
return renderErr
}
if err := render(func(path *moon.PlanetOccultationPath) {}); err != nil {
t.Fatalf("valid isochrones rejected: %v", err)
}
if err := render(func(path *moon.PlanetOccultationPath) {
path.GreatestTimeContours[0].JDE = 0
}); err == nil {
t.Fatal("zero JDE accepted")
}
if err := render(func(path *moon.PlanetOccultationPath) {
path.GreatestTimeContours[0].Segments[0][0].Longitude = math.NaN()
}); err == nil {
t.Fatal("NaN longitude accepted")
}
if err := render(func(path *moon.PlanetOccultationPath) {
path.GreatestTimeContours[0].Segments[0] = path.GreatestTimeContours[0].Segments[0][:1]
}); err == nil {
t.Fatal("single-point branch accepted")
}
if err := render(func(path *moon.PlanetOccultationPath) {
path.GreatestTimeContours[0].Segments[0][0].Time = path.GreatestTimeContours[0].Segments[0][0].Time.Add(2 * time.Second)
}); err == nil {
t.Fatal("branch point with a mismatched instant accepted")
}
}
+191
View File
@@ -0,0 +1,191 @@
package svg
import (
"fmt"
"html"
"strings"
"b612.me/astro/internal/svgchart"
)
// occultationLabelRect 是画布坐标下的标签占位矩形。
type occultationLabelRect struct {
left, top, right, bottom float64
}
func (rect occultationLabelRect) overlaps(other occultationLabelRect) bool {
return rect.left < other.right && other.left < rect.right && rect.top < other.bottom && other.top < rect.bottom
}
func (rect occultationLabelRect) expanded(padding float64) occultationLabelRect {
return occultationLabelRect{
left: rect.left - padding, top: rect.top - padding,
right: rect.right + padding, bottom: rect.bottom + padding,
}
}
// occultationLabelPlacer 是三族地图标签(等时线、中心线时刻、事件标记)共用的已放置表;
// nil 表示不做避让。
type occultationLabelPlacer struct {
placed []occultationLabelRect
}
func (placer *occultationLabelPlacer) reserved(rect occultationLabelRect) bool {
if placer == nil {
return false
}
for _, current := range placer.placed {
if rect.overlaps(current) {
return true
}
}
return false
}
// place 在占位可用时登记并返回 true;padding 是标签之间要求的最小空隙。
func (placer *occultationLabelPlacer) place(x, y float64, anchor, text string, fontSize, padding float64) bool {
rect := occultationLabelRectFor(x, y, anchor, text, fontSize).expanded(padding)
if placer.reserved(rect) {
return false
}
placer.reserve(rect)
return true
}
func (placer *occultationLabelPlacer) reserve(rect occultationLabelRect) {
if placer == nil {
return
}
placer.placed = append(placer.placed, rect)
}
// occultationLabelTextWidth 估算标签宽度,口径与数据块的压叠判定一致。
func occultationLabelTextWidth(text string, fontSize float64) float64 {
return svgchart.EstimatedTextWidth(text, fontSize)
}
// occultationLabelRectFor 返回锚点为 (x, y) 的文本占位,上下留白与数据块同口径。
func occultationLabelRectFor(x, y float64, anchor, text string, fontSize float64) occultationLabelRect {
width := occultationLabelTextWidth(text, fontSize)
above, below := svgchart.EstimatedTextExtents(fontSize)
left := x
switch anchor {
case "middle":
left = x - width/2
case "end":
left = x - width
}
return occultationLabelRect{
left: left, top: y - above, right: left + width, bottom: y + below,
}
}
// occultationLegendItem 是图例中的一项:色块(fill)与线段(color)至少有一个。
type occultationLegendItem struct {
label string
color string
dash string
markerWidth float64
fill string
fillOpacity float64
}
const (
occultationLegendRowHeight = 18.0
occultationLegendMinimumFontSize = 7.0
)
func occultationLegendItemWidth(item occultationLegendItem, fontSize float64) float64 {
return item.markerWidth + 6 + occultationLabelTextWidth(item.label, fontSize)
}
// occultationLegendItemRows 按画布宽度把图例项贪心排成多行;行数不得超过布局预留的两行。
func occultationLegendItemRows(
layout starOccultationSVGLayout,
items []occultationLegendItem,
fontSize float64,
) [][]occultationLegendItem {
rows := make([][]occultationLegendItem, 0, 2)
current := make([]occultationLegendItem, 0, len(items))
width := 0.0
for _, item := range items {
itemWidth := occultationLegendItemWidth(item, fontSize)
// 布局只预留两行,第三行会压到页脚;超出的项并回第二行而不是换行。
if len(current) > 0 && len(rows) < 1 && width+itemWidth > layout.mapWidth {
rows = append(rows, current)
current = make([]occultationLegendItem, 0, len(items))
width = 0
}
current = append(current, item)
width += itemWidth
}
if len(current) > 0 {
rows = append(rows, current)
}
return rows
}
// occultationLegendFontSize 在放得下的前提下取最大字号:窄地图(详细版式)必须缩字号,
// 否则图例项会溢出地图横向范围压到右侧数据块上。
func occultationLegendFontSize(layout starOccultationSVGLayout, items []occultationLegendItem, fontSize float64) float64 {
for candidate := fontSize; candidate >= occultationLegendMinimumFontSize; candidate-- {
rows := occultationLegendItemRows(layout, items, candidate)
fits := true
for _, row := range rows {
total := 0.0
for _, item := range row {
total += occultationLegendItemWidth(item, candidate)
}
if total > layout.mapWidth {
fits = false
break
}
}
if fits {
return candidate
}
}
return occultationLegendMinimumFontSize
}
// writeOccultationLegendItems 逐行写图例;每行内的项按剩余空间均分间隔,避免文字互相压叠。
func writeOccultationLegendItems(
b *strings.Builder,
top float64,
layout starOccultationSVGLayout,
items []occultationLegendItem,
fontSize float64,
) {
fontSize = occultationLegendFontSize(layout, items, fontSize)
rows := occultationLegendItemRows(layout, items, fontSize)
y := top
for _, row := range rows {
total := 0.0
for _, item := range row {
total += occultationLegendItemWidth(item, fontSize)
}
gap := 0.0
if len(row) > 0 && total < layout.mapWidth {
gap = (layout.mapWidth - total) / float64(len(row))
}
x := layout.mapX
for _, item := range row {
if item.fill != "" {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="9" fill="%s" fill-opacity="%.2f"/>`,
x, y-10, item.markerWidth, item.fill, item.fillOpacity)
}
if item.color != "" {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="2"`,
x, y-5, x+item.markerWidth, y-5, item.color)
if item.dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, item.dash)
}
b.WriteString(`/>`)
}
fmt.Fprintf(b, `<text class="occultation-legend-text" x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
x+item.markerWidth+6, y, fontSize, html.EscapeString(item.label))
x += occultationLegendItemWidth(item, fontSize) + gap
}
y += occultationLegendRowHeight
}
}
+87
View File
@@ -0,0 +1,87 @@
package svg
import (
"testing"
"time"
)
// 本文件量化三族地图标签(等时线、中心线时刻、事件标记)的互相避让:
// 产物里任一族的 <text> 占位都不许与另一族相交。
type occultationLabelFixture struct {
name string
width int
height int
// labelStep 为 0 时用默认 30 分钟。
labelStep time.Duration
star bool
}
func TestOccultationMapLabelsDoNotOverlap(t *testing.T) {
fixtures := []occultationLabelFixture{
{name: "stellar 1200x800", width: 1200, height: 800, labelStep: 10 * time.Minute, star: true},
{name: "stellar 720x520", width: 720, height: 520, labelStep: 15 * time.Minute, star: true},
{name: "stellar 640x480", width: 640, height: 480, star: true},
{name: "planetary compact 1200x800", width: 1200, height: 800, labelStep: 10 * time.Minute},
{name: "planetary compact 720x520", width: 720, height: 520, labelStep: 15 * time.Minute},
}
totalLabels := 0
for _, fixture := range fixtures {
fixture := fixture
t.Run(fixture.name, func(t *testing.T) {
options := StarOccultationSVGOptions{
Width: fixture.width, Height: fixture.height, Location: time.UTC, TimeLabelStep: fixture.labelStep,
}
var diagram string
var err error
if fixture.star {
diagram, err = StarOccultationPathSVG(occultationTestStarPath(t), options)
} else {
diagram, err = PlanetOccultationPathSVG(occultationTestSaturnPath(t), options)
}
if err != nil {
t.Fatalf("render: %v", err)
}
labels := occultationSVGLabelTexts(occultationSVGTexts(t, diagram))
if len(labels) < 3 {
t.Fatalf("fixture produced only %d labels; the overlap check would be vacuous", len(labels))
}
if outside := occultationSVGTextsOutsideCanvas(labels, float64(fixture.width), float64(fixture.height)); len(outside) > 0 {
t.Fatalf("map labels outside the canvas: %v", outside)
}
if pairs := occultationSVGTextOverlapPairs(labels); pairs != 0 {
t.Fatalf("%d label overlap pairs among %d labels", pairs, len(labels))
}
totalLabels += len(labels)
})
}
if totalLabels < 40 {
t.Fatalf("label fixtures only produced %d labels, want a dense label set", totalLabels)
}
t.Logf("checked %d labels across %d fixtures", totalLabels, len(fixtures))
}
func TestPlanetOccultationMapLabelsAvoidTotalBandMarkers(t *testing.T) {
diagram, err := PlanetOccultationPathSVG(occultationTestMarsPath(t), StarOccultationSVGOptions{
Width: 1200, Height: 800, Location: time.UTC, TimeLabelStep: 10 * time.Minute,
})
if err != nil {
t.Fatalf("render: %v", err)
}
labels := occultationSVGLabelTexts(occultationSVGTexts(t, diagram))
if len(labels) < 5 {
t.Fatalf("total-band fixture produced only %d labels", len(labels))
}
if pairs := occultationSVGTextOverlapPairs(labels); pairs != 0 {
t.Fatalf("%d label overlap pairs among %d labels", pairs, len(labels))
}
markers := 0
for _, label := range labels {
if label.parentClass == "event-marker event-greatest" {
markers++
}
}
if markers != 1 {
t.Fatalf("greatest-event label count = %d, want one", markers)
}
}
+81 -28
View File
@@ -9,6 +9,7 @@ import (
"time"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/moon"
)
@@ -69,13 +70,15 @@ type localStarOccultationSVGLayout struct {
stageTop float64
stageBottom float64
footerY float64
headerMaxLines int
}
type localStarOccultationEventFrame struct {
label string
name string
time time.Time
frame moon.StarOccultationDiagramFrame
label string
name string
time time.Time
frame moon.StarOccultationDiagramFrame
appearance localOccultationMoonAppearance
}
// FindLocalStarOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次点光源恒星月掩。不会加载内嵌星表;空切片表示没有本地事件。
@@ -198,6 +201,9 @@ func renderLocalStarOccultationSVG(
) string {
title := localStarOccultationSVGTitle(info, options)
headerLines := localStarOccultationSVGHeaderLines(info, options)
// 页眉字号 14/13,省略号按较大的 14 估算;行数上限只与画布高度有关,先按空页眉取上限。
headerLimit := localStarOccultationSVGLayoutFor(options, 72).headerMaxLines
headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14, headerLimit)
headerBottom := 72.0 + float64(len(headerLines))*19
layout := localStarOccultationSVGLayoutFor(options, headerBottom)
events := localStarOccultationSVGEventFrames(info, diagram.Frames, options.Language)
@@ -211,8 +217,13 @@ func renderLocalStarOccultationSVG(
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)
titleSize := starOccultationTitleFontSize(title, layout.width)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize))
}
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))
layout.width/2, titleSize, html.EscapeString(titleText))
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"
@@ -258,6 +269,7 @@ func localStarOccultationSVGLayoutFor(options LocalStarOccultationSVGOptions, he
stageTop: stageTop,
stageBottom: stageBottom,
footerY: height - 43,
headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight),
}
}
@@ -268,8 +280,10 @@ func writeLocalStarOccultationOverview(
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
overviewTitle := occultationTitleText(localStarOccultationSVGOverviewTitle(options), options.OverviewTitle != "",
layout.panelX-layout.overviewLeft-12, 14)
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)))
layout.overviewLeft, layout.overviewTop-10, html.EscapeString(overviewTitle))
cx := (layout.overviewLeft + layout.overviewRight) / 2
cy := (layout.overviewTop+layout.overviewBottom)/2 + 4
extent := localStarOccultationSVGExtent(diagram.Frames)
@@ -284,7 +298,9 @@ func writeLocalStarOccultationOverview(
moonRadius := localStarOccultationSVGMaximumMoonRadius(diagram.Frames) * scale
writeLocalStarOccultationAxes(b, cx, cy, moonRadius, options.Language)
writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon")
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
"local-star-occultation-moon-overview")
writeLocalStarOccultationLunarPath(b, diagram.Frames, mapX, mapY, extent, options.Language)
writeLocalStarOccultationTrack(b, diagram.Frames, mapX, mapY)
for _, event := range events {
@@ -400,14 +416,6 @@ func localStarOccultationSVGLunarPathLabel(language string) string {
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 {
@@ -466,8 +474,10 @@ func writeLocalStarOccultationContacts(
) {
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)
contactsTitle := occultationTitleText(localStarOccultationSVGContactsTitle(options), options.ContactsTitle != "",
layout.width-layout.panelX-layout.margin, 14)
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)))
layout.panelX, layout.overviewTop-10, html.EscapeString(contactsTitle))
available := layout.overviewBottom - layout.overviewTop - 10
rowHeight := math.Min(86, available/math.Max(1, float64(len(events))))
for index, event := range events {
@@ -494,8 +504,10 @@ func writeLocalStarOccultationStages(
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
phaseTitle := occultationTitleText(localStarOccultationSVGPhasePanelsTitle(options), options.PhasePanelsTitle != "",
layout.width-2*layout.margin, 14)
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)))
layout.margin, layout.stageTop+13, html.EscapeString(phaseTitle))
if len(events) == 0 {
return
}
@@ -507,7 +519,8 @@ func writeLocalStarOccultationStages(
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")
writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon",
event.appearance, fmt.Sprintf("local-star-occultation-moon-stage-%d", index))
x := cx - event.frame.StarXArcsec*scale
y := cy - event.frame.StarYArcsec*scale
writeLocalStarOccultationStar(b, x, y, event.frame.BehindMoon, "stage-star", event.label)
@@ -531,12 +544,14 @@ func writeLocalStarOccultationFooter(
layout localStarOccultationSVGLayout,
options LocalStarOccultationSVGOptions,
) {
directionLines := starOccultationWrapText(localStarOccultationSVGDirectionText(options), layout.width-80, 10)
directionLines, footerLines := localOccultationFooterLines(
localStarOccultationSVGDirectionText(options), options.DirectionText != "",
localStarOccultationSVGFooterNote(options), options.FooterNote != "",
layout.footerY, layout.width, layout.height)
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))
@@ -559,10 +574,11 @@ func localStarOccultationSVGEventFrames(
for _, frame := range frames {
if localStarOccultationSVGFrameHasLabel(frame, label) {
result = append(result, localStarOccultationEventFrame{
label: label,
name: localStarOccultationSVGEventName(label, language),
time: times[label],
frame: frame,
label: label,
name: localStarOccultationSVGEventName(label, language),
time: times[label],
frame: frame,
appearance: localOccultationMoonAppearanceAt(times[label], info.Observer),
})
break
}
@@ -585,15 +601,52 @@ func localStarOccultationSVGFrameHasLabel(frame moon.StarOccultationDiagramFrame
func localStarOccultationSVGHeaderLines(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) []string {
lines := make([]string, 0, 4)
for _, value := range []string{
localStarOccultationSVGSummaryText(info, options),
localStarOccultationSVGGreatestText(info, options),
for _, item := range []struct {
value string
custom bool
}{
{localStarOccultationSVGSummaryText(info, options), options.SummaryText != ""},
{localStarOccultationSVGGreatestText(info, options), options.GreatestText != ""},
} {
lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...)
lines = append(lines, occultationWrappedTextLines(item.value, item.custom, float64(options.Width)-80, 13)...)
}
return lines
}
// localOccultationFooterLines 排布本地图页脚:补充说明贴画布底边,方向说明占用上方余高,两块都按行数上限截断。
func localOccultationFooterLines(
directionText string, directionCustom bool,
noteText string, noteCustom bool,
footerY, width, height float64,
) ([]string, []string) {
maxWidth := width - 80
direction := occultationWrappedTextLines(directionText, directionCustom, maxWidth, 10)
note := occultationWrappedTextLines(noteText, noteCustom, maxWidth, 9)
noteAbove, noteBelow := svgchart.EstimatedTextExtents(9)
noteHeight := noteAbove + float64(len(note)-1)*12 + noteBelow
direction = svgchart.TruncateTextLines(direction, maxWidth, 10,
svgchart.BaselineLineLimit(10, 14, footerY-8, height-2-noteHeight))
noteFirst := footerY + float64(len(direction))*14 - 6
note = svgchart.TruncateTextLines(note, maxWidth, 9,
svgchart.BaselineLineLimit(9, 12, noteFirst, height-2))
return direction, note
}
// localOccultationHeaderLineLimit 由版式的页脚预留与阶段区高度推出页眉行数上限,总览区被压到 120 px 以下时圆盘与方向标记会跑出画布。
func localOccultationHeaderLineLimit(height, footerSpace, stageHeight float64) int {
const (
stageGap = 20.0
overviewTopGap = 26.0
overviewMin = 120.0
headerStart = 72.0
)
limit := int(math.Floor((height - footerSpace - stageHeight - stageGap - overviewMin - overviewTopGap - headerStart) / 19))
if limit < 1 {
return 1
}
return limit
}
func localStarOccultationSVGTitle(info moon.StarOccultationInfo, options LocalStarOccultationSVGOptions) string {
if options.Title != "" {
return options.Title
+49
View File
@@ -2,6 +2,7 @@ package svg
import (
"errors"
"fmt"
"math"
"strings"
"testing"
@@ -56,6 +57,54 @@ func TestFindLocalStarOccultationSVGsHR4799(t *testing.T) {
}
}
func TestLocalStarOccultationSVGUsesGreatestMoonPhase(t *testing.T) {
info := localHR4799Occultation(t)
diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
Location: time.UTC,
})
if err != nil {
t.Fatalf("LocalStarOccultationSVG() error = %v", err)
}
appearance := localOccultationMoonAppearanceAt(info.Greatest, info.Observer)
want := fmt.Sprintf(`data-illumination="%.6f" data-bright-limb-position-angle="%.3f"`,
appearance.illumination, appearance.brightLimbPA)
if !strings.Contains(diagram, want) {
t.Fatalf("local SVG does not carry greatest-time Moon appearance %q", want)
}
if appearance.illumination > 1e-6 && appearance.illumination < 1-1e-6 &&
!strings.Contains(diagram, `<clipPath id="local-star-occultation-moon-overview">`) {
t.Fatal("non-full Moon overview is missing its phase clip path")
}
}
func TestLocalOccultationMoonPhasePathSelectsIlluminatedArea(t *testing.T) {
gibbous := localOccultationMoonPhasePath(100, 0.75)
if !strings.Contains(gibbous, `A 50.000 100.000 0 0 1 0 -100.000`) {
t.Fatalf("waxing or waning gibbous terminator uses the wrong arc: %s", gibbous)
}
crescent := localOccultationMoonPhasePath(100, 0.25)
if !strings.Contains(crescent, `A 50.000 100.000 0 0 0 0 -100.000`) {
t.Fatalf("crescent terminator uses the wrong arc: %s", crescent)
}
}
func TestLocalOccultationMoonSVGRotationUsesNorthUpEastLeft(t *testing.T) {
tests := []struct {
positionAngle float64
rotation float64
}{
{positionAngle: 0, rotation: -90},
{positionAngle: 90, rotation: -180},
{positionAngle: 180, rotation: -270},
{positionAngle: 270, rotation: -360},
}
for _, test := range tests {
if got := localOccultationMoonSVGRotation(test.positionAngle); got != test.rotation {
t.Fatalf("bright-limb PA %.1f rotation = %.1f, want %.1f", test.positionAngle, got, test.rotation)
}
}
}
func TestLocalStarOccultationSVGEnglishAndCustomText(t *testing.T) {
info := localHR4799Occultation(t)
diagram, err := LocalStarOccultationSVG(info, hr4799StarCoordinate(), LocalStarOccultationSVGOptions{
+4
View File
@@ -4,6 +4,7 @@ import "b612.me/astro/internal/svgmap"
// MapProjection 控制全球月掩地图使用的投影。
// 零值根据事件几何自动选择投影。
// 与 eclipse/svg.EclipseMapProjection 是同一概念的两套名字,取值字符串逐对相同。
// MapProjection controls the projection used by a global occultation map.
// The zero value selects a projection from the event geometry.
type MapProjection string
@@ -21,6 +22,9 @@ const (
// MapProjectionSouthPolar 使用南极方位等距投影。
// MapProjectionSouthPolar uses the south-polar azimuthal equidistant projection.
MapProjectionSouthPolar MapProjection = "south-polar"
// MapProjectionOrthographic 使用正射球面投影,视点取事件中心,只画朝向观察者的半球。
// MapProjectionOrthographic uses the orthographic globe, viewed from the event centre; only the facing hemisphere is drawn.
MapProjectionOrthographic MapProjection = "orthographic"
)
func internalMapProjection(value MapProjection) svgmap.Projection {
+258 -41
View File
@@ -9,6 +9,8 @@ import (
"unicode"
"unicode/utf8"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
@@ -30,6 +32,8 @@ type starOccultationSVGLayout struct {
panelWidth float64
footerY float64
projection svgmap.Projection
// center 是正射球面的视点;其他投影忽略它。
center svgmap.GeoPoint
}
type starOccultationGeoPoint struct {
@@ -91,29 +95,113 @@ func validateStarOccultationPath(path moon.StarOccultationPath) error {
return fmt.Errorf("%w: northern and southern limit sample %d times must match", ErrInvalidStarOccultationPath, index)
}
}
if err := occultationgeo.ValidateRiseSetCurves(path.RiseSetCurves, path.Start.Time, path.End.Time); err != nil {
return fmt.Errorf("%w: invalid rise/set curves: %v", ErrInvalidStarOccultationPath, err)
}
if err := validateOccultationContours(
ErrInvalidStarOccultationPath, "band contours", path.BandContours,
path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validateOccultationContours(
ErrInvalidStarOccultationPath, "visibility contours", path.VisibilityContours,
path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validateOccultationGreatestTimeContours(ErrInvalidStarOccultationPath, path.GreatestTimeContours); err != nil {
return err
}
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
if err := validateOccultationFootprints(
ErrInvalidStarOccultationPath, "stellar", path.Footprints, path.Start.Time, path.End.Time,
); err != nil {
return err
}
return validateOccultationFootprints(
ErrInvalidStarOccultationPath, "stellar compact band", path.BandFootprints, path.Start.Time, path.End.Time,
)
}
func validateStarOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
return validateOccultationPathPoint(ErrInvalidStarOccultationPath, name, point)
}
func validateOccultationPathPoint(base error, name string, point moon.OccultationPathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("%w: %s time is required", ErrInvalidStarOccultationPath, name)
return fmt.Errorf("%w: %s time is required", base, name)
}
if !starOccultationFinite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 {
return fmt.Errorf("%w: %s longitude must be in [-180, 180]", ErrInvalidStarOccultationPath, name)
return fmt.Errorf("%w: %s longitude must be in [-180, 180]", base, name)
}
if !starOccultationFinite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("%w: %s latitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
return fmt.Errorf("%w: %s latitude must be in [-90, 90]", base, 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)
return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", base, name)
}
if !starOccultationFinite(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("%w: %s width must be finite and non-negative", ErrInvalidStarOccultationPath, name)
return fmt.Errorf("%w: %s width must be finite and non-negative", base, name)
}
return nil
}
func validateOccultationContours(
base error,
name string,
contours [][]moon.OccultationPathPoint,
start, end time.Time,
) error {
for contourIndex, contour := range contours {
if len(contour) < 2 {
return fmt.Errorf("%w: %s[%d] must contain at least two points", base, name, contourIndex)
}
for pointIndex, point := range contour {
if err := validateOccultationPathPoint(
base, fmt.Sprintf("%s[%d][%d]", name, contourIndex, pointIndex), point,
); err != nil {
return err
}
if point.Time.Before(start) || point.Time.After(end) {
return fmt.Errorf("%w: %s point is outside event time", base, name)
}
if pointIndex > 0 && !point.Time.After(contour[pointIndex-1].Time) {
return fmt.Errorf("%w: %s[%d] times must be strictly increasing", base, name, contourIndex)
}
}
}
return nil
}
// validateOccultationGreatestTimeContours 校验等时线:支路至少两点、点时刻必须等于该支路的
// 时刻取值(1 ms 容差覆盖 TT 往返),其余字段与普通路径点同口径。
func validateOccultationGreatestTimeContours(base error, contours []moon.OccultationGreatestTimeContour) error {
for contourIndex, contour := range contours {
if !starOccultationFinite(contour.JDE) || contour.JDE == 0 {
return fmt.Errorf("%w: greatest-time contour %d JDE is required", base, contourIndex)
}
if contour.Time.IsZero() {
return fmt.Errorf("%w: greatest-time contour %d time is required", base, contourIndex)
}
for segmentIndex, segment := range contour.Segments {
if len(segment) < 2 {
return fmt.Errorf("%w: greatest-time contour %d segment %d must contain at least two points", base, contourIndex, segmentIndex)
}
name := fmt.Sprintf("greatest-time contour %d segment %d", contourIndex, segmentIndex)
for pointIndex, point := range segment {
if err := validateOccultationPathPoint(base, fmt.Sprintf("%s point %d", name, pointIndex), point); err != nil {
return err
}
if delta := point.Time.Sub(contour.Time); delta > time.Millisecond || delta < -time.Millisecond {
return fmt.Errorf("%w: %s point %d time does not match the contour instant", base, name, pointIndex)
}
}
}
}
return nil
}
@@ -122,6 +210,7 @@ func starOccultationSVGLayoutFor(
options StarOccultationSVGOptions,
headerBottom float64,
projection svgmap.Projection,
center svgmap.GeoPoint,
) starOccultationSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
@@ -134,9 +223,10 @@ func starOccultationSVGLayoutFor(
mapWidth = width - 2*margin - gap - panelWidth
}
contentTop := headerBottom + 28
footerSpace := 82.0
// 底部预留必须同时容纳图例行、页脚正文与它们之间的空隙,否则小画布上图例与页脚会同高叠印。
footerSpace := 104.0
if projection != svgmap.ProjectionEquirectangular {
footerSpace = 116
footerSpace = 128
}
availableHeight := math.Max(110, height-contentTop-footerSpace)
mapHeight := math.Min(mapWidth/2, availableHeight)
@@ -149,6 +239,11 @@ func starOccultationSVGLayoutFor(
mapWidth = math.Min(mapWidth, 2*mapHeight)
}
mapY := contentTop + math.Max(0, (availableHeight-mapHeight)/2)
footerY := height - 54
// 图例最多两行(occultationLegendRowHeight),页脚必须落在第二行下方。
if legendY := mapY + mapHeight + 30; footerY < legendY+2*occultationLegendRowHeight {
footerY = legendY + 2*occultationLegendRowHeight
}
return starOccultationSVGLayout{
width: width,
height: height,
@@ -160,8 +255,9 @@ func starOccultationSVGLayoutFor(
panelX: margin + mapWidth + gap,
panelY: mapY,
panelWidth: panelWidth,
footerY: height - 54,
footerY: footerY,
projection: projection,
center: center,
}
}
@@ -177,6 +273,9 @@ func (layout starOccultationSVGLayout) mapFrame() svgmap.Frame {
Width: layout.mapWidth,
Height: layout.mapHeight,
Projection: layout.projection,
// 正射球面必须给出视点,否则会退回 (0°,0°) 而完全不对准事件。
CenterLongitude: layout.center.Longitude,
CenterLatitude: layout.center.Latitude,
}
}
@@ -189,6 +288,16 @@ func resolveStarOccultationMapProjection(path moon.StarOccultationPath, requeste
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
}
}
for _, footprints := range [][]moon.OccultationFootprint{path.Footprints, path.BandFootprints} {
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)
}
@@ -225,16 +334,16 @@ func starOccultationPathSegments(points []moon.OccultationPathPoint) [][]moon.Oc
func starOccultationPathSegmentsForProjection(
points []moon.OccultationPathPoint,
projection svgmap.Projection,
view svgmap.ClipView,
) [][]moon.OccultationPathPoint {
if projection == svgmap.ProjectionEquirectangular {
if view.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)
clipped := svgmap.PolylineSegments(geographic, view)
result := make([][]moon.OccultationPathPoint, 0, len(clipped))
for _, segment := range clipped {
converted := make([]moon.OccultationPathPoint, len(segment))
@@ -246,6 +355,36 @@ func starOccultationPathSegmentsForProjection(
return result
}
func starOccultationBoundarySegmentsForProjection(
points []moon.OccultationPathPoint,
view svgmap.ClipView,
) [][]moon.OccultationPathPoint {
ranges := occultationgeo.ContinuousBoundaryRanges(points)
result := make([][]moon.OccultationPathPoint, 0, len(ranges))
for _, sampleRange := range ranges {
current := points[sampleRange.Start:sampleRange.End]
if len(current) == 1 {
current = []moon.OccultationPathPoint{current[0], current[0]}
}
if view.Projection == svgmap.ProjectionEquirectangular {
result = append(result, starOccultationPathSegments(current)...)
continue
}
geographic := make([]svgmap.GeoPoint, len(current))
for index, point := range current {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
for _, segment := range svgmap.PolylineSegments(geographic, view) {
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
@@ -289,12 +428,13 @@ func starOccultationInterpolatePathPoint(a, b moon.OccultationPathPoint, fractio
func starOccultationBandSegments(
northern, southern []moon.OccultationPathPoint,
) [][]starOccultationGeoPoint {
return starOccultationBandFragments(northern, southern, svgmap.ProjectionEquirectangular)
return starOccultationBandFragments(northern, southern,
svgmap.ClipView{Projection: svgmap.ProjectionEquirectangular})
}
func starOccultationBandFragments(
northern, southern []moon.OccultationPathPoint,
projection svgmap.Projection,
view svgmap.ClipView,
) [][]starOccultationGeoPoint {
count := len(northern)
if len(southern) < count {
@@ -303,32 +443,68 @@ func starOccultationBandFragments(
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}
segments := make([][]starOccultationGeoPoint, 0, 2)
for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern[:count], southern[:count]) {
if sampleRange.End-sampleRange.Start < 2 {
continue
}
if math.Abs(starOccultationPolygonArea(converted)) > 1e-9 {
segments = append(segments, converted)
polygon := make([]starOccultationGeoPoint, 0, 2*(sampleRange.End-sampleRange.Start))
for _, point := range northern[sampleRange.Start:sampleRange.End] {
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
}
for index := sampleRange.End - 1; index >= sampleRange.Start; 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}
}
for _, fragment := range svgmap.PolygonFragments(geographic, view) {
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 starOccultationBandEndpointSections(
northern, southern []moon.OccultationPathPoint,
view svgmap.ClipView,
) [][]starOccultationGeoPoint {
count := len(northern)
if len(southern) < count {
count = len(southern)
}
if count == 0 {
return nil
}
sections := make([][]starOccultationGeoPoint, 0, 2)
for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern[:count], southern[:count]) {
if sampleRange.End-sampleRange.Start != 1 {
continue
}
index := sampleRange.Start
line := []svgmap.GeoPoint{
{Longitude: northern[index].Longitude, Latitude: northern[index].Latitude},
{Longitude: southern[index].Longitude, Latitude: southern[index].Latitude},
}
for _, segment := range svgmap.PolylineSegments(line, view) {
converted := make([]starOccultationGeoPoint, len(segment))
for pointIndex, point := range segment {
converted[pointIndex] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude}
}
sections = append(sections, converted)
}
}
return sections
}
func legacyStarOccultationBandSegments(polygon []starOccultationGeoPoint) [][]starOccultationGeoPoint {
polygon = starOccultationUnwrapPolygon(polygon)
minimumLongitude, maximumLongitude := polygon[0].longitude, polygon[0].longitude
@@ -424,16 +600,21 @@ func starOccultationPolygonArea(points []starOccultationGeoPoint) float64 {
return area / 2
}
func starOccultationSVGHeaderLines(path moon.StarOccultationPath, options StarOccultationSVGOptions) []string {
func starOccultationSVGHeaderLines(
path moon.StarOccultationPath,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
) []string {
lines := make([]string, 0, 4)
for _, item := range []struct {
text string
fontSize float64
custom bool
}{
{starOccultationSVGSummaryText(path, options), 14},
{starOccultationSVGGreatestText(path, options), 13},
{starOccultationSVGSummaryText(path, options), 14, flags.summaryText},
{starOccultationSVGGreatestText(path, options), 13, flags.greatestText},
} {
lines = append(lines, starOccultationWrapText(item.text, float64(options.Width)-80, item.fontSize)...)
lines = append(lines, occultationWrappedTextLines(item.text, item.custom, float64(options.Width)-80, item.fontSize)...)
}
return lines
}
@@ -483,10 +664,15 @@ func starOccultationSVGGreatestText(path moon.StarOccultationPath, options StarO
}
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)
// 带宽与行星图、详细版同口径:取南北限的地面间距。Greatest.WidthKM 是另一种构造,两者不可互换。
partialWidth := path.GreatestLimitSeparationKM
if partialWidth <= 0 {
partialWidth = path.Greatest.WidthKM
}
return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, path.Greatest.WidthKM, altitude)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, partialWidth, altitude)
}
return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, partialWidth, altitude)
}
func starOccultationSVGMapTitle(options StarOccultationSVGOptions) string {
@@ -527,6 +713,8 @@ func starOccultationProjectionLabel(projection MapProjection, language string) s
return "North-polar azimuthal equidistant projection"
case MapProjectionSouthPolar:
return "South-polar azimuthal equidistant projection"
case MapProjectionOrthographic:
return "Orthographic globe projection"
default:
return "Equirectangular projection"
}
@@ -536,6 +724,8 @@ func starOccultationProjectionLabel(projection MapProjection, language string) s
return "北极方位等距投影"
case MapProjectionSouthPolar:
return "南极方位等距投影"
case MapProjectionOrthographic:
return "正射球面投影"
default:
return "等经纬投影"
}
@@ -595,6 +785,33 @@ func starOccultationSameDate(first, second time.Time) bool {
return y1 == y2 && m1 == m2 && d1 == d2
}
// occultationTitleText 截断调用方给出的单行标题:自动文案不动,避免改变既有产物。
func occultationTitleText(value string, custom bool, maxWidth, fontSize float64) string {
// 标题是单行固定槽位,放不下就截断;默认标题本来就放得下时结果不变。
return svgchart.EllipsizeText(value, maxWidth, fontSize)
}
// occultationWrappedTextLines 折行一段文本:调用方自定义文本按保守字宽折行,自动生成文本沿用既有口径,改口径会移动断行位置。
func occultationWrappedTextLines(value string, custom bool, maxWidth, fontSize float64) []string {
if custom {
return svgchart.WrapText(value, maxWidth, fontSize)
}
return starOccultationWrapText(value, maxWidth, fontSize)
}
// starOccultationSVGHeaderLineLimit 返回页眉行数上限:地图下限 110、上方空隙 28 与页脚预留之外的余高才轮到页眉。
func starOccultationSVGHeaderLineLimit(options StarOccultationSVGOptions, projection svgmap.Projection) int {
footerSpace := 104.0
if projection != svgmap.ProjectionEquirectangular {
footerSpace = 128
}
limit := int(math.Floor((float64(options.Height) - 210 - footerSpace) / 19))
if limit < 1 {
return 1
}
return limit
}
func starOccultationWrapText(value string, maxWidth, fontSize float64) []string {
value = strings.TrimSpace(value)
if value == "" {
+105
View File
@@ -0,0 +1,105 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/moon"
)
// localOccultationMoonAppearance contains the apparent lunar illumination used
// by the fixed-site occultation diagrams. The limb angle is topocentric so the
// rendered bright side follows the same observer geometry as the occultation.
type localOccultationMoonAppearance struct {
illumination float64
brightLimbPA float64
}
func localOccultationMoonAppearanceAt(date time.Time, observer moon.Observer) localOccultationMoonAppearance {
illumination := moon.Phase(date)
if !starOccultationFinite(illumination) {
illumination = 1
}
illumination = math.Max(0, math.Min(1, illumination))
brightLimbPA := moon.TopocentricBrightLimbPositionAngle(
date, observer.Longitude, observer.Latitude, observer.Height,
)
if !starOccultationFinite(brightLimbPA) {
brightLimbPA = 0
}
return localOccultationMoonAppearance{
illumination: illumination,
brightLimbPA: brightLimbPA,
}
}
// writeLocalOccultationMoon draws a textured lunar disk with a phase-aware
// terminator. clipID is unique within the SVG document because each stage
// can have a different event time and therefore a different lunar phase.
func writeLocalOccultationMoon(
b *strings.Builder,
cx, cy, radius float64,
class string,
appearance localOccultationMoonAppearance,
clipID string,
) {
if !starOccultationFinite(radius) || radius <= 0 {
return
}
illumination := math.Max(0, math.Min(1, appearance.illumination))
brightLimbPA := appearance.brightLimbPA
if !starOccultationFinite(brightLimbPA) {
brightLimbPA = 0
}
fmt.Fprintf(b, `<g class="%s" data-illumination="%.6f" data-bright-limb-position-angle="%.3f">`,
html.EscapeString(class), illumination, brightLimbPA)
if illumination < 1-1e-6 {
fmt.Fprintf(b, `<circle class="moon-phase-dark" cx="%.3f" cy="%.3f" r="%.3f" fill="#24272a"/>`, cx, cy, radius)
}
if illumination > 1e-6 {
if illumination >= 1-1e-6 {
writeLocalOccultationMoonTexture(b, cx, cy, radius)
} else {
path := localOccultationMoonPhasePath(radius, illumination)
rotation := localOccultationMoonSVGRotation(brightLimbPA)
fmt.Fprintf(b, `<clipPath id="%s"><path d="%s" transform="translate(%.3f %.3f) rotate(%.3f)"/></clipPath>`,
html.EscapeString(clipID), path, cx, cy, rotation)
fmt.Fprintf(b, `<g class="moon-phase-lit" clip-path="url(#%s)">`, html.EscapeString(clipID))
writeLocalOccultationMoonTexture(b, cx, cy, radius)
b.WriteString(`</g>`)
}
}
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 writeLocalOccultationMoonTexture(b *strings.Builder, cx, cy, radius float64) {
fmt.Fprintf(b, `<use href="#le-moon" x="%.3f" y="%.3f" width="%.3f" height="%.3f"/>`,
cx-radius, cy-radius, radius*2, radius*2)
}
// The chart uses north-up and east-left coordinates. SVG rotations are
// clockwise from +X, so a celestial position angle of 0 points upward and 90
// points left after this conversion.
func localOccultationMoonSVGRotation(brightLimbPA float64) float64 {
return -brightLimbPA - 90
}
// localOccultationMoonPhasePath returns the illuminated portion of a unit
// projected lunar disk. The outer arc is the visible limb; the inner ellipse
// is the projected terminator. The +X side is the bright side before rotation.
func localOccultationMoonPhasePath(radius, illumination float64) string {
cosPhase := 2*illumination - 1
terminatorRadius := radius * math.Abs(cosPhase)
sweep := 1
if cosPhase < 0 {
sweep = 0
}
return fmt.Sprintf(
"M 0 %.3f A %.3f %.3f 0 0 1 0 %.3f A %.3f %.3f 0 0 %d 0 %.3f Z",
-radius, radius, radius, radius, terminatorRadius, radius, sweep, -radius,
)
}
+47
View File
@@ -0,0 +1,47 @@
package svg
import (
"errors"
"testing"
)
// 数据块行距不足时详细版必须拒绝画布,而不是画出压叠的面板文字。
func TestOccultationDetailedSVGRejectsCanvasesThatCrowdPanels(t *testing.T) {
for _, testCase := range []struct {
name string
width, height int
rejected bool
}{
{name: "landscape too short", width: 900, height: 400, rejected: true},
{name: "landscape borderline", width: 900, height: 560, rejected: true},
{name: "portrait too narrow", width: 600, height: 800, rejected: true},
{name: "smallest landscape", width: 800, height: 600},
{name: "default page", width: 1000, height: 1414},
{name: "landscape page", width: 1414, height: 1000},
} {
for name, render := range map[string]func() (string, error){
"star": func() (string, error) {
return StarOccultationDetailedSVG(occultationTestStarPath(t), hr4799StarCoordinate(),
OccultationDetailedSVGOptions{Width: testCase.width, Height: testCase.height})
},
"planet": func() (string, error) {
return PlanetOccultationDetailedSVG(occultationTestSaturnPath(t),
OccultationDetailedSVGOptions{Width: testCase.width, Height: testCase.height})
},
} {
document, err := render()
if testCase.rejected {
if !errors.Is(err, ErrInvalidOccultationDetailedSVGOptions) {
t.Fatalf("%s %s: err = %v, want ErrInvalidOccultationDetailedSVGOptions", name, testCase.name, err)
}
continue
}
if err != nil {
t.Fatalf("%s %s: %v", name, testCase.name, err)
}
if len(document) == 0 {
t.Fatalf("%s %s: empty document", name, testCase.name)
}
}
}
}
+520 -137
View File
@@ -3,11 +3,12 @@ package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
@@ -81,33 +82,30 @@ func PlanetOccultationPathSVG(
starShape.TargetID = targetID
}
options.Projection = MapProjection(resolvePlanetOccultationMapProjection(path, options.Projection))
// 默认文案会先写回 options,必须在填充之前记下调用方真正给出的字段。
flags := starOccultationSVGTextFlags(options)
options = planetOccultationSVGDefaults(path, options)
return renderOccultationPathSVG(starShape, &path, options), nil
diagram, err := renderOccultationPathSVG(starShape, &path, options, flags)
if err != nil {
// 几何引擎失败不是数据格式错误,不能裹成 ErrInvalidPlanetOccultationPath 哨兵。
return "", fmt.Errorf("planetary occultation SVG band geometry: %w", err)
}
return diagram, 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)
minimumLatitude, maximumLatitude := planetOccultationBandLatitudeRange(path)
// 掩带包住极点时等经纬会把它拉成横贯上下边缘的长条,必须改用极区图;
// 判据要看带子本身的纬度范围,掩甚点在中纬也可能绕极。
if requested == MapProjectionAuto {
if maximumLatitude >= occultationPolarBandReach {
return svgmap.ProjectionNorthPolar
}
}
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)
}
}
if minimumLatitude <= -occultationPolarBandReach {
return svgmap.ProjectionSouthPolar
}
}
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
@@ -123,6 +121,7 @@ func planetOccultationStarShape(path moon.PlanetOccultationPath, targetID string
CenterLine: path.CenterLine,
NorthernLimit: path.NorthernLimit,
SouthernLimit: path.SouthernLimit,
RiseSetCurves: path.RiseSetCurves,
Step: path.Step,
TargetSpacingKM: path.TargetSpacingKM,
}
@@ -135,13 +134,33 @@ func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error {
if err := validateStarOccultationPath(planetOccultationStarShape(path, path.TargetID)); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPlanetOccultationPath, err)
}
if err := validateOccultationGreatestTimeContours(ErrInvalidPlanetOccultationPath, path.GreatestTimeContours); err != nil {
return 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 {
len(path.TotalFootprints) != 0 || len(path.TotalBandFootprints) != 0 ||
len(path.TotalBandContours) != 0 || len(path.TotalRiseSetCurves) != 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 err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
return err
}
if err := validatePlanetOccultationContours(
"partial band contours", path.PartialBandContours, path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validatePlanetOccultationContours(
"partial visibility contours", path.PartialVisibilityContours, path.Start.Time, path.End.Time,
); err != nil {
return err
}
return validatePlanetOccultationFootprints(
"partial compact band", path.PartialBandFootprints, path.Start.Time, path.End.Time,
)
}
if !path.TotalComplete {
return fmt.Errorf("%w: global total-occultation band is incomplete", ErrInvalidPlanetOccultationPath)
@@ -163,13 +182,51 @@ func validatePlanetOccultationPath(path moon.PlanetOccultationPath) error {
if err := validatePlanetOccultationTotalLimits(path); err != nil {
return err
}
if err := occultationgeo.ValidateRiseSetCurves(path.TotalRiseSetCurves, path.TotalStart.Time, path.TotalEnd.Time); err != nil {
return fmt.Errorf("%w: invalid total rise/set curves: %v", ErrInvalidPlanetOccultationPath, err)
}
if err := validatePlanetOccultationContours(
"partial band contours", path.PartialBandContours, path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validatePlanetOccultationContours(
"partial visibility contours", path.PartialVisibilityContours, path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validatePlanetOccultationContours(
"total band contours", path.TotalBandContours, path.TotalStart.Time, path.TotalEnd.Time,
); err != nil {
return err
}
if err := validatePlanetOccultationContours(
"total visibility contours", path.TotalVisibilityContours, path.TotalStart.Time, path.TotalEnd.Time,
); err != nil {
return err
}
if err := validatePlanetOccultationFootprints("partial", path.PartialFootprints, path.Start.Time, path.End.Time); err != nil {
return err
}
if err := validatePlanetOccultationFootprints(
"partial compact band", path.PartialBandFootprints, 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
return validatePlanetOccultationFootprints(
"total compact band", path.TotalBandFootprints, path.TotalStart.Time, path.TotalEnd.Time,
)
}
func validatePlanetOccultationContours(
name string,
contours [][]moon.OccultationPathPoint,
start, end time.Time,
) error {
return validateOccultationContours(ErrInvalidPlanetOccultationPath, name, contours, start, end)
}
func validatePlanetOccultationFootprints(
@@ -177,34 +234,17 @@ func validatePlanetOccultationFootprints(
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 validateOccultationFootprints(ErrInvalidPlanetOccultationPath, name, footprints, start, end)
}
func validateOccultationFootprints(
baseError error,
name string,
footprints []moon.OccultationFootprint,
start, end time.Time,
) error {
if err := occultationgeo.ValidateFootprints(footprints, start, end); err != nil {
return fmt.Errorf("%w: %s %v", baseError, name, err)
}
return nil
}
@@ -307,18 +347,24 @@ func planetOccultationSVGSummaryText(path moon.PlanetOccultationPath, options Pl
func planetOccultationSVGGreatestText(path moon.PlanetOccultationPath, language string) string {
coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude)
// 带宽用南北限的地面间距。Greatest.WidthKM 是另一种构造(接触锥可见弧上横向偏移的极差),
// 数值可以远大于实际带宽,两者不可互换。
partialWidth := path.GreatestLimitSeparationKM
if partialWidth <= 0 {
partialWidth = path.Greatest.WidthKM
}
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)
coordinates, partialWidth, path.GreatestTotalWidthKM)
}
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km | 全掩带宽 %.1f km",
coordinates, path.Greatest.WidthKM, path.GreatestTotalWidthKM)
coordinates, partialWidth, path.GreatestTotalWidthKM)
}
if language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, path.Greatest.WidthKM)
return fmt.Sprintf("Greatest point %s | partial-band width %.1f km", coordinates, partialWidth)
}
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, path.Greatest.WidthKM)
return fmt.Sprintf("掩甚点 %s | 部分掩带宽 %.1f km", coordinates, partialWidth)
}
func planetOccultationSVGFooter(path moon.PlanetOccultationPath, options PlanetOccultationSVGOptions) string {
@@ -345,86 +391,310 @@ func writePlanetOccultationMap(
starShape moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
) error {
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)
partial, err := writePlanetOccultationPartialBand(b, path, layout)
if err != nil {
return err
}
total := planetOccultationBand{}
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)
total, err = writePlanetOccultationTotalBand(b, path, layout)
if err != nil {
return err
}
}
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 !partial.draw.compact && len(path.PartialFootprints) == 0 {
writeStarOccultationGeoLine(b, path.NorthernLimit, layout,
occultationLineStyle{className: "northern-limit", color: "#a66f18", strokeWidth: 1.25, boundary: true})
writeStarOccultationGeoLine(b, path.SouthernLimit, layout,
occultationLineStyle{className: "southern-limit", color: "#a66f18", strokeWidth: 1.25, boundary: true})
}
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, "")
if path.HasTotalBand && !total.draw.compact && len(path.TotalFootprints) == 0 {
writeStarOccultationGeoLine(b, path.NorthernTotalLimit, layout,
occultationLineStyle{className: "northern-total-limit", color: "#355878", strokeWidth: 1.3, boundary: true})
writeStarOccultationGeoLine(b, path.SouthernTotalLimit, layout,
occultationLineStyle{className: "southern-total-limit", color: "#355878", strokeWidth: 1.3, boundary: true})
}
writeStarOccultationGeoLine(b, starShape.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4")
// Keep the physical phase curves above both translucent footprint bands;
// do not turn them into a closed static-band outline.
writeOccultationRiseSetCurves(b, path.RiseSetCurves, layout, "occultation")
writeOccultationRiseSetCurves(b, path.TotalRiseSetCurves, layout, "occultation-total")
writeOccultationHorizonConnectors(
b, partial.footprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, layout,
false,
)
// 事件标记先占位、后绘制:三族标签共用一张占位表,后一族必须绕开先放置的标签。
placer := &occultationLabelPlacer{}
eventMarkers := planetOccultationEventMarkerDraws(path, layout, options.Language, placer)
writeOccultationGreatestTimeContours(b, path.GreatestTimeContours, layout, options, placer)
writeStarOccultationGeoLine(b, starShape.CenterLine, layout,
occultationLineStyle{className: "center-line", color: "#59676b", strokeWidth: 1.6, dash: "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)
writeOccultationTimeMarkers(b, starShape.CenterLine, layout, options, excluded, path.Greatest.Time, placer)
writePlanetOccultationEventMarkers(b, path, eventMarkers)
layout.mapFrame().WriteFrame(b)
writePlanetOccultationLegend(b, layout, options.Language, path.HasTotalBand)
writePlanetOccultationLegend(
b, layout, options.Language, path.HasTotalBand, len(path.RiseSetCurves) > 0,
len(path.GreatestTimeContours) > 0,
)
return nil
}
func writePlanetOccultationFootprintSweep(
// planetOccultationBand 是一次行星掩带填充的结果及它采用的足迹。
type planetOccultationBand struct {
draw occultationBandDraw
footprints []moon.PlanetOccultationFootprint
}
// writePlanetOccultationPartialBand 绘制偏掩带;没有足迹时退回静态限带。
func writePlanetOccultationPartialBand(
b *strings.Builder,
footprints []moon.PlanetOccultationFootprint,
path moon.PlanetOccultationPath,
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}
) (planetOccultationBand, error) {
footprints := path.PartialBandFootprints
compact := len(footprints) > 0
if len(footprints) == 0 {
footprints = path.PartialFootprints
}
if len(footprints) == 0 {
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
"partial-occultation-band-layer", "occultation-band", "#e0ae43", 0.34)
return planetOccultationBand{}, nil
}
draw, err := (occultationFootprintSweep{
footprints: footprints,
contours: path.PartialBandContours,
visibilityContours: path.PartialVisibilityContours,
northern: path.NorthernLimit,
southern: path.SouthernLimit,
curves: path.RiseSetCurves,
layout: layout,
layerClass: "partial-occultation-band-layer",
pathClass: "occultation-band",
color: "#e0ae43",
opacity: 0.34,
compactBand: compact,
kind: partialOccultationFootprintSweep,
}).write(b)
if err != nil {
return planetOccultationBand{}, err
}
return planetOccultationBand{draw: draw, footprints: footprints}, nil
}
// writePlanetOccultationTotalBand 绘制全掩带;没有足迹时退回静态内接触限带。
func writePlanetOccultationTotalBand(
b *strings.Builder,
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
) (planetOccultationBand, error) {
footprints := path.TotalBandFootprints
compact := len(footprints) > 0
if len(footprints) == 0 {
footprints = path.TotalFootprints
}
if len(footprints) == 0 {
writeOccultationBand(b, path.NorthernTotalLimit, path.SouthernTotalLimit, layout,
"total-occultation-band-layer", "total-occultation-band", "#607d98", 0.72)
return planetOccultationBand{}, nil
}
draw, err := (occultationFootprintSweep{
footprints: footprints,
contours: path.TotalBandContours,
visibilityContours: path.TotalVisibilityContours,
northern: path.NorthernTotalLimit,
southern: path.SouthernTotalLimit,
curves: path.TotalRiseSetCurves,
layout: layout,
layerClass: "total-occultation-band-layer",
pathClass: "total-occultation-band",
color: "#607d98",
opacity: 0.72,
compactBand: compact,
kind: totalOccultationFootprintSweep,
}).write(b)
if err != nil {
return planetOccultationBand{}, err
}
return planetOccultationBand{draw: draw, footprints: footprints}, nil
}
type occultationFootprintSweepKind uint8
const (
starOccultationFootprintSweep occultationFootprintSweepKind = iota
partialOccultationFootprintSweep
totalOccultationFootprintSweep
)
// occultationBandSource 说明填充掩带几何的来源。
type occultationBandSource uint8
const (
occultationBandAbsent occultationBandSource = iota
occultationBandFootprints
occultationBandEnvelope
occultationBandSweep
)
// occultationBandDraw 报告一次掩带填充的结果。
type occultationBandDraw struct {
source occultationBandSource
// compact 表示填充由合并后的紧凑带构造,调用方不再叠加限线。
compact bool
}
func (draw occultationBandDraw) drawn() bool { return draw.source != occultationBandAbsent }
// claimBoundary 报告图例能否声明掩带边界:解析包络与直接绘制的瞬时足迹都可以,
// 只有足迹扫掠合并回退不保证边界就是解析边界。
func (draw occultationBandDraw) claimBoundary() bool {
return draw.source == occultationBandEnvelope || draw.source == occultationBandFootprints
}
// occultationFootprintSweep 是一次掩带填充需要的几何输入与画布样式。
type occultationFootprintSweep struct {
footprints []moon.PlanetOccultationFootprint
contours [][]moon.OccultationPathPoint
visibilityContours [][]moon.OccultationPathPoint
northern, southern []moon.OccultationPathPoint
curves []moon.OccultationRiseSetCurve
layout starOccultationSVGLayout
layerClass string
pathClass string
color string
opacity float64
compactBand bool
kind occultationFootprintSweepKind
}
// write 写出一个复合填充路径;几何引擎报错时返回错误,不退回原始瞬时足迹。
func (s occultationFootprintSweep) write(b *strings.Builder) (occultationBandDraw, error) {
rings, source, err := s.resolveRings()
if err != nil {
return occultationBandDraw{}, err
}
draw := occultationBandDraw{compact: s.compactBand && len(rings) > 0}
if len(rings) == 0 {
return draw, nil
}
draw.source = source
stroke, strokeWidth := s.outlineStyle(source)
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-opacity="0.88" stroke-width="%.2f"><path class="%s" fill-rule="nonzero" d="`,
s.layerClass, s.color, s.opacity, stroke, strokeWidth, s.pathClass)
for _, ring := range rings {
writeOccultationPathCommands(b, ring)
b.WriteString("Z ")
}
b.WriteString(`"/></g>`)
return draw, nil
}
// resolveRings 返回裁剪到画布、定向并简化后的填充环。
func (s occultationFootprintSweep) resolveRings() ([][]occultationScreenPoint, occultationBandSource, error) {
source := occultationBandFootprints
var polygons [][]geodata.GeoPoint
if s.compactBand {
merged, authoritative, err := s.mergedBandPolygons()
if err != nil {
return nil, source, err
}
if len(merged) == 0 {
return nil, source, errors.New("occultation band geometry produced no polygon")
}
polygons = merged
source = occultationBandSweep
if authoritative {
source = occultationBandEnvelope
}
} else {
polygons = planetOccultationFootprintPolygons(s.footprints)
}
rings := make([][]occultationScreenPoint, 0, len(polygons))
for _, polygon := range 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, s.layout.mapFrame().Clip()) {
if len(fragment) < 3 {
continue
}
for _, fragment := range svgmap.PolygonFragments(geographic, layout.projection) {
if len(fragment) < 3 {
continue
if planetOccultationProjectedArea(s.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]
}
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 ")
}
rings = append(rings, s.layout.projectOccultationGeoRing(fragment))
}
}
if geometry.Len() == 0 {
return
return rings, source, nil
}
// mergedBandPolygons 命中缓存时直接复用只读的合并结果:紧凑带的合并远贵于逐足迹绘制。
func (s occultationFootprintSweep) mergedBandPolygons() ([][]geodata.GeoPoint, bool, error) {
key := occultationBandCacheKey{kind: s.kind, digest: occultationBandDigest(s)}
if cached, ok := lookupOccultationBandPolygons(key); ok {
return cached.polygons, cached.authoritative, nil
}
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())
polygons, authoritative, err := s.mergeBandPolygons()
if err != nil {
return nil, false, err
}
storeOccultationBandPolygons(key, occultationBandPolygons{polygons: polygons, authoritative: authoritative})
return polygons, authoritative, nil
}
func (s occultationFootprintSweep) mergeBandPolygons() ([][]geodata.GeoPoint, bool, error) {
if len(s.contours) == 0 {
if s.kind == totalOccultationFootprintSweep {
return occultationgeo.VisibleTotalBandPolygons(s.footprints, s.northern, s.southern, s.curves)
}
return occultationgeo.VisibleBandPolygons(s.footprints, s.northern, s.southern, s.curves)
}
if s.kind == totalOccultationFootprintSweep {
return occultationgeo.VisibleTotalBandPolygonsFromAnalyticContours(
s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves,
)
}
if s.kind == starOccultationFootprintSweep {
return occultationgeo.VisibleStarBandPolygonsFromAnalyticContours(
s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves,
)
}
return occultationgeo.VisibleBandPolygonsFromAnalyticContours(
s.footprints, s.contours, s.visibilityContours, s.northern, s.southern, s.curves,
)
}
// outlineStyle 只给解析轮廓描边;总掩带保持纯填充外观。
func (s occultationFootprintSweep) outlineStyle(source occultationBandSource) (string, float64) {
if source != occultationBandEnvelope || s.pathClass == "total-occultation-band" {
return "none", 0
}
return s.color, 1.3
}
func planetOccultationFootprintPolygons(footprints []moon.PlanetOccultationFootprint) [][]geodata.GeoPoint {
polygons := make([][]geodata.GeoPoint, 0, len(footprints))
for _, footprint := range footprints {
for _, polygon := range footprint.Polygons {
geographic := make([]geodata.GeoPoint, len(polygon))
for index, point := range polygon {
geographic[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
polygons = append(polygons, geographic)
}
}
return polygons
}
func planetOccultationProjectedArea(layout starOccultationSVGLayout, points []svgmap.GeoPoint) float64 {
@@ -447,12 +717,13 @@ type planetOccultationProjectedEventMarker struct {
placement starOccultationEventMarkerPlacement
}
func writePlanetOccultationEventMarkers(
b *strings.Builder,
// planetOccultationEventMarkerDraws 计算全部事件标记;近似重合的外接触与内接触先分开标签再登记占位。
func planetOccultationEventMarkerDraws(
path moon.PlanetOccultationPath,
layout starOccultationSVGLayout,
language string,
) {
placer *occultationLabelPlacer,
) []planetOccultationProjectedEventMarker {
markers := []planetOccultationProjectedEventMarker{
{point: path.Start, label: planetOccultationEventLabel("start", language), kind: "start"},
}
@@ -485,6 +756,34 @@ func writePlanetOccultationEventMarkers(
separatePlanetOccultationMarkerPair(markers, 0, 1, layout)
separatePlanetOccultationMarkerPair(markers, 4, 3, layout)
}
visible := make([]starOccultationEventMarkerDraw, 0, len(markers))
for index := range markers {
if !markers[index].visible {
continue
}
visible = append(visible, starOccultationEventMarkerDraw{
x: markers[index].x, y: markers[index].y,
label: markers[index].label, kind: markers[index].kind,
placement: markers[index].placement,
})
}
reserveOccultationEventMarkerLabels(visible, layout, placer)
offset := 0
for index := range markers {
if !markers[index].visible {
continue
}
markers[index].placement = visible[offset].placement
offset++
}
return markers
}
func writePlanetOccultationEventMarkers(
b *strings.Builder,
path moon.PlanetOccultationPath,
markers []planetOccultationProjectedEventMarker,
) {
drawOrder := make([]int, len(markers))
for index := range drawOrder {
drawOrder[index] = index
@@ -537,39 +836,87 @@ func writePlanetOccultationLegend(
layout starOccultationSVGLayout,
language string,
hasTotal bool,
hasRiseSet bool,
hasIsochrones bool,
) {
labels := []string{"部分掩带", "可见中心线", "几何中心线"}
type legendItem struct {
label string
kind string
}
items := []legendItem{{"部分掩带", "partial"}}
if hasTotal {
labels = []string{"部分掩带", "全掩带", "可见中心线", "几何中心线"}
items = append(items, legendItem{"全掩带", "total"})
}
items = append(items, legendItem{"可见中心线", "visible"}, legendItem{"几何中心线", "geometric"})
if hasRiseSet {
items = append(items, legendItem{"初掩/掩甚/终掩月升月落线", "rise-set"})
}
if hasIsochrones {
items = append(items, legendItem{"掩甚时刻等时线", "isochrone"})
}
if language == starOccultationSVGLanguageEnglish {
labels = []string{"Partial band", "Visible center", "Geometric center"}
if hasTotal {
labels = []string{"Partial band", "Total band", "Visible center", "Geometric center"}
for index := range items {
switch items[index].kind {
case "partial":
items[index].label = "Partial band"
case "total":
items[index].label = "Total band"
case "visible":
items[index].label = "Visible center"
case "geometric":
items[index].label = "Geometric center"
case "rise-set":
items[index].label = "Rise/set phase lines"
case "isochrone":
items[index].label = "Greatest-time isochrones"
}
}
}
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)
legend := make([]occultationLegendItem, 0, len(items))
for _, item := range items {
current := occultationLegendItem{label: item.label, markerWidth: 18}
switch item.kind {
case "partial":
current.fill, current.fillOpacity = "#e0ae43", 0.55
case "total":
current.fill, current.fillOpacity = "#607d98", 0.82
case "geometric":
current.color, current.dash = "#59676b", "5 4"
case "rise-set":
current.color = "#d97706"
case "isochrone":
current.color = "#1f6fb2"
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(`/>`)
current.color = "#087f8c"
}
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))
legend = append(legend, current)
}
writeOccultationLegendItems(b, y, layout, legend, 9)
}
func writeOccultationHorizonConnectors(
b *strings.Builder,
footprints []moon.OccultationFootprint,
northern, southern []moon.OccultationPathPoint,
curves []moon.OccultationRiseSetCurve,
layout starOccultationSVGLayout,
pointSource bool,
) {
connectors := occultationgeo.HorizonConnectorSegments(footprints, curves, northern, southern)
if pointSource {
connectors = occultationgeo.StarHorizonConnectorSegments(footprints, curves, northern, southern)
}
for _, connector := range connectors {
if len(connector.Points) < 2 {
continue
}
points := occultationgeo.DensifyOccultationPathPoints(connector.Points, 35)
className := fmt.Sprintf("occultation-rise-set-boundary occultation-horizon-%s", connector.Direction)
writeStarOccultationGeoLine(
b, points, layout,
occultationLineStyle{className: className, color: "#d97706", strokeWidth: 1.35},
)
}
}
@@ -645,3 +992,39 @@ func planetOccultationChineseName(planet moon.OccultationPlanet) string {
return "行星"
}
}
// occultationPolarBandReach 是判定掩带已经包住极点的纬度阈值。
const occultationPolarBandReach = 89.0
// planetOccultationBandLatitudeRange 返回掩带(中心线、南北限与足印)的纬度范围。
func planetOccultationBandLatitudeRange(path moon.PlanetOccultationPath) (float64, float64) {
minimumLatitude, maximumLatitude := path.Greatest.Latitude, 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.PartialBandFootprints,
path.TotalFootprints, path.TotalBandFootprints,
} {
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 minimumLatitude, maximumLatitude
}
// planetOccultationOrthographicCentre 取掩甚点作为正射球面的视点。
func planetOccultationOrthographicCentre(path moon.PlanetOccultationPath) svgmap.GeoPoint {
return svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}
}
+48 -22
View File
@@ -9,6 +9,7 @@ import (
"time"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/moon"
)
@@ -57,11 +58,12 @@ type LocalPlanetOccultationSVGOptions struct {
}
type localPlanetOccultationEventFrame struct {
label string
name string
time time.Time
hidden bool
frame moon.PlanetOccultationDiagramFrame
label string
name string
time time.Time
hidden bool
frame moon.PlanetOccultationDiagramFrame
appearance localOccultationMoonAppearance
}
// FindLocalPlanetOccultationSVGs 搜索固定观测地点,并渲染请求时间窗口内的每次有限盘面行星月掩。空切片表示没有本地事件。
@@ -192,6 +194,9 @@ func renderLocalPlanetOccultationSVG(
) string {
title := localPlanetOccultationSVGTitle(info, options)
headerLines := localPlanetOccultationSVGHeaderLines(info, options)
// 页眉字号 14/13,省略号按较大的 14 估算;行数上限只与画布高度有关,先按空页眉取上限。
headerLimit := localPlanetOccultationSVGLayoutFor(options, 72).headerMaxLines
headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14, headerLimit)
headerBottom := 72.0 + float64(len(headerLines))*19
layout := localPlanetOccultationSVGLayoutFor(options, headerBottom)
events := localPlanetOccultationSVGEventFrames(info, diagram.Frames, options.Language)
@@ -205,8 +210,13 @@ func renderLocalPlanetOccultationSVG(
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)
titleSize := starOccultationTitleFontSize(title, layout.width)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize))
}
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))
layout.width/2, titleSize, html.EscapeString(titleText))
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"
@@ -255,6 +265,7 @@ func localPlanetOccultationSVGLayoutFor(
stageTop: stageTop,
stageBottom: stageBottom,
footerY: height - 62,
headerMaxLines: localOccultationHeaderLineLimit(height, footerSpace, stageHeight),
}
}
@@ -265,8 +276,10 @@ func writeLocalPlanetOccultationOverview(
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
overviewTitle := occultationTitleText(localPlanetOccultationSVGOverviewTitle(options), options.OverviewTitle != "",
layout.panelX-layout.overviewLeft-12, 14)
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)))
layout.overviewLeft, layout.overviewTop-10, html.EscapeString(overviewTitle))
cx := (layout.overviewLeft + layout.overviewRight) / 2
cy := (layout.overviewTop+layout.overviewBottom)/2 + 4
extent := localPlanetOccultationSVGExtent(diagram.Frames)
@@ -289,7 +302,9 @@ func writeLocalPlanetOccultationOverview(
event.frame.PlanetRadiusArcsec*scale, false, "overview-planet-disk", event.label,
)
}
writeLocalStarOccultationMoon(b, cx, cy, moonRadius, "overview-moon")
writeLocalOccultationMoon(b, cx, cy, moonRadius, "overview-moon",
localOccultationMoonAppearanceAt(diagram.Occultation.Greatest, diagram.Occultation.Observer),
"local-planet-occultation-moon-overview")
for _, event := range events {
x := mapX(event.frame.PlanetXArcsec)
y := mapY(event.frame.PlanetYArcsec)
@@ -435,8 +450,10 @@ func writeLocalPlanetOccultationContacts(
) {
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)
contactsTitle := occultationTitleText(localPlanetOccultationSVGContactsTitle(options), options.ContactsTitle != "",
layout.width-layout.panelX-layout.margin, 14)
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)))
layout.panelX, layout.overviewTop-10, html.EscapeString(contactsTitle))
available := layout.overviewBottom - layout.overviewTop - 10
rowHeight := math.Min(66, available/math.Max(1, float64(len(events))))
for index, event := range events {
@@ -465,8 +482,10 @@ func writeLocalPlanetOccultationStages(
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
phaseTitle := occultationTitleText(localPlanetOccultationSVGPhasePanelsTitle(options), options.PhasePanelsTitle != "",
layout.width-2*layout.margin, 14)
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)))
layout.margin, layout.stageTop+13, html.EscapeString(phaseTitle))
if len(events) == 0 {
return
}
@@ -484,7 +503,8 @@ func writeLocalPlanetOccultationStages(
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")
writeLocalOccultationMoon(b, cx, cy, event.frame.MoonRadiusArcsec*scale, "stage-moon",
event.appearance, fmt.Sprintf("local-planet-occultation-moon-stage-%d", index))
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)
@@ -527,12 +547,14 @@ func writeLocalPlanetOccultationFooter(
layout localStarOccultationSVGLayout,
options LocalPlanetOccultationSVGOptions,
) {
directionLines := starOccultationWrapText(localPlanetOccultationSVGDirectionText(options), layout.width-80, 10)
directionLines, footerLines := localOccultationFooterLines(
localPlanetOccultationSVGDirectionText(options), options.DirectionText != "",
localPlanetOccultationSVGFooterNote(info, options), options.FooterNote != "",
layout.footerY, layout.width, layout.height)
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))
@@ -562,11 +584,12 @@ func localPlanetOccultationSVGEventFrames(
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,
label: label,
name: localPlanetOccultationSVGEventName(label, language),
time: times[label],
hidden: hidden,
frame: frame,
appearance: localOccultationMoonAppearanceAt(times[label], info.Observer),
})
break
}
@@ -592,11 +615,14 @@ func localPlanetOccultationSVGHeaderLines(
options LocalPlanetOccultationSVGOptions,
) []string {
lines := make([]string, 0, 4)
for _, value := range []string{
localPlanetOccultationSVGSummaryText(info, options),
localPlanetOccultationSVGGreatestText(info, options),
for _, item := range []struct {
value string
custom bool
}{
{localPlanetOccultationSVGSummaryText(info, options), options.SummaryText != ""},
{localPlanetOccultationSVGGreatestText(info, options), options.GreatestText != ""},
} {
lines = append(lines, starOccultationWrapText(value, float64(options.Width)-80, 13)...)
lines = append(lines, occultationWrappedTextLines(item.value, item.custom, float64(options.Width)-80, 13)...)
}
return lines
}
+21
View File
@@ -2,6 +2,7 @@ package svg
import (
"errors"
"fmt"
"math"
"strings"
"testing"
@@ -52,6 +53,26 @@ func TestFindLocalPlanetOccultationSVGsSaturnFiveContacts(t *testing.T) {
}
}
func TestLocalPlanetOccultationSVGUsesGreatestMoonPhase(t *testing.T) {
info := localSaturnOccultation(t)
diagram, err := LocalPlanetOccultationSVG(info, LocalPlanetOccultationSVGOptions{
Location: time.UTC,
})
if err != nil {
t.Fatalf("LocalPlanetOccultationSVG() error = %v", err)
}
appearance := localOccultationMoonAppearanceAt(info.Greatest, info.Observer)
want := fmt.Sprintf(`data-illumination="%.6f" data-bright-limb-position-angle="%.3f"`,
appearance.illumination, appearance.brightLimbPA)
if !strings.Contains(diagram, want) {
t.Fatalf("local planetary SVG does not carry greatest-time Moon appearance %q", want)
}
if appearance.illumination > 1e-6 && appearance.illumination < 1-1e-6 &&
!strings.Contains(diagram, `<clipPath id="local-planet-occultation-moon-overview">`) {
t.Fatal("non-full Moon overview is missing its phase clip path")
}
}
func TestLocalPlanetOccultationSVGStageCentersPreserveDisplayedTangency(t *testing.T) {
event := localPlanetOccultationEventFrame{
label: "C2",
+323 -1
View File
@@ -36,6 +36,7 @@ func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) {
`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"`,
`class="occultation-rise-set-boundary occultation-start-rise"`, `stroke="#d97706"`, "初掩/掩甚/终掩月升月落线",
} {
if !strings.Contains(diagram, want) {
t.Fatalf("planetary SVG missing %q", want)
@@ -68,6 +69,316 @@ func TestFindPlanetOccultationSVGsSaturnFiniteDisk(t *testing.T) {
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)
}
bandIndex := strings.Index(diagram, `class="occultation-band"`)
curveIndex := strings.Index(diagram, `class="occultation-rise-set-boundary occultation-start-rise"`)
if bandIndex < 0 || curveIndex < 0 || curveIndex < bandIndex {
t.Fatalf("planetary SVG draws rise/set curves before the filled band: band=%d curve=%d", bandIndex, curveIndex)
}
curveEnd := -1
if curveIndex >= 0 {
curveEnd = strings.Index(diagram[curveIndex:], "/>")
}
if curveEnd < 0 || strings.Contains(diagram[curveIndex:curveIndex+curveEnd], "stroke-dasharray") {
t.Fatal("planetary rise/set phase line is rendered with a gap-producing dash pattern")
}
}
func TestPlanetOccultationLegendOmitsDisabledRiseSetCurves(t *testing.T) {
path := samplePlanetOccultationPath()
path.RiseSetCurves = nil
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
if strings.Contains(diagram, "初掩/掩甚/终掩月升月落线") || strings.Contains(diagram, "Rise/set phase lines") {
t.Fatal("planetary SVG legend claims disabled rise/set curves are present")
}
}
func TestPlanetOccultationSVGWithoutFootprintsUsesLimitBands(t *testing.T) {
path := samplePlanetOccultationPath()
path.PartialFootprints = nil
path.TotalFootprints = nil
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
for _, className := range []string{
`class="occultation-band"`, `class="total-occultation-band"`,
`class="northern-limit"`, `class="southern-limit"`,
`class="northern-total-limit"`, `class="southern-total-limit"`,
} {
if !strings.Contains(diagram, className) {
t.Fatalf("planetary SVG without footprints is missing %s", className)
}
}
}
func TestPlanetOccultationSVG20240725UsesCompactBandFootprints(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900,
DisableRiseSet: true, DisableFootprints: true,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.PartialFootprints) != 0 || len(path.TotalFootprints) != 0 ||
len(path.PartialBandFootprints) == 0 || len(path.TotalBandFootprints) == 0 {
t.Fatalf("dense/compact footprint counts partial=%d/%d total=%d/%d",
len(path.PartialFootprints), len(path.PartialBandFootprints),
len(path.TotalFootprints), len(path.TotalBandFootprints))
}
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: MapProjectionEquirectangular,
})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
for _, className := range []string{
`class="northern-limit"`, `class="southern-limit"`,
`class="northern-total-limit"`, `class="southern-total-limit"`,
} {
if strings.Contains(diagram, className) {
t.Fatalf("compact planetary SVG still overlays discontinuous auxiliary line %s", className)
}
}
x, y := planetOccultationSVGMapPoint(
t, diagram, MapProjectionEquirectangular, path.Greatest.Longitude, path.Greatest.Latitude,
)
for _, className := range []string{`class="occultation-band"`, `class="total-occultation-band"`} {
pathData := planetOccultationSVGPathData(t, diagram, strings.TrimSuffix(strings.TrimPrefix(className, `class="`), `"`))
if !strings.Contains(pathData, "Z") {
t.Fatalf("compact planetary SVG %s is not explicitly closed", className)
}
if !planetOccultationSVGPathContains(pathData, x, y) {
t.Fatalf("compact planetary SVG %s excludes greatest at %.3f, %.3f", className, x, y)
}
}
}
func TestPlanetOccultationSVGMars20250729MergesTotalRiseSetBoundaryBand(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationMars,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.TotalBandFootprints) < 20 {
t.Fatalf("total compact footprint count=%d, want sparse support samples", len(path.TotalBandFootprints))
}
if len(path.TotalRiseSetCurves) == 0 {
t.Fatal("total compact band is missing inner-contact rise/set curves")
}
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: MapProjectionEquirectangular,
})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
pathData := planetOccultationSVGPathData(t, diagram, "total-occultation-band")
subpaths := strings.Count(pathData, "M ")
if subpaths >= len(path.TotalBandFootprints)/2 {
t.Fatalf("Mars 2025-07-29 total band has %d subpaths for %d compact footprints, want merged boundary geometry",
subpaths, len(path.TotalBandFootprints))
}
if !strings.Contains(pathData, "Z") {
t.Fatal("Mars 2025-07-29 total boundary band is not explicitly closed")
}
}
func TestPlanetOccultationPathSVGJupiter20230517PolarBandContainsPole(t *testing.T) {
start := time.Date(2023, time.May, 17, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationJupiter,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one Jupiter path", len(paths), err)
}
for _, projection := range []struct {
name string
value MapProjection
longitude, latitude float64
}{
{name: "north-polar", value: MapProjectionNorthPolar, longitude: 0, latitude: 90},
{name: "equirectangular", value: MapProjectionEquirectangular, longitude: 0, latitude: 89},
} {
diagram, renderErr := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: projection.value,
})
if renderErr != nil {
t.Fatalf("%s PlanetOccultationPathSVG() error = %v", projection.name, renderErr)
}
x, y := planetOccultationSVGMapPoint(t, diagram, projection.value, projection.longitude, projection.latitude)
for _, className := range []string{"occultation-band", "total-occultation-band"} {
pathData := planetOccultationSVGPathData(t, diagram, className)
if !planetOccultationSVGPathContains(pathData, x, y) {
t.Fatalf("%s %s does not contain %.1f°E %.1f°N at %.3f, %.3f", projection.name, className,
projection.longitude, projection.latitude, x, y)
}
}
}
}
func TestPlanetOccultationPathSVGJupiter20230517UsesFineFootprintSweep(t *testing.T) {
start := time.Date(2023, time.May, 17, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationJupiter,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one Jupiter path", len(paths), err)
}
path := paths[0]
for _, series := range []struct {
name string
footprints []moon.PlanetOccultationFootprint
}{
{name: "partial", footprints: path.PartialFootprints},
{name: "total", footprints: path.TotalFootprints},
} {
if len(series.footprints) < 100 {
t.Fatalf("%s footprint count = %d, want fine sweep sampling", series.name, len(series.footprints))
}
for index := 1; index < len(series.footprints); index++ {
gap := series.footprints[index].Time.Sub(series.footprints[index-1].Time)
if gap > time.Minute+time.Millisecond {
t.Fatalf("%s footprint gap at %d = %s, want at most one minute", series.name, index, gap)
}
}
}
}
func planetOccultationSVGMapCenter(t *testing.T, diagram string) (float64, float64) {
t.Helper()
attributes := planetOccultationSVGElementAttributes(t, diagram, "circle", "map-ocean")
return planetOccultationSVGFloatAttribute(t, attributes, "cx"),
planetOccultationSVGFloatAttribute(t, attributes, "cy")
}
func planetOccultationSVGMapPoint(
t *testing.T, diagram string, projection MapProjection, longitude, latitude float64,
) (float64, float64) {
t.Helper()
if projection == MapProjectionNorthPolar || projection == MapProjectionSouthPolar {
return planetOccultationSVGMapCenter(t, diagram)
}
attributes := planetOccultationSVGElementAttributes(t, diagram, "rect", "map-ocean")
x := planetOccultationSVGFloatAttribute(t, attributes, "x")
y := planetOccultationSVGFloatAttribute(t, attributes, "y")
width := planetOccultationSVGFloatAttribute(t, attributes, "width")
height := planetOccultationSVGFloatAttribute(t, attributes, "height")
return x + (longitude+180)/360*width, y + (90-latitude)/180*height
}
func planetOccultationSVGPathData(t testing.TB, diagram, className string) string {
t.Helper()
attributes := planetOccultationSVGElementAttributes(t, diagram, "path", className)
value, ok := attributes["d"]
if !ok {
t.Fatalf("SVG path %q has no d attribute", className)
}
return value
}
func planetOccultationSVGElementAttributes(
t testing.TB,
diagram, elementName, className string,
) map[string]string {
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 != elementName {
continue
}
attributes := make(map[string]string, len(start.Attr))
for _, attribute := range start.Attr {
attributes[attribute.Name.Local] = attribute.Value
}
if attributes["class"] == className {
return attributes
}
}
t.Fatalf("SVG element %s.%s not found", elementName, className)
return nil
}
func planetOccultationSVGFloatAttribute(t testing.TB, attributes map[string]string, name string) float64 {
t.Helper()
value, ok := attributes[name]
if !ok {
t.Fatalf("SVG element has no %s attribute", name)
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
t.Fatalf("parse SVG %s attribute %q: %v", name, value, err)
}
return parsed
}
func planetOccultationSVGPathContains(pathData string, x, y float64) bool {
fields := strings.Fields(pathData)
polygon := make([][2]float64, 0)
for index := 0; index < len(fields); {
switch fields[index] {
case "M", "L":
if index+2 >= len(fields) {
return false
}
pointX, xErr := strconv.ParseFloat(fields[index+1], 64)
pointY, yErr := strconv.ParseFloat(fields[index+2], 64)
if xErr != nil || yErr != nil {
return false
}
polygon = append(polygon, [2]float64{pointX, pointY})
index += 3
case "Z":
if planetOccultationSVGPolygonContains(polygon, x, y) {
return true
}
polygon = polygon[:0]
index++
default:
return false
}
}
return false
}
func planetOccultationSVGPolygonContains(polygon [][2]float64, x, y float64) bool {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
a, b := polygon[previous], polygon[current]
if (a[1] > y) == (b[1] > y) {
continue
}
if a[0]+(y-a[1])*(b[0]-a[0])/(b[1]-a[1]) > x {
inside = !inside
}
}
return inside
}
func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *testing.T) {
@@ -77,7 +388,7 @@ func TestPlanetOccultationPathSVGWrapsFivePhaseSummaryAtRenderedFontSize(t *test
SummaryText: strings.Repeat("A", 130),
})
options = planetOccultationSVGDefaults(path, options)
lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options)
lines := starOccultationSVGHeaderLines(planetOccultationStarShape(path, path.TargetID), options, starOccultationSVGTextFlags(options))
if len(lines) < 3 {
t.Fatalf("planetary SVG header lines = %d, want wrapped summary plus greatest line", len(lines))
}
@@ -187,6 +498,17 @@ func TestPlanetOccultationPathSVGRejectsInvalidPath(t *testing.T) {
}
}
func TestPlanetOccultationPathSVGRejectsMalformedRiseSetCurve(t *testing.T) {
path := samplePlanetOccultationPath()
curve := sampleOccultationRiseSetCurve(path.Start.Time)
curve.Direction = moon.RiseSetDirection("bogus")
path.RiseSetCurves = []moon.OccultationRiseSetCurve{curve}
_, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidPlanetOccultationPath) {
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err)
}
}
func TestPlanetOccultationPathSVGSupportsPartialOnlyGlobalBand(t *testing.T) {
path := samplePlanetOccultationPath()
path.HasTotalBand = false
+350
View File
@@ -0,0 +1,350 @@
package svg
import (
"errors"
"math"
"strings"
"testing"
"time"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/moon"
)
// 本节钉住入口契约:进入几何引擎的每一组轮廓都要先校验;引擎失败属于内部错误,
// 用非哨兵错误返回(不得谎报数据无效),也不能静默改画原始足迹。
func TestStarOccultationPathSVGRejectsMalformedBandContours(t *testing.T) {
base := sampleStarOccultationPath().Start.Time
valid := func() [][]moon.OccultationPathPoint {
return [][]moon.OccultationPathPoint{{
reviewPoint(base, 10*time.Minute, 110, 10),
reviewPoint(base, 20*time.Minute, 120, 10),
}}
}
tests := []struct {
name string
mutate func([][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint
}{
{name: "nan longitude", mutate: func(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
contours[0][1].Longitude = math.NaN()
return contours
}},
{name: "duplicate times", mutate: func(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
contours[0][1].Time = contours[0][0].Time
return contours
}},
{name: "single point", mutate: func([][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
return [][]moon.OccultationPathPoint{{reviewPoint(base, 10*time.Minute, 110, 10)}}
}},
{name: "empty contour", mutate: func([][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
return [][]moon.OccultationPathPoint{{}}
}},
{name: "outside event time", mutate: func(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
contours[0][0].Time = base.Add(-time.Minute)
return contours
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := sampleStarOccultationPath()
path.BandContours = test.mutate(valid())
if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}); !errors.Is(err, ErrInvalidStarOccultationPath) {
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
}
})
}
}
func TestStarOccultationPathSVGAcceptsValidBandContours(t *testing.T) {
path := sampleStarOccultationPath()
base := path.Start.Time
path.BandContours = [][]moon.OccultationPathPoint{{
reviewPoint(base, 10*time.Minute, 110, 10),
reviewPoint(base, 20*time.Minute, 120, 12),
reviewPoint(base, 30*time.Minute, 130, 14),
}}
path.VisibilityContours = [][]moon.OccultationPathPoint{{
reviewPoint(base, 12*time.Minute, 112, 11),
reviewPoint(base, 24*time.Minute, 124, 13),
}}
if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}); err != nil {
t.Fatalf("StarOccultationPathSVG() rejected valid contours: %v", err)
}
}
func TestPlanetOccultationPathSVGRejectsMalformedContours(t *testing.T) {
tests := []struct {
name string
mutate func(*moon.PlanetOccultationPath)
}{
{name: "partial band contour time order", mutate: func(path *moon.PlanetOccultationPath) {
base := path.Start.Time
path.PartialBandContours = [][]moon.OccultationPathPoint{{
reviewPoint(base, 20*time.Minute, 120, 10),
reviewPoint(base, 10*time.Minute, 110, 10),
}}
}},
{name: "partial visibility contour nan", mutate: func(path *moon.PlanetOccultationPath) {
base := path.Start.Time
path.PartialVisibilityContours = [][]moon.OccultationPathPoint{{
reviewPoint(base, 10*time.Minute, 110, 10),
reviewPoint(base, 20*time.Minute, 120, math.NaN()),
}}
}},
{name: "total band contour outside total interval", mutate: func(path *moon.PlanetOccultationPath) {
base := path.TotalStart.Time
path.TotalBandContours = [][]moon.OccultationPathPoint{{
reviewPoint(base, -time.Minute, 100, 8),
reviewPoint(base, 10*time.Minute, 110, 8),
}}
}},
{name: "total visibility contour point count", mutate: func(path *moon.PlanetOccultationPath) {
path.TotalVisibilityContours = [][]moon.OccultationPathPoint{
{reviewPoint(path.TotalStart.Time, time.Minute, 100, 8)},
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := samplePlanetOccultationPath()
test.mutate(&path)
if _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}); !errors.Is(err, ErrInvalidPlanetOccultationPath) {
t.Fatalf("PlanetOccultationPathSVG() error = %v, want ErrInvalidPlanetOccultationPath", err)
}
})
}
}
func TestPlanetOccultationPathSVGAcceptsValidContours(t *testing.T) {
path := samplePlanetOccultationPath()
start, totalStart := path.Start.Time, path.TotalStart.Time
path.PartialBandContours = [][]moon.OccultationPathPoint{{
reviewPoint(start, 10*time.Minute, 110, 10),
reviewPoint(start, 20*time.Minute, 120, 12),
}}
path.PartialVisibilityContours = [][]moon.OccultationPathPoint{{
reviewPoint(start, 15*time.Minute, 115, 10),
reviewPoint(start, 25*time.Minute, 125, 12),
}}
path.TotalBandContours = [][]moon.OccultationPathPoint{{
reviewPoint(totalStart, 5*time.Minute, 105, 8),
reviewPoint(totalStart, 15*time.Minute, 115, 8),
}}
path.TotalVisibilityContours = [][]moon.OccultationPathPoint{{
reviewPoint(totalStart, 6*time.Minute, 106, 8),
reviewPoint(totalStart, 16*time.Minute, 116, 8),
}}
if _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{}); err != nil {
t.Fatalf("PlanetOccultationPathSVG() rejected valid contours: %v", err)
}
}
func TestStarOccultationPathSVGReportsBandGeometryError(t *testing.T) {
path := sampleStarOccultationPath()
path.RiseSetCurves = []moon.OccultationRiseSetCurve{sampleOccultationRiseSetCurve(path.Start.Time)}
path.BandFootprints = []moon.OccultationFootprint{reviewFootprint(path.Greatest.Time)}
path.BandContours = [][]moon.OccultationPathPoint{{
reviewPoint(path.Start.Time, 10*time.Minute, 110, 0),
reviewPoint(path.Start.Time, 10*time.Minute+time.Second, -80, 0),
}}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if err == nil {
t.Fatal("StarOccultationPathSVG() accepted a band geometry failure")
}
if errors.Is(err, ErrInvalidStarOccultationPath) {
t.Fatalf("geometry engine failure = %v, must not be reported as invalid path data", err)
}
if !strings.Contains(err.Error(), "band geometry") {
t.Fatalf("geometry engine failure = %v, want a render-stage context", err)
}
if diagram != "" {
t.Fatal("stellar renderer returned a diagram after a band geometry failure")
}
}
func TestPlanetOccultationPathSVGReportsBandGeometryError(t *testing.T) {
path := samplePlanetOccultationPath()
totalStart := path.TotalStart.Time
path.TotalRiseSetCurves = []moon.OccultationRiseSetCurve{
reviewRiseSetCurve(totalStart.Add(8*time.Minute), totalStart.Add(18*time.Minute)),
}
path.TotalBandFootprints = []moon.OccultationFootprint{reviewFootprint(totalStart.Add(10 * time.Minute))}
path.TotalBandContours = [][]moon.OccultationPathPoint{{
reviewPoint(totalStart, time.Minute, 100, 0),
reviewPoint(totalStart, time.Minute+time.Second, -80, 0),
}}
diagram, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{})
if err == nil {
t.Fatal("PlanetOccultationPathSVG() accepted a band geometry failure")
}
if errors.Is(err, ErrInvalidPlanetOccultationPath) {
t.Fatalf("geometry engine failure = %v, must not be reported as invalid path data", err)
}
if !strings.Contains(err.Error(), "band geometry") {
t.Fatalf("geometry engine failure = %v, want a render-stage context", err)
}
if diagram != "" {
t.Fatal("planetary renderer returned a diagram after a band geometry failure")
}
}
func TestStarOccultationLegendOmitsBoundaryClaimForSweepFallback(t *testing.T) {
path := sampleStarOccultationPath()
path.BandFootprints = []moon.OccultationFootprint{
reviewFootprint(path.Start.Time.Add(time.Hour)),
reviewFootprint(path.Start.Time.Add(2 * time.Hour)),
}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if err != nil {
t.Fatalf("StarOccultationPathSVG: %v", err)
}
if strings.Contains(diagram, "掩带范围与边界") {
t.Fatal("non-authoritative sweep fallback still claims the analytic band boundary")
}
if !strings.Contains(diagram, "掩带范围") {
t.Fatal("non-authoritative sweep fallback is missing the band extent legend entry")
}
}
func TestStarOccultationLegendClaimsBoundaryForFootprintSweep(t *testing.T) {
path := starOccultationSimplificationPath(t)
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520})
if err != nil {
t.Fatalf("StarOccultationPathSVG: %v", err)
}
if !strings.Contains(diagram, "掩带范围与边界") {
t.Fatal("stellar SVG legend dropped the band-and-limits entry for the default footprint sweep")
}
}
func TestStarOccultationSVGDrawsHorizonConnectorsFromResolvedFootprints(t *testing.T) {
path := reviewHorizonConnectorPath()
connectors := occultationgeo.StarHorizonConnectorSegments(
path.Footprints, path.RiseSetCurves, path.NorthernLimit, path.SouthernLimit)
if len(connectors) == 0 {
t.Fatal("fixture does not yield a horizon connector for the drawn footprints")
}
if compact := occultationgeo.StarHorizonConnectorSegments(
path.BandFootprints, path.RiseSetCurves, path.NorthernLimit, path.SouthernLimit); len(compact) != 0 {
t.Fatalf("fixture unexpectedly yields %d compact-band connectors", len(compact))
}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if err != nil {
t.Fatalf("StarOccultationPathSVG: %v", err)
}
if !strings.Contains(diagram, `class="occultation-rise-set-boundary occultation-horizon-`) {
t.Fatal("stellar SVG dropped the horizon connectors derived from the drawn footprints")
}
}
func TestPlanetOccultationSVGDrawsTotalRiseSetCurves(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationMars,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute,
},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
if len(paths[0].TotalRiseSetCurves) == 0 {
t.Fatal("fixture has no inner-contact rise/set curves")
}
diagram, err := PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{
Width: 1200, Height: 800, Projection: MapProjectionEquirectangular,
})
if err != nil {
t.Fatalf("PlanetOccultationPathSVG: %v", err)
}
for _, want := range []string{
`class="occultation-total-rise-set-boundary occultation-total-start-rise"`,
`class="occultation-rise-set-boundary occultation-start-rise"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("planetary SVG is missing rise/set curve %s", want)
}
}
}
func reviewPoint(base time.Time, offset time.Duration, longitude, latitude float64) moon.OccultationPathPoint {
return moon.OccultationPathPoint{
Time: base.Add(offset),
Longitude: longitude,
Latitude: latitude,
MoonAltitude: 20,
}
}
func reviewFootprint(when time.Time) moon.OccultationFootprint {
return moon.OccultationFootprint{
Time: when,
Polygons: [][]moon.OccultationPathPoint{{
{Time: when, Longitude: -20, Latitude: -10},
{Time: when, Longitude: 5, Latitude: -10},
{Time: when, Longitude: 5, Latitude: 10},
{Time: when, Longitude: -20, Latitude: 10},
}},
}
}
func reviewRiseSetCurve(first, second time.Time) moon.OccultationRiseSetCurve {
return moon.OccultationRiseSetCurve{
Phase: moon.RiseSetPhaseStart,
Direction: moon.RiseSetDirectionRise,
Segments: [][]moon.OccultationPathPoint{{
{Time: first, Longitude: 100, Latitude: 8, MoonAltitude: 0},
{Time: second, Longitude: 110, Latitude: 8, MoonAltitude: 0},
}},
}
}
func reviewHorizonConnectorPath() moon.StarOccultationPath {
path := sampleStarOccultationPath()
start := path.Start.Time
curvePoint := func(offset time.Duration, longitude, latitude float64) moon.OccultationPathPoint {
return moon.OccultationPathPoint{
Time: start.Add(offset), Longitude: longitude, Latitude: latitude, MoonAltitude: 0,
}
}
path.RiseSetCurves = []moon.OccultationRiseSetCurve{
{
Phase: moon.RiseSetPhaseStart, Direction: moon.RiseSetDirectionRise,
Segments: [][]moon.OccultationPathPoint{{
curvePoint(20*time.Minute, 169, 25), curvePoint(21*time.Minute, 170, 25),
}},
},
{
Phase: moon.RiseSetPhaseEnd, Direction: moon.RiseSetDirectionRise,
Segments: [][]moon.OccultationPathPoint{{
curvePoint(21*time.Minute+30*time.Second, 171, 25), curvePoint(22*time.Minute, 172, 25),
}},
},
}
footprint := func(offset time.Duration, longitude, latitude float64) moon.OccultationFootprint {
when := start.Add(offset)
return moon.OccultationFootprint{
Time: when,
Polygons: [][]moon.OccultationPathPoint{{
{Time: when, Longitude: longitude - 0.5, Latitude: latitude - 0.5},
{Time: when, Longitude: longitude + 0.5, Latitude: latitude - 0.5},
{Time: when, Longitude: longitude, Latitude: latitude + 0.5},
}},
Boundaries: [][]moon.OccultationPathPoint{{
{Time: when, Longitude: longitude, Latitude: latitude},
{Time: when, Longitude: longitude + 0.05, Latitude: latitude + 0.05},
{Time: when, Longitude: longitude + 0.1, Latitude: latitude + 0.1},
}},
}
}
path.Footprints = []moon.OccultationFootprint{
footprint(21*time.Minute, 170, 25),
footprint(21*time.Minute+30*time.Second, 171, 25),
}
path.BandFootprints = nil
return path
}
+180
View File
@@ -0,0 +1,180 @@
package svg
import (
"strconv"
"strings"
"b612.me/astro/internal/geodata"
"b612.me/astro/moon"
)
// occultationScreenPoint 是投影到 SVG 画布后的像素坐标。
type occultationScreenPoint struct {
x float64
y float64
}
// occultationGeometryTolerancePixels 是掩带几何写出前的折线简化容差(像素);0 表示不简化。
var occultationGeometryTolerancePixels = 0.4
// projectOccultationRing 投影并简化一个地理环。
func (layout starOccultationSVGLayout) projectOccultationRing(points []starOccultationGeoPoint) []occultationScreenPoint {
screen := make([]occultationScreenPoint, len(points))
for index, point := range points {
x, y := layout.project(point.longitude, point.latitude)
screen[index] = occultationScreenPoint{x: x, y: y}
}
return simplifyOccultationRing(screen, occultationGeometryTolerancePixels)
}
// projectOccultationPolyline 投影并简化一条地理折线。
func (layout starOccultationSVGLayout) projectOccultationPolyline(points []starOccultationGeoPoint) []occultationScreenPoint {
screen := make([]occultationScreenPoint, len(points))
for index, point := range points {
x, y := layout.project(point.longitude, point.latitude)
screen[index] = occultationScreenPoint{x: x, y: y}
}
return simplifyOccultationPolyline(screen, occultationGeometryTolerancePixels)
}
// projectOccultationPathLine 投影并简化一条月掩路径折线。
func (layout starOccultationSVGLayout) projectOccultationPathLine(points []moon.OccultationPathPoint) []occultationScreenPoint {
screen := make([]occultationScreenPoint, len(points))
for index, point := range points {
x, y := layout.project(point.Longitude, point.Latitude)
screen[index] = occultationScreenPoint{x: x, y: y}
}
return simplifyOccultationPolyline(screen, occultationGeometryTolerancePixels)
}
// projectOccultationGeoRing 投影并简化一条地图多边形环。
func (layout starOccultationSVGLayout) projectOccultationGeoRing(points []geodata.GeoPoint) []occultationScreenPoint {
screen := make([]occultationScreenPoint, len(points))
for index, point := range points {
x, y := layout.project(point.Longitude, point.Latitude)
screen[index] = occultationScreenPoint{x: x, y: y}
}
return simplifyOccultationRing(screen, occultationGeometryTolerancePixels)
}
// simplifyOccultationRing 简化闭合环:首点与离首点最远的顶点保留为两个锚点,两段各自简化。
// 简化后不足三个顶点时返回原环,保证结果仍可填充。
func simplifyOccultationRing(points []occultationScreenPoint, tolerance float64) []occultationScreenPoint {
if len(points) < 4 || !(tolerance > 0) {
return points
}
ring := points
closed := points[0] == points[len(points)-1]
if closed {
ring = points[:len(points)-1]
}
if len(ring) < 3 {
return points
}
anchor := 1
farthest := -1.0
for index := 1; index < len(ring); index++ {
distance := occultationSquaredDistance(ring[0], ring[index])
if distance > farthest {
farthest, anchor = distance, index
}
}
first := simplifyOccultationPolyline(ring[:anchor+1], tolerance)
second := make([]occultationScreenPoint, len(ring)-anchor+1)
copy(second, ring[anchor:])
second[len(second)-1] = ring[0]
second = simplifyOccultationPolyline(second, tolerance)
result := make([]occultationScreenPoint, 0, len(first)+len(second)-2)
result = append(result, first[:len(first)-1]...)
result = append(result, second[:len(second)-1]...)
if len(result) < 3 {
return points
}
if closed {
result = append(result, result[0])
}
return result
}
// simplifyOccultationPolyline 用 Douglas-Peucker 简化折线,端点恒保留。
func simplifyOccultationPolyline(points []occultationScreenPoint, tolerance float64) []occultationScreenPoint {
if len(points) < 3 || !(tolerance > 0) {
return points
}
keep := make([]bool, len(points))
keep[0], keep[len(points)-1] = true, true
toleranceSquared := tolerance * tolerance
type span struct{ first, last int }
stack := make([]span, 0, 16)
stack = append(stack, span{first: 0, last: len(points) - 1})
for len(stack) > 0 {
current := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if current.last <= current.first+1 {
continue
}
index, distance := occultationFarthestVertex(points, current.first, current.last)
if distance <= toleranceSquared {
continue
}
keep[index] = true
stack = append(stack, span{first: current.first, last: index}, span{first: index, last: current.last})
}
result := make([]occultationScreenPoint, 0, len(points))
for index, point := range points {
if keep[index] {
result = append(result, point)
}
}
return result
}
// occultationFarthestVertex 返回首末顶点之间离弦最远的顶点及其平方距离。
func occultationFarthestVertex(points []occultationScreenPoint, first, last int) (int, float64) {
best, bestDistance := first, -1.0
start, end := points[first], points[last]
for index := first + 1; index < last; index++ {
distance := occultationChordSquaredDistance(points[index], start, end)
if distance > bestDistance {
best, bestDistance = index, distance
}
}
return best, bestDistance
}
// occultationChordSquaredDistance 返回点到线段的最短平方距离;线段退化时退化为点距。
func occultationChordSquaredDistance(point, start, end occultationScreenPoint) float64 {
dx, dy := end.x-start.x, end.y-start.y
lengthSquared := dx*dx + dy*dy
if lengthSquared <= 0 {
return occultationSquaredDistance(point, start)
}
position := ((point.x-start.x)*dx + (point.y-start.y)*dy) / lengthSquared
if position < 0 {
position = 0
} else if position > 1 {
position = 1
}
return occultationSquaredDistance(point, occultationScreenPoint{x: start.x + position*dx, y: start.y + position*dy})
}
func occultationSquaredDistance(first, second occultationScreenPoint) float64 {
dx, dy := first.x-second.x, first.y-second.y
return dx*dx + dy*dy
}
// writeOccultationPathCommands 写出 `M x y L x y ...` 命令序列,坐标保留三位小数。
func writeOccultationPathCommands(b *strings.Builder, points []occultationScreenPoint) {
var xBuffer, yBuffer [32]byte
for index, point := range points {
if index == 0 {
b.WriteString("M ")
} else {
b.WriteString("L ")
}
b.Write(strconv.AppendFloat(xBuffer[:0], point.x, 'f', 3, 64))
b.WriteByte(' ')
b.Write(strconv.AppendFloat(yBuffer[:0], point.y, 'f', 3, 64))
b.WriteByte(' ')
}
}
+178
View File
@@ -0,0 +1,178 @@
package svg
import (
"testing"
"time"
"b612.me/astro/moon"
)
func BenchmarkStarOccultationSVGHR4799(b *testing.B) {
path := starOccultationSimplificationPath(b)
for _, tolerance := range []float64{0, occultationGeometryTolerancePixels} {
b.Run(occultationBenchmarkToleranceName(tolerance), func(b *testing.B) {
occultationBenchmarkRender(b, tolerance, func() (string, error) {
return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520})
}, "occultation-band")
})
}
}
func BenchmarkPlanetOccultationSVGSaturn20240725(b *testing.B) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
for _, tolerance := range []float64{0, occultationGeometryTolerancePixels} {
b.Run(occultationBenchmarkToleranceName(tolerance), func(b *testing.B) {
occultationBenchmarkRender(b, tolerance, func() (string, error) {
return PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800})
}, "occultation-band")
})
}
}
func occultationBenchmarkToleranceName(tolerance float64) string {
if tolerance <= 0 {
return "unsimplified"
}
return "simplified"
}
func occultationBenchmarkRender(
b *testing.B,
tolerance float64,
render func() (string, error),
className string,
) {
b.Helper()
previous := occultationGeometryTolerancePixels
occultationGeometryTolerancePixels = tolerance
defer func() { occultationGeometryTolerancePixels = previous }()
diagram, err := render()
if err != nil {
b.Fatalf("render: %v", err)
}
b.ReportMetric(float64(len(diagram)), "doc-bytes")
b.ReportMetric(float64(occultationPathVertexCount(planetOccultationSVGPathData(b, diagram, className))), "band-vertices")
b.ResetTimer()
for attempt := 0; attempt < b.N; attempt++ {
if _, err := render(); err != nil {
b.Fatalf("render: %v", err)
}
}
}
// BenchmarkStarOccultationSimplificationSpeedup 在同一次基准里测原始与简化两档容差,
// 把渲染耗时对比从单元测试移到基准,并直接报告加速比。
func BenchmarkStarOccultationSimplificationSpeedup(b *testing.B) {
path := starOccultationSimplificationPath(b)
render := func() (string, error) {
return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520})
}
raw := occultationBenchmarkDuration(b, 0, render)
simplified := occultationBenchmarkDuration(b, occultationGeometryTolerancePixels, render)
b.ReportMetric(float64(raw.Nanoseconds()), "raw-ns/op")
b.ReportMetric(float64(simplified.Nanoseconds()), "simplified-ns/op")
b.ReportMetric(float64(raw)/float64(simplified), "speedup")
}
// BenchmarkPlanetOccultationSimplificationSpeedup 是行星路径的同一对比。
func BenchmarkPlanetOccultationSimplificationSpeedup(b *testing.B) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
render := func() (string, error) {
return PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800})
}
raw := occultationBenchmarkDuration(b, 0, render)
simplified := occultationBenchmarkDuration(b, occultationGeometryTolerancePixels, render)
b.ReportMetric(float64(raw.Nanoseconds()), "raw-ns/op")
b.ReportMetric(float64(simplified.Nanoseconds()), "simplified-ns/op")
b.ReportMetric(float64(raw)/float64(simplified), "speedup")
}
func occultationBenchmarkDuration(b *testing.B, tolerance float64, render func() (string, error)) time.Duration {
b.Helper()
previous := occultationGeometryTolerancePixels
occultationGeometryTolerancePixels = tolerance
defer func() { occultationGeometryTolerancePixels = previous }()
begin := time.Now()
for attempt := 0; attempt < b.N; attempt++ {
if _, err := render(); err != nil {
b.Fatalf("render: %v", err)
}
}
return time.Since(begin) / time.Duration(b.N)
}
// BenchmarkPlanetOccultationSVGFootprintBands 对比 DisableFootprints 两档:
// 逐条绘制密集瞬时足迹 vs 绘制紧凑带的合并结果(命中只读缓存)。
func BenchmarkPlanetOccultationSVGFootprintBands(b *testing.B) {
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
for _, disable := range []bool{false, true} {
name := "dense-footprints"
if disable {
name = "compact-band"
}
b.Run(name, func(b *testing.B) {
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: disable},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
render := func() (string, error) {
return PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800})
}
if _, err := render(); err != nil {
b.Fatalf("warm-up render: %v", err)
}
b.ResetTimer()
for attempt := 0; attempt < b.N; attempt++ {
if _, err := render(); err != nil {
b.Fatalf("render: %v", err)
}
}
})
}
}
// BenchmarkPlanetOccultationSVGCompactBandCold 是紧凑带合并缓存未命中时的成本,
// 用于说明首次渲染仍要付出一次合并代价。
func BenchmarkPlanetOccultationSVGCompactBandCold(b *testing.B) {
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true},
)
if err != nil || len(paths) != 1 {
b.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
b.ResetTimer()
for attempt := 0; attempt < b.N; attempt++ {
occultationBandCache.Lock()
occultationBandCache.entries = make(map[occultationBandCacheKey]occultationBandPolygons, occultationBandCacheCapacity)
occultationBandCache.order = nil
occultationBandCache.Unlock()
if _, err := PlanetOccultationPathSVG(path, PlanetOccultationSVGOptions{Width: 1200, Height: 800}); err != nil {
b.Fatalf("render: %v", err)
}
}
}
+300
View File
@@ -0,0 +1,300 @@
package svg
import (
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/moon"
)
// 本节钉住写出前折线简化的契约:简化几何与原几何逐点偏差不超过容差,环数不变,
// 文档字节与顶点数显著下降;渲染速度只由同文件的基准度量,单元测试不做墙钟断言。
func TestSimplifyOccultationPolylineCollapsesCollinearRun(t *testing.T) {
points := make([]occultationScreenPoint, 0, 101)
for index := 0; index <= 100; index++ {
points = append(points, occultationScreenPoint{x: float64(index), y: 0})
}
simplified := simplifyOccultationPolyline(points, 0.4)
if len(simplified) != 2 {
t.Fatalf("collinear run simplified to %d points, want 2", len(simplified))
}
if simplified[0] != points[0] || simplified[1] != points[len(points)-1] {
t.Fatal("polyline simplification dropped an endpoint")
}
}
func TestSimplifyOccultationRingKeepsClosureAndAnchors(t *testing.T) {
ring := make([]occultationScreenPoint, 0, 73)
for index := 0; index < 72; index++ {
angle := 2 * math.Pi * float64(index) / 72
ring = append(ring, occultationScreenPoint{x: 200 + 80*math.Cos(angle), y: 200 + 80*math.Sin(angle)})
}
for _, input := range [][]occultationScreenPoint{ring, append(append([]occultationScreenPoint(nil), ring...), ring[0])} {
simplified := simplifyOccultationRing(input, 0.4)
if len(simplified) >= len(input) {
t.Fatalf("circle simplification kept %d of %d vertices", len(simplified), len(input))
}
if simplified[0] != input[0] {
t.Fatal("ring simplification dropped the first vertex")
}
if closed := input[0] == input[len(input)-1]; closed != (simplified[0] == simplified[len(simplified)-1]) {
t.Fatalf("ring closure changed: input closed=%v output closed=%v", closed, simplified[0] == simplified[len(simplified)-1])
}
if deviation := occultationRingMaxDeviation([][]occultationScreenPoint{input}, [][]occultationScreenPoint{simplified}); deviation > 0.4+1e-9 {
t.Fatalf("circle simplification deviation = %.6f px, want <= 0.4", deviation)
}
}
}
func TestSimplifyOccultationRingKeepsDegenerateRing(t *testing.T) {
ring := []occultationScreenPoint{{x: 10, y: 10}, {x: 10.05, y: 10}, {x: 10, y: 10.05}}
simplified := simplifyOccultationRing(ring, 0.4)
if len(simplified) != len(ring) {
t.Fatalf("sub-tolerance ring simplified to %d points, want %d", len(simplified), len(ring))
}
}
func TestStarOccultationBandSimplificationWithinTolerance(t *testing.T) {
path := starOccultationSimplificationPath(t)
raw := occultationRenderWithTolerance(t, path, 0)
simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels)
rawRings := occultationSVGPathRings(t, raw, "occultation-band")
rings := occultationSVGPathRings(t, simplified, "occultation-band")
if len(rawRings) == 0 {
t.Fatal("stellar reference document has no occultation-band rings")
}
if len(rawRings) != len(rings) {
t.Fatalf("band ring count changed from %d to %d", len(rawRings), len(rings))
}
deviation := occultationRingMaxDeviation(rawRings, rings)
if deviation > occultationGeometryTolerancePixels+0.01 {
t.Fatalf("band deviation = %.4f px, tolerance = %.4f px", deviation, occultationGeometryTolerancePixels)
}
}
func TestPlanetOccultationBandSimplificationWithinTolerance(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
render := func(tolerance float64) string {
t.Helper()
previous := occultationGeometryTolerancePixels
occultationGeometryTolerancePixels = tolerance
defer func() { occultationGeometryTolerancePixels = previous }()
return occultationRender(t, func() (string, error) {
return PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{Width: 1200, Height: 800})
})
}
raw := render(0)
simplified := render(occultationGeometryTolerancePixels)
rawVertices, simplifiedVertices := 0, 0
for _, className := range []string{"occultation-band", "total-occultation-band"} {
rawRings := occultationSVGPathRings(t, raw, className)
rings := occultationSVGPathRings(t, simplified, className)
if len(rawRings) == 0 || len(rawRings) != len(rings) {
t.Fatalf("%s ring count changed from %d to %d", className, len(rawRings), len(rings))
}
if deviation := occultationRingMaxDeviation(rawRings, rings); deviation > occultationGeometryTolerancePixels+0.01 {
t.Fatalf("%s deviation = %.4f px, tolerance = %.4f px", className, deviation, occultationGeometryTolerancePixels)
}
rawVertices += occultationPathVertexCount(planetOccultationSVGPathData(t, raw, className))
simplifiedVertices += occultationPathVertexCount(planetOccultationSVGPathData(t, simplified, className))
}
if len(simplified)*4 > len(raw)*3 {
t.Fatalf("planetary document bytes %d -> %d, want at least a 25%% reduction", len(raw), len(simplified))
}
if simplifiedVertices*100 > rawVertices*40 {
t.Fatalf("planetary band vertices %d -> %d, want at least a 60%% reduction", rawVertices, simplifiedVertices)
}
t.Logf("planetary document %d -> %d bytes, band vertices %d -> %d",
len(raw), len(simplified), rawVertices, simplifiedVertices)
}
func TestStarOccultationSVGSimplificationShrinksOutput(t *testing.T) {
path := starOccultationSimplificationPath(t)
raw := occultationRenderWithTolerance(t, path, 0)
simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels)
rawVertices := occultationBandVertexCount(t, raw)
simplifiedVertices := occultationBandVertexCount(t, simplified)
if rawVertices == 0 || simplifiedVertices == 0 {
t.Fatalf("band vertex count raw=%d simplified=%d", rawVertices, simplifiedVertices)
}
if len(simplified)*4 > len(raw)*3 {
t.Fatalf("document bytes %d -> %d, want at least a 25%% reduction", len(raw), len(simplified))
}
if simplifiedVertices*100 > rawVertices*35 {
t.Fatalf("band vertices %d -> %d, want at least a 65%% reduction", rawVertices, simplifiedVertices)
}
t.Logf("document %d -> %d bytes, band vertices %d -> %d",
len(raw), len(simplified), rawVertices, simplifiedVertices)
}
func TestStarOccultationBandSimplificationPreservesRingTopology(t *testing.T) {
path := starOccultationSimplificationPath(t)
raw := occultationRenderWithTolerance(t, path, 0)
simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels)
rawRings := occultationSVGPathRings(t, raw, "occultation-band")
rings := occultationSVGPathRings(t, simplified, "occultation-band")
if len(rawRings) != len(rings) {
t.Fatalf("band ring count %d -> %d, want unchanged", len(rawRings), len(rings))
}
for index := range rawRings {
if len(rings[index]) < 3 {
t.Fatalf("ring %d has %d vertices after simplification, want at least 3", index, len(rings[index]))
}
if len(rings[index]) > len(rawRings[index]) {
t.Fatalf("ring %d grew from %d to %d vertices", index, len(rawRings[index]), len(rings[index]))
}
if math.Signbit(occultationRingSignedArea(rawRings[index])) != math.Signbit(occultationRingSignedArea(rings[index])) {
t.Fatalf("ring %d winding changed by simplification", index)
}
for _, point := range rings[index] {
if !occultationRingContainsVertex(rawRings[index], point) {
t.Fatalf("ring %d vertex %.3f,%.3f is not an original vertex", index, point.x, point.y)
}
}
}
}
func occultationRingSignedArea(ring []occultationScreenPoint) float64 {
area := 0.0
for index, point := range ring {
next := ring[(index+1)%len(ring)]
area += point.x*next.y - next.x*point.y
}
return area / 2
}
func occultationRingContainsVertex(ring []occultationScreenPoint, target occultationScreenPoint) bool {
for _, point := range ring {
if point == target {
return true
}
}
return false
}
func starOccultationSimplificationPath(t testing.TB) moon.StarOccultationPath {
t.Helper()
location := time.FixedZone("CST", 8*3600)
paths, err := moon.FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799StarCoordinate(),
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
return paths[0]
}
func occultationRenderWithTolerance(
t testing.TB,
path moon.StarOccultationPath,
tolerance float64,
) string {
t.Helper()
previous := occultationGeometryTolerancePixels
occultationGeometryTolerancePixels = tolerance
defer func() { occultationGeometryTolerancePixels = previous }()
return occultationRender(t, func() (string, error) {
return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520})
})
}
func occultationRender(t testing.TB, render func() (string, error)) string {
t.Helper()
diagram, err := render()
if err != nil {
t.Fatalf("occultation render: %v", err)
}
return diagram
}
func occultationBandVertexCount(t testing.TB, diagram string) int {
t.Helper()
return occultationPathVertexCount(planetOccultationSVGPathData(t, diagram, "occultation-band"))
}
func occultationPathVertexCount(pathData string) int {
count := 0
for _, field := range strings.Fields(pathData) {
if field == "M" || field == "L" {
count++
}
}
return count
}
func occultationSVGPathRings(t testing.TB, diagram, className string) [][]occultationScreenPoint {
t.Helper()
fields := strings.Fields(planetOccultationSVGPathData(t, diagram, className))
rings := make([][]occultationScreenPoint, 0, 8)
for index := 0; index < len(fields); {
switch fields[index] {
case "M":
rings = append(rings, nil)
fallthrough
case "L":
if index+2 >= len(fields) {
t.Fatalf("truncated path data in %q", className)
}
x, xErr := strconv.ParseFloat(fields[index+1], 64)
y, yErr := strconv.ParseFloat(fields[index+2], 64)
if xErr != nil || yErr != nil {
t.Fatalf("parse %q coordinate %q/%q: %v %v", className, fields[index+1], fields[index+2], xErr, yErr)
}
rings[len(rings)-1] = append(rings[len(rings)-1], occultationScreenPoint{x: x, y: y})
index += 3
case "Z":
index++
default:
t.Fatalf("unexpected path command %q in %q", fields[index], className)
}
}
return rings
}
func occultationRingMaxDeviation(raw, simplified [][]occultationScreenPoint) float64 {
maximum := 0.0
for index := range raw {
for _, point := range raw[index] {
maximum = math.Max(maximum, occultationRingDeviation(point, simplified[index]))
}
}
return maximum
}
func occultationRingDeviation(point occultationScreenPoint, ring []occultationScreenPoint) float64 {
minimum := math.Inf(1)
for index := range ring {
minimum = math.Min(minimum, occultationSegmentDistance(point, ring[index], ring[(index+1)%len(ring)]))
}
return minimum
}
func occultationSegmentDistance(point, start, end occultationScreenPoint) float64 {
dx, dy := end.x-start.x, end.y-start.y
lengthSquared := dx*dx + dy*dy
position := 0.0
if lengthSquared > 0 {
position = ((point.x-start.x)*dx + (point.y-start.y)*dy) / lengthSquared
}
if position < 0 {
position = 0
} else if position > 1 {
position = 1
}
return math.Hypot(point.x-(start.x+position*dx), point.y-(start.y+position*dy))
}
+400
View File
@@ -0,0 +1,400 @@
package svg
import (
"encoding/xml"
"io"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"b612.me/astro/moon"
)
// 本文件提供解析 SVG 产物、按保守字宽估算文本占位的测试工具。
type occultationSVGText struct {
class string
parent string
parentClass string
value string
x, y float64
fontSize float64
anchor string
}
func occultationSVGTexts(t *testing.T, diagram string) []occultationSVGText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
stack := []string{}
classes := []string{}
texts := []occultationSVGText{}
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode SVG: %v", err)
}
switch element := token.(type) {
case xml.StartElement:
stack = append(stack, element.Name.Local)
class := ""
for _, attribute := range element.Attr {
if attribute.Name.Local == "class" {
class = attribute.Value
}
}
classes = append(classes, class)
if element.Name.Local != "text" {
continue
}
text := occultationSVGText{class: class, fontSize: 10}
if len(classes) >= 2 {
text.parent = stack[len(stack)-2]
text.parentClass = classes[len(classes)-2]
}
for _, attribute := range element.Attr {
switch attribute.Name.Local {
case "x":
text.x, _ = strconv.ParseFloat(attribute.Value, 64)
case "y":
text.y, _ = strconv.ParseFloat(attribute.Value, 64)
case "font-size":
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "text-anchor":
text.anchor = attribute.Value
}
}
texts = append(texts, text)
case xml.EndElement:
if len(stack) == 0 {
t.Fatal("unbalanced SVG element")
}
stack = stack[:len(stack)-1]
classes = classes[:len(classes)-1]
case xml.CharData:
if len(stack) == 0 || stack[len(stack)-1] != "text" || len(texts) == 0 {
continue
}
texts[len(texts)-1].value += string(element)
}
}
return texts
}
// occultationSVGTextBox 用偏大的字宽估算占位,避免真实字体比测试度量更宽时漏判重叠。
func occultationSVGTextBox(text occultationSVGText) [4]float64 {
fontSize := text.fontSize
if fontSize <= 0 {
fontSize = 10
}
units := 0.0
for _, current := range text.value {
if current < 0x80 {
units += 0.62
continue
}
units++
units += 0.05
}
width := units * fontSize
left := text.x
switch text.anchor {
case "middle":
left = text.x - width/2
case "end":
left = text.x - width
}
return [4]float64{left, text.y - fontSize*0.85, left + width, text.y + fontSize*0.3}
}
func occultationSVGTextsOutsideCanvas(texts []occultationSVGText, width, height float64) []string {
outside := []string{}
for _, text := range texts {
box := occultationSVGTextBox(text)
if box[0] < 0 || box[2] > width || box[1] < 0 || box[3] > height {
outside = append(outside, text.value)
}
}
return outside
}
func occultationSVGTextOverlapPairs(texts []occultationSVGText) int {
pairs := 0
for first := 0; first < len(texts); first++ {
for second := first + 1; second < len(texts); second++ {
a, b := occultationSVGTextBox(texts[first]), occultationSVGTextBox(texts[second])
if a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] {
pairs++
}
}
}
return pairs
}
// occultationSVGLabelTexts 取三族地图标签:等时线标注、中心线时刻标注、事件标记。
func occultationSVGLabelTexts(texts []occultationSVGText) []occultationSVGText {
labels := []occultationSVGText{}
for _, text := range texts {
if strings.Contains(text.class, "greatest-time-isoline-label") ||
strings.Contains(text.parentClass, "occultation-time-marker") ||
strings.Contains(text.parentClass, "event-marker") {
labels = append(labels, text)
}
}
return labels
}
// occultationSVGDetailedPanelRows 解析详细版式数据块:返回"块标题 → 行标签 → 值"。
func occultationSVGDetailedPanelRows(t *testing.T, diagram string) map[string]map[string]string {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
panels := map[string]map[string]string{}
inPanel := false
texts := []occultationSVGText{}
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode SVG: %v", err)
}
switch element := token.(type) {
case xml.StartElement:
if element.Name.Local == "g" {
for _, attribute := range element.Attr {
if attribute.Name.Local == "class" && attribute.Value == "occultation-detailed-panel" {
inPanel, texts = true, nil
}
}
continue
}
if !inPanel || element.Name.Local != "text" {
continue
}
text := occultationSVGText{}
for _, attribute := range element.Attr {
if attribute.Name.Local == "text-anchor" {
text.anchor = attribute.Value
}
}
texts = append(texts, text)
case xml.EndElement:
if !inPanel || element.Name.Local != "g" {
continue
}
inPanel = false
rows := map[string]string{}
title := ""
for index := 0; index < len(texts); {
if texts[index].anchor == "end" {
index++
continue
}
if index+1 < len(texts) && texts[index+1].anchor == "end" {
rows[texts[index].value] = texts[index+1].value
index += 2
continue
}
if title == "" {
title = texts[index].value
}
index++
}
panels[title] = rows
case xml.CharData:
if inPanel && len(texts) > 0 {
texts[len(texts)-1].value += string(element)
}
}
}
return panels
}
func occultationSVGPanelValue(t *testing.T, panels map[string]map[string]string, title, label string) string {
t.Helper()
rows, ok := panels[title]
if !ok {
titles := make([]string, 0, len(panels))
for current := range panels {
titles = append(titles, current)
}
sort.Strings(titles)
t.Fatalf("detailed SVG is missing panel %q, have %v", title, titles)
}
value, ok := rows[label]
if !ok {
labels := make([]string, 0, len(rows))
for current := range rows {
labels = append(labels, current)
}
sort.Strings(labels)
t.Fatalf("panel %q is missing row %q, have %v", title, label, labels)
}
return value
}
// occultationSVGNumericPrefix 取 "69.2 s" / "-5.86°" 这类文本开头的数值。
func occultationSVGNumericPrefix(t *testing.T, value string) float64 {
t.Helper()
trimmed := strings.TrimSpace(value)
end := 0
for end < len(trimmed) && (trimmed[end] == '+' || trimmed[end] == '-' || trimmed[end] == '.' ||
(trimmed[end] >= '0' && trimmed[end] <= '9')) {
end++
}
parsed, err := strconv.ParseFloat(trimmed[:end], 64)
if err != nil {
t.Fatalf("parse number from %q: %v", value, err)
}
return parsed
}
// occultationSVGSexagesimal 解析 "±DD°MM'SS.S\"" 与 "HHhMMmSS.Ss" 两种六十进制文本,返回度。
func occultationSVGSexagesimal(t *testing.T, value string) float64 {
t.Helper()
fields := strings.FieldsFunc(value, func(current rune) bool {
return current != '.' && (current < '0' || current > '9')
})
if len(fields) != 3 {
t.Fatalf("parse sexagesimal %q: got %d fields", value, len(fields))
}
numbers := make([]float64, 3)
for index, field := range fields {
parsed, err := strconv.ParseFloat(field, 64)
if err != nil {
t.Fatalf("parse sexagesimal field %q of %q: %v", field, value, err)
}
numbers[index] = parsed
}
degrees := numbers[0] + numbers[1]/60 + numbers[2]/3600
if strings.HasSuffix(strings.TrimSpace(value), "s") {
degrees *= 15
}
if strings.HasPrefix(strings.TrimSpace(value), "-") {
degrees = -degrees
}
return degrees
}
// occultationSVGPanelRects 取详细版式数据块自身的矩形。
func occultationSVGPanelRects(t *testing.T, diagram string) [][4]float64 {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
rects := [][4]float64{}
inPanel := false
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode SVG: %v", err)
}
switch element := token.(type) {
case xml.StartElement:
if element.Name.Local == "g" {
inPanel = false
for _, attribute := range element.Attr {
if attribute.Name.Local == "class" && attribute.Value == "occultation-detailed-panel" {
inPanel = true
}
}
continue
}
if !inPanel || element.Name.Local != "rect" {
continue
}
rect := [4]float64{}
for _, attribute := range element.Attr {
value, parseErr := strconv.ParseFloat(attribute.Value, 64)
if parseErr != nil {
continue
}
switch attribute.Name.Local {
case "x":
rect[0] = value
case "y":
rect[1] = value
case "width":
rect[2] = value
case "height":
rect[3] = value
}
}
rects = append(rects, rect)
case xml.EndElement:
if element.Name.Local == "g" {
inPanel = false
}
}
}
return rects
}
var (
occultationTestStarOnce sync.Once
occultationTestStarEvent moon.StarOccultationPath
occultationTestSaturnOnce sync.Once
occultationTestSaturnData moon.PlanetOccultationPath
occultationTestMarsOnce sync.Once
occultationTestMarsData moon.PlanetOccultationPath
)
// occultationTestStarPath 取一个带等时线的真实恒星掩事件。
func occultationTestStarPath(t *testing.T) moon.StarOccultationPath {
t.Helper()
occultationTestStarOnce.Do(func() {
location := time.FixedZone("CST", 8*3600)
paths, err := moon.FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799StarCoordinate(),
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200, GreatestTimeStep: 30 * time.Minute},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
occultationTestStarEvent = paths[0]
})
return occultationTestStarEvent
}
// occultationTestSaturnPath 取一个关闭密集足迹、带等时线的真实行星掩事件。
func occultationTestSaturnPath(t *testing.T) moon.PlanetOccultationPath {
t.Helper()
occultationTestSaturnOnce.Do(func() {
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 2 * time.Minute, DisableFootprints: true, GreatestTimeStep: 30 * time.Minute})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
occultationTestSaturnData = paths[0]
})
return occultationTestSaturnData
}
// occultationTestMarsPath 取一个同时带偏掩带与全掩带的真实行星掩事件。
func occultationTestMarsPath(t *testing.T) moon.PlanetOccultationPath {
t.Helper()
occultationTestMarsOnce.Do(func() {
start := time.Date(2025, time.July, 29, 0, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
paths, err := moon.FindPlanetOccultationPaths(start, start.Add(24*time.Hour), moon.OccultationMars,
moon.OccultationPathOptions{
Step: 20 * time.Minute, TargetSpacingKM: 900, RiseSetStep: time.Minute,
DisableFootprints: true, IncludeFootprintTimeline: true,
FootprintTimelineStep: 5 * time.Minute, GreatestTimeStep: 30 * time.Minute,
})
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
occultationTestMarsData = paths[0]
})
return occultationTestMarsData
}
+304 -3
View File
@@ -3,12 +3,14 @@ package svg
import (
"encoding/xml"
"errors"
"fmt"
"io"
"math"
"strings"
"testing"
"time"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
@@ -33,18 +35,77 @@ func TestFindStarOccultationSVGsHR4799(t *testing.T) {
`<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"`,
`class="southern-limit"`, "掩带范围与边界",
`class="occultation-rise-set-boundary occultation-start-rise"`, `stroke="#d97706"`, "初掩/掩甚/终掩月升月落线",
`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 got := strings.Count(diagram, `class="occultation-band"`); got != 1 {
t.Fatalf("HR 4799 occultation-band sweep count = %d, want one compound path", got)
}
bandIndex := strings.Index(diagram, `class="occultation-band"`)
curveIndex := strings.Index(diagram, `class="occultation-rise-set-boundary occultation-start-rise"`)
if bandIndex < 0 || curveIndex < 0 || curveIndex < bandIndex {
t.Fatalf("stellar SVG draws rise/set curves before the filled band: band=%d curve=%d", bandIndex, curveIndex)
}
if err := validateXML(diagram); err != nil {
t.Fatalf("generated SVG is not valid XML: %v", err)
}
curveIndex = strings.Index(diagram, `class="occultation-rise-set-boundary occultation-start-rise"`)
curveEnd := -1
if curveIndex >= 0 {
curveEnd = strings.Index(diagram[curveIndex:], "/>")
}
if curveIndex < 0 || curveEnd < 0 || strings.Contains(diagram[curveIndex:curveIndex+curveEnd], "stroke-dasharray") {
t.Fatal("stellar rise/set phase line is rendered with a gap-producing dash pattern")
}
}
func TestStarOccultationAutoProjectionIncludesFootprints(t *testing.T) {
path := sampleStarOccultationPath()
path.Greatest.Latitude = 70
for index := range path.CenterLine {
path.CenterLine[index].Latitude = 65
path.NorthernLimit[index].Latitude = 70
path.SouthernLimit[index].Latitude = 60
}
path.Start.Latitude = 70
path.End.Latitude = 70
footprintTime := path.Start.Time.Add(90 * time.Minute)
path.Footprints = []moon.OccultationFootprint{{
Time: footprintTime,
Polygons: [][]moon.OccultationPathPoint{{
{Time: footprintTime, Longitude: -10, Latitude: -10},
{Time: footprintTime, Longitude: 10, Latitude: -10},
{Time: footprintTime, Longitude: 0, Latitude: 10},
}},
}}
if projection := resolveStarOccultationMapProjection(path, MapProjectionAuto); projection != svgmap.ProjectionEquirectangular {
t.Fatalf("auto projection = %q, want equirectangular for cross-hemisphere footprint", projection)
}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if err != nil {
t.Fatalf("StarOccultationPathSVG() error = %v", err)
}
if !strings.Contains(diagram, "等经纬投影") {
t.Fatal("auto-projected SVG clipped a cross-hemisphere footprint into a polar map")
}
}
func TestStarOccultationLegendOmitsDisabledRiseSetCurves(t *testing.T) {
path := sampleStarOccultationPath()
path.RiseSetCurves = nil
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if err != nil {
t.Fatalf("StarOccultationPathSVG: %v", err)
}
if strings.Contains(diagram, "初掩/掩甚/终掩月升月落线") || strings.Contains(diagram, "Rise/set phase lines") {
t.Fatal("stellar SVG legend claims disabled rise/set curves are present")
}
}
func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) {
@@ -52,6 +113,7 @@ func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) {
StarOccultationSVGOptions{Width: 900, Height: 760},
110,
svgmap.ProjectionNorthPolar,
svgmap.GeoPoint{},
)
legendY := layout.mapY + layout.mapHeight + 30
if gap := layout.footerY - legendY; gap < 24 {
@@ -59,6 +121,145 @@ func TestPolarOccultationLayoutSeparatesLegendAndFooter(t *testing.T) {
}
}
func TestPolarStarOccultationSVGSplitsGrazingBoundaryBranchChanges(t *testing.T) {
tests := []struct {
name string
date time.Time
star moon.StarCoordinate
projection MapProjection
}{
{
name: "Antares south polar",
date: time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC),
star: moon.StarCoordinate{
ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24,
},
projection: MapProjectionSouthPolar,
},
{
name: "Regulus north polar",
date: time.Date(2025, time.August, 23, 0, 0, 0, 0, time.UTC),
star: moon.StarCoordinate{
ID: "Regulus", RA: 152.09291666666667, Dec: 11.967222222222222,
Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000,
ProperMotionRACosDecMasPerYear: -248, ProperMotionDecMasPerYear: 6, ParallaxMas: 45,
},
projection: MapProjectionNorthPolar,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paths, err := moon.FindStarOccultationPaths(
test.date, test.date.Add(24*time.Hour), test.star,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
path := paths[0]
if len(path.Footprints) == 0 {
t.Fatal("stellar path has no instantaneous footprints")
}
if ranges := occultationgeo.ContinuousPairedBoundaryRanges(path.NorthernLimit, path.SouthernLimit); len(ranges) < 2 {
t.Fatalf("continuous band ranges = %d, want branch change to be split", len(ranges))
}
projection := internalMapProjection(test.projection)
for _, limit := range [][]moon.OccultationPathPoint{path.NorthernLimit, path.SouthernLimit} {
for _, segment := range starOccultationBoundarySegmentsForProjection(limit, svgmap.ClipView{Projection: projection}) {
for index := 1; index < len(segment); index++ {
if distance := occultationgeo.DistanceKM(segment[index-1], segment[index]); distance > occultationgeo.BoundaryBranchJumpKM+1e-6 {
t.Fatalf("rendered boundary segment still spans %.1f km branch change", distance)
}
}
}
}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{
Width: 1200, Height: 800, Location: time.UTC, Projection: test.projection,
})
if err != nil {
t.Fatalf("StarOccultationPathSVG() error = %v", err)
}
if strings.Count(diagram, `class="occultation-band"`) != 1 {
t.Fatal("instantaneous footprints were not rendered as one compound sweep")
}
for _, className := range []string{`class="northern-limit"`, `class="southern-limit"`} {
if !strings.Contains(diagram, className) {
t.Fatalf("footprint sweep SVG missing split boundary %s", className)
}
}
if err := validateXML(diagram); err != nil {
t.Fatalf("generated SVG is not valid XML: %v", err)
}
})
}
}
func TestStarOccultationPathSVGPreservesEndpointBranchFragments(t *testing.T) {
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
for _, count := range []int{2, 3} {
t.Run(fmt.Sprintf("%d points", count), func(t *testing.T) {
path := svgEndpointBranchJumpPath(start, count)
segments := starOccultationBoundarySegmentsForProjection(
path.NorthernLimit, svgmap.ClipView{Projection: svgmap.ProjectionEquirectangular},
)
if len(segments) != 2 {
t.Fatalf("north-limit segment count = %d, want two discontinuous fragments", len(segments))
}
if !segments[0][0].Time.Equal(path.Start.Time) ||
!segments[len(segments)-1][len(segments[len(segments)-1])-1].Time.Equal(path.End.Time) {
t.Fatal("split SVG boundary does not retain start and end samples")
}
for _, segment := range segments {
for index := 1; index < len(segment); index++ {
if distance := occultationgeo.DistanceKM(segment[index-1], segment[index]); distance > occultationgeo.BoundaryBranchJumpKM+1e-6 {
t.Fatalf("endpoint fragment spans an impossible %.1f km jump", distance)
}
}
}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{
Projection: MapProjectionEquirectangular,
})
if err != nil {
t.Fatalf("StarOccultationPathSVG: %v", err)
}
if got := strings.Count(diagram, `class="occultation-band"`); got != 2 {
t.Fatalf("endpoint-band section count = %d, want 2", got)
}
if got := strings.Count(diagram, `class="northern-limit"`); got != 2 {
t.Fatalf("north-limit path count = %d, want 2", got)
}
if err := validateXML(diagram); err != nil {
t.Fatalf("generated SVG is not valid XML: %v", err)
}
})
}
}
func svgEndpointBranchJumpPath(start time.Time, count int) moon.StarOccultationPath {
north := make([]moon.OccultationPathPoint, count)
south := make([]moon.OccultationPathPoint, count)
for index := range north {
when := start.Add(time.Duration(index) * time.Second)
longitude := 30.0 + float64(index)/10
if index == 0 {
longitude = 0
}
north[index] = moon.OccultationPathPoint{
Time: when, Longitude: longitude, Latitude: 10, MoonAltitude: 20,
}
south[index] = moon.OccultationPathPoint{
Time: when, Longitude: longitude, Latitude: -10, MoonAltitude: 20,
}
}
return moon.StarOccultationPath{
TargetID: "endpoint-jump", Start: north[0], Greatest: north[0], End: north[count-1],
Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Second,
}
}
func TestStarOccultationPathSVGEnglishAndCustomText(t *testing.T) {
path := sampleStarOccultationPath()
path.TargetID = "Alpha < Beta & Gamma"
@@ -273,6 +474,95 @@ func TestStarOccultationPathSVGRejectsInvalidPath(t *testing.T) {
}
}
func TestStarOccultationPathSVGRejectsMalformedRiseSetCurves(t *testing.T) {
tests := []struct {
name string
mutate func(*moon.OccultationRiseSetCurve)
}{
{name: "phase", mutate: func(curve *moon.OccultationRiseSetCurve) {
curve.Phase = moon.RiseSetPhase("bogus")
}},
{name: "direction", mutate: func(curve *moon.OccultationRiseSetCurve) {
curve.Direction = moon.RiseSetDirection("bogus")
}},
{name: "coordinate", mutate: func(curve *moon.OccultationRiseSetCurve) {
curve.Segments[0][1].Longitude = math.NaN()
}},
{name: "time order", mutate: func(curve *moon.OccultationRiseSetCurve) {
curve.Segments[0][1].Time = curve.Segments[0][0].Time
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := sampleStarOccultationPath()
curve := sampleOccultationRiseSetCurve(path.Start.Time)
test.mutate(&curve)
path.RiseSetCurves = []moon.OccultationRiseSetCurve{curve}
_, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidStarOccultationPath) {
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
}
})
}
}
func TestStarOccultationPathSVGRejectsMalformedFootprint(t *testing.T) {
path := sampleStarOccultationPath()
path.Footprints = []moon.OccultationFootprint{{Time: path.Start.Time}}
_, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if !errors.Is(err, ErrInvalidStarOccultationPath) {
t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err)
}
}
func TestStarOccultationPathSVGCompactBandUsesClosedOutlineWithoutRawLimits(t *testing.T) {
path := sampleStarOccultationPath()
makeFootprint := func(when time.Time, west, east float64) moon.OccultationFootprint {
return moon.OccultationFootprint{
Time: when,
Polygons: [][]moon.OccultationPathPoint{{
{Time: when, Longitude: west, Latitude: -10},
{Time: when, Longitude: east, Latitude: -10},
{Time: when, Longitude: east, Latitude: 10},
{Time: when, Longitude: west, Latitude: 10},
}},
}
}
path.BandFootprints = []moon.OccultationFootprint{
makeFootprint(path.Start.Time.Add(time.Hour), -20, 5),
makeFootprint(path.Start.Time.Add(2*time.Hour), -5, 20),
}
diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{})
if err != nil {
t.Fatalf("StarOccultationPathSVG: %v", err)
}
if strings.Contains(diagram, `class="northern-limit"`) || strings.Contains(diagram, `class="southern-limit"`) {
t.Fatal("compact stellar SVG still overlays discontinuous raw limits")
}
pathData := planetOccultationSVGPathData(t, diagram, "occultation-band")
if !strings.Contains(pathData, "Z") {
t.Fatal("compact stellar SVG band is not explicitly closed")
}
}
func TestStarOccultationPathSVGAllowsDenseAndCompactFootprints(t *testing.T) {
path := sampleStarOccultationPath()
when := path.Greatest.Time
footprint := moon.OccultationFootprint{
Time: when,
Polygons: [][]moon.OccultationPathPoint{{
{Time: when, Longitude: -10, Latitude: -10},
{Time: when, Longitude: 10, Latitude: -10},
{Time: when, Longitude: 0, Latitude: 10},
}},
}
path.Footprints = []moon.OccultationFootprint{footprint}
path.BandFootprints = []moon.OccultationFootprint{footprint}
if _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}); err != nil {
t.Fatalf("StarOccultationPathSVG() rejected coexisting static and timed footprints: %v", err)
}
}
func TestStarOccultationPathSVGRejectsMisalignedLimits(t *testing.T) {
path := sampleStarOccultationPath()
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
@@ -332,6 +622,17 @@ func sampleStarOccultationPath() moon.StarOccultationPath {
}
}
func sampleOccultationRiseSetCurve(start time.Time) moon.OccultationRiseSetCurve {
return moon.OccultationRiseSetCurve{
Phase: moon.RiseSetPhaseStart,
Direction: moon.RiseSetDirectionRise,
Segments: [][]moon.OccultationPathPoint{{
{Time: start.Add(20 * time.Minute), Longitude: 10, Latitude: 20, MoonAltitude: 0},
{Time: start.Add(40 * time.Minute), Longitude: 12, Latitude: 21, MoonAltitude: 0},
}},
}
}
func validateXML(value string) error {
decoder := xml.NewDecoder(strings.NewReader(value))
for {
+20 -6
View File
@@ -17,6 +17,7 @@ func writeOccultationTimeMarkers(
options StarOccultationSVGOptions,
excluded []time.Time,
greatest time.Time,
placer *occultationLabelPlacer,
) {
if options.TimeLabelStep <= 0 || len(points) < 2 {
return
@@ -42,15 +43,28 @@ func writeOccultationTimeMarkers(
if tooClose {
continue
}
projected = append(projected, [2]float64{x, y})
labelY := y - 8
text := marker.Time.In(options.Location).Format("15:04")
// 掩甚附近的标签默认压到圆点下方,给掩甚事件标记让位;两个方向都被占用就丢弃这个时刻。
baselines := []float64{y - 8, y + 15}
if occultationTimesNear(marker.Time, greatest, occultationGreatestTimeLabelWindow(options.TimeLabelStep)) {
labelY = y + 15
} else if labelY < frame.Y+10 {
labelY = y + 15
baselines[0], baselines[1] = baselines[1], baselines[0]
}
labelY, placed := 0.0, false
for _, baseline := range baselines {
if baseline < frame.Y+10 || baseline > frame.Y+frame.Height-8 {
continue
}
if placer.place(x, baseline, "middle", text, 9, 2) {
labelY, placed = baseline, true
break
}
}
if !placed {
continue
}
projected = append(projected, [2]float64{x, y})
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")))
x, y, x, labelY, html.EscapeString(text))
}
}