feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
+64
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user