feat: 引入可切换星历模型并扩展天象观测链路

- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
2026-10-15 23:54:15 +08:00
parent 16c62a97d5
commit 044f2f4381
674 changed files with 62983 additions and 8396 deletions
+11
View File
@@ -17,6 +17,7 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
"besselian-critical-envelope", "paired-limit-chords", "sampled-open-sweep",
"central-path-limits", "penumbral-outlines", "central-shadow-outlines",
"p1-p4-visibility-regions", "p1-p4-horizon-boundaries",
"c1-c4-visibility-regions", "c1-c4-horizon-boundaries", "transit-contact-isochrones",
}
if len(documented) != len(eclipseMapDataSources) {
t.Fatalf("vocabulary has %d entries, want %d", len(eclipseMapDataSources), len(documented))
@@ -51,6 +52,13 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
t.Fatal("missing 2026-03-03 lunar map")
}
if rendered, ok := TransitMapSVG(TransitVenus, time.Date(2012, time.June, 6, 0, 0, 0, 0, time.UTC),
TransitMapSVGOptions{Location: time.UTC, Isochrones: TransitIsochroneExternal, IsochroneStep: 10 * time.Minute}); ok {
charts["2012-06-06 venus transit"] = rendered
} else {
t.Fatal("missing 2012-06-06 transit map")
}
seen := map[string]bool{}
for name, chart := range charts {
for _, match := range eclipseMapDataSourcePattern.FindAllStringSubmatch(chart, -1) {
@@ -73,6 +81,9 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
eclipseMapSourceCentralShadowOutlines,
eclipseMapSourceLunarVisibilityRegions,
eclipseMapSourceLunarHorizonBoundaries,
eclipseMapSourceTransitVisibilityRegions,
eclipseMapSourceTransitHorizonBoundaries,
eclipseMapSourceTransitContactIsochrones,
} {
if !seen[source] {
t.Fatalf("main layer source %q carries no data-source marker", source)
+25 -16
View File
@@ -1,4 +1,4 @@
// Package svg 生成日食与月食的 SVG 图件。
// Package svg 生成日食、月食与水星、金星凌日的 SVG 图件。
//
// 可降级的图层用 data-source 标注实际几何来源,取值词表如下;没有降级分支的图层不带该属性。
//
@@ -16,27 +16,33 @@
// central-shadow-outlines 瞬时本影、反本影轮廓
// p1-p4-visibility-regions 月食可见性分区
// p1-p4-horizon-boundaries 月食 P1、P4 地平边界
// c1-c4-visibility-regions 凌日可见性分区
// c1-c4-horizon-boundaries 凌日 C1、C4 视地平边界
// transit-contact-isochrones 凌日站心接触等时线
//
// Package svg renders solar- and lunar-eclipse SVG charts. Degradable layers tag the geometry
// Package svg renders solar-eclipse, lunar-eclipse and Mercury/Venus transit SVG charts. Degradable layers tag the geometry
// source they actually used in data-source, using exactly the values above.
package svg
// data-source 词表,见包注释。
const (
eclipseMapSourcePartialBandUnion = "partial-band-union"
eclipseMapSourceSampledFootprintSweep = "sampled-footprint-sweep"
eclipseMapSourcePartialBandContours = "partial-band-contours"
eclipseMapSourceRiseSetPhaseLines = "rise-set-phase-lines"
eclipseMapSourceMagnitudeContours = "magnitude-contours"
eclipseMapSourceGreatestTimeIsochrones = "greatest-time-isochrones"
eclipseMapSourceBesselianEnvelope = "besselian-critical-envelope"
eclipseMapSourcePairedLimitChords = "paired-limit-chords"
eclipseMapSourceSampledOpenSweep = "sampled-open-sweep"
eclipseMapSourceCentralPathLimits = "central-path-limits"
eclipseMapSourcePenumbralOutlines = "penumbral-outlines"
eclipseMapSourceCentralShadowOutlines = "central-shadow-outlines"
eclipseMapSourceLunarVisibilityRegions = "p1-p4-visibility-regions"
eclipseMapSourceLunarHorizonBoundaries = "p1-p4-horizon-boundaries"
eclipseMapSourcePartialBandUnion = "partial-band-union"
eclipseMapSourceSampledFootprintSweep = "sampled-footprint-sweep"
eclipseMapSourcePartialBandContours = "partial-band-contours"
eclipseMapSourceRiseSetPhaseLines = "rise-set-phase-lines"
eclipseMapSourceMagnitudeContours = "magnitude-contours"
eclipseMapSourceGreatestTimeIsochrones = "greatest-time-isochrones"
eclipseMapSourceBesselianEnvelope = "besselian-critical-envelope"
eclipseMapSourcePairedLimitChords = "paired-limit-chords"
eclipseMapSourceSampledOpenSweep = "sampled-open-sweep"
eclipseMapSourceCentralPathLimits = "central-path-limits"
eclipseMapSourcePenumbralOutlines = "penumbral-outlines"
eclipseMapSourceCentralShadowOutlines = "central-shadow-outlines"
eclipseMapSourceLunarVisibilityRegions = "p1-p4-visibility-regions"
eclipseMapSourceLunarHorizonBoundaries = "p1-p4-horizon-boundaries"
eclipseMapSourceTransitVisibilityRegions = "c1-c4-visibility-regions"
eclipseMapSourceTransitHorizonBoundaries = "c1-c4-horizon-boundaries"
eclipseMapSourceTransitContactIsochrones = "transit-contact-isochrones"
)
// eclipseMapDataSources 是全部合法取值,测试用它封闭词表。
@@ -55,4 +61,7 @@ var eclipseMapDataSources = []string{
eclipseMapSourceCentralShadowOutlines,
eclipseMapSourceLunarVisibilityRegions,
eclipseMapSourceLunarHorizonBoundaries,
eclipseMapSourceTransitVisibilityRegions,
eclipseMapSourceTransitHorizonBoundaries,
eclipseMapSourceTransitContactIsochrones,
}
+70
View File
@@ -0,0 +1,70 @@
package svg
// 各日月食出图入口都接受月缘剖面,并在图注写明所用口径。
import (
"strings"
"testing"
"time"
"b612.me/astro/moon/limb"
)
func TestEclipseSVGsDeclareLunarLimbConvention(t *testing.T) {
solarDate := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
lunarDate := time.Date(2025, time.September, 7, 0, 0, 0, 0, time.UTC)
profile := limb.Default()
render := map[string]func(applied bool) (string, bool){
"local solar": func(applied bool) (string, bool) {
options := LocalSolarEclipseSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LocalSolarEclipseSVG(solarDate, -96.8, 32.78, 0, options)
},
"solar map": func(applied bool) (string, bool) {
options := SolarEclipseMapSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return SolarEclipseMapSVG(solarDate, options)
},
"lunar": func(applied bool) (string, bool) {
options := LunarEclipseSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LunarEclipseSVG(lunarDate, options)
},
"lunar map": func(applied bool) (string, bool) {
options := LunarEclipseMapSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LunarEclipseMapSVG(lunarDate, options)
},
"lunar detailed": func(applied bool) (string, bool) {
options := LunarEclipseDetailedSVGOptions{Location: time.UTC}
if applied {
options.LunarLimb = profile
}
return LunarEclipseDetailedSVG(lunarDate, options)
},
}
for name, draw := range render {
circular, ok := draw(false)
if !ok {
t.Fatalf("%s: circular chart missing", name)
}
corrected, ok := draw(true)
if !ok {
t.Fatalf("%s: limb chart missing", name)
}
if !strings.Contains(circular, "圆月面") {
t.Fatalf("%s: circular chart does not declare the circular limb", name)
}
if !strings.Contains(corrected, "月缘剖面") {
t.Fatalf("%s: limb chart does not declare the limb profile", name)
}
}
}
+7 -2
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -73,6 +74,8 @@ type LunarEclipseSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;只精修接触时刻,月盘仍按圆面绘制 / lunar limb profile, nil for circular; refines contacts only.
LunarLimb basic.LunarLimb
}
type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
@@ -164,7 +167,8 @@ func lunarEclipseSVG(
}
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
StepDays: durationToDays(options.Step),
LunarLimb: options.LunarLimb,
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
@@ -1045,7 +1049,8 @@ func lunarEclipseSVGFooterLines(direction string, info LunarEclipseInfo, options
if maxLines < 1 {
maxLines = 1
}
note := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
note := limbnote.Join(timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language)
lines := []string{lunarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-80, fontSize)
+13 -8
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -11,6 +12,7 @@ import (
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
@@ -40,6 +42,8 @@ type LunarEclipseDetailedSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;只精修接触时刻,月盘仍按圆面绘制 / lunar limb profile, nil for circular; refines contacts only.
LunarLimb basic.LunarLimb
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
@@ -118,7 +122,8 @@ func lunarEclipseDetailedSVG(
}
options = normalizeLunarEclipseDetailedSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
StepDays: durationToDays(options.Step),
LunarLimb: options.LunarLimb,
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
@@ -457,7 +462,8 @@ func writeLunarEclipseDetailedSummary(
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, view.metaY+float64(index)*22, html.EscapeString(line))
}
zoneNote := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
zoneNote := limbnote.Join(timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language)
labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle")
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+4*22, html.EscapeString(zoneNote))
@@ -499,13 +505,13 @@ func writeLunarEclipseDetailedGeocentricBlocks(
{Label: "赤经 R.A.", Value: formatSolarEclipseRA(sunRa)},
{Label: "赤纬 Dec.", Value: formatSolarEclipseDec(sunDec)},
{Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(sunSd)},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(sunSd, 6378.137/696000.0))},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(sunSd, geodata.EarthWGS84RadiusKM/696000.0))},
},
{
{Label: "赤经 R.A.", Value: formatSolarEclipseRA(moonRa)},
{Label: "赤纬 Dec.", Value: formatSolarEclipseDec(moonDec)},
{Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(moonSd)},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(moonSd, 6378.137/1737.4))},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(moonSd, geodata.EarthWGS84RadiusKM/1737.4))},
},
}
if english {
@@ -578,7 +584,7 @@ func writeLunarEclipseArcMinuteScaleBar(
return
}
// 示意图以"月球半径"为单位,一个月球半径对应的真实角径是 asin(R月 / 月距),不是地球视差。
moonSemidiameterArcmin := math.Asin((lunarMoonRadiusKM/lunarEarthRadiusKM)/shadow.MoonDistanceEarthRadii) * 180 / math.Pi * 60
moonSemidiameterArcmin := math.Asin((lunarMoonRadiusKM/geodata.EarthWGS84RadiusKM)/shadow.MoonDistanceEarthRadii) * 180 / math.Pi * 60
if moonSemidiameterArcmin <= 0 {
return
}
@@ -650,7 +656,6 @@ func writeLunarEclipseDetailedFooter(
}
const (
// lunarMoonRadiusKM 与 lunarEarthRadiusKM 只用于把示意图的"月球半径"刻度换算成角分比例尺。
lunarMoonRadiusKM = 1737.4
lunarEarthRadiusKM = 6378.137
// lunarMoonRadiusKM 与 geodata.EarthWGS84RadiusKM 只用于把示意图的"月球半径"刻度换算成角分比例尺。
lunarMoonRadiusKM = 1737.4
)
@@ -9,6 +9,8 @@ import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/ephemtest"
)
// 详细版"食甚时的月亮(地心坐标)"块必须用食甚时刻的力学时求值,与 NASA 月食图同一时刻;
@@ -26,6 +28,7 @@ func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) {
{"2026-03-03", time.Date(2026, 3, 3, 0, 0, 0, 0, cst), "10h56m15.1s", "+06°24&#39;05.2&#34;"},
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40&#39;54.3&#34;"},
}
values := ephemtest.NewValues(t, "lunar_geocentric_block")
for _, want := range cases {
doc, ok := LunarEclipseDetailedSVG(want.date, LunarEclipseDetailedSVGOptions{
Language: "zh", Location: cst})
@@ -33,6 +36,8 @@ func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) {
t.Fatalf("%s: missing chart", want.name)
}
ra, dec := lunarGeocentricMoonBlock(t, doc)
want.ra = values.WantString(want.name+"/ra", want.ra, ra)
want.dec = values.WantString(want.name+"/dec", want.dec, dec)
if ra != want.ra || dec != want.dec {
t.Errorf("%s: 食甚坐标 RA=%s Dec=%s, want RA=%s Dec=%s", want.name, ra, dec, want.ra, want.dec)
}
+20 -18
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -11,6 +12,7 @@ import (
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/lunarhorizon"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
@@ -39,6 +41,8 @@ type LunarEclipseMapSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;精修接触时刻,阶段地平圈随之移动 / lunar limb profile; refines contacts and moves phase horizons.
LunarLimb basic.LunarLimb
// Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false.
Projection EclipseMapProjection
@@ -57,19 +61,24 @@ type LunarEclipseMapSVGOptions struct {
// LunarEclipseMapSVG 使用默认月食模型绘制全球 P1-P4 可见区域。
// LunarEclipseMapSVG renders the global P1-P4 visibility regions using the default lunar-eclipse model.
func LunarEclipseMapSVG(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDate)
return lunarEclipseMapSVG(date, options, lunarEclipseMapModule(options).LunarEclipseOnDate)
}
// LunarEclipseMapSVGDanjon 使用 Danjon 模型绘制全球可见区域。
// LunarEclipseMapSVGDanjon renders the global visibility regions with Danjon's model.
func LunarEclipseMapSVGDanjon(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateDanjon)
return lunarEclipseMapSVG(date, options, lunarEclipseMapModule(options).LunarEclipseOnDateDanjon)
}
// LunarEclipseMapSVGChauvenet 使用 Chauvenet 模型绘制全球可见区域。
// LunarEclipseMapSVGChauvenet renders the global visibility regions with Chauvenet's model.
func LunarEclipseMapSVGChauvenet(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) {
return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateChauvenet)
return lunarEclipseMapSVG(date, options, lunarEclipseMapModule(options).LunarEclipseOnDateChauvenet)
}
// lunarEclipseMapModule 返回按选项月缘配置的核心模块;未设置月缘时等同包级计算。
func lunarEclipseMapModule(options LunarEclipseMapSVGOptions) *eclipsecore.Eclipse {
return eclipsecore.NewWithInstance(nil).WithLunarLimb(options.LunarLimb)
}
func lunarEclipseMapSVG(
@@ -159,7 +168,7 @@ func renderLunarEclipseMapSVG(
frame.WriteFrame(&builder)
legendRows := lunarEclipseMapLegendRows(frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, len(legendRows))
writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, info.LunarLimbApplied, len(legendRows))
builder.WriteString(`</svg>`)
return builder.String()
}
@@ -223,11 +232,11 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []
}
if frame.Projection == svgmap.ProjectionEquirectangular {
// 等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近 180° 经度,
// 只连端点会在极区切出地平线以外的假可见帽(1904-09-24 实测 −0.054°,约 6 km)。
// 只连端点会在极区切出地平线以外的假可见帽。
// 极射投影没有这个畸变,保持原输出。
// Equirectangular projection stretches longitude near a pole: a 1-degree spherical
// horizon arc can span almost 180 degrees there, so joining endpoints alone cuts a fake
// visible cap outside the horizon (measured -0.054 deg, about 6 km, on 1904-09-24).
// visible cap outside the horizon.
// Polar projections have no such distortion and keep their existing output.
// 门限取画布上约 0.03 像素:极区细分再多也画不出来,而 GeoJSON 用的 0.002°
// 在 812 像素宽的世界图上相当于 0.005 像素,会让 SVG 无谓地大三分之一。
@@ -476,6 +485,7 @@ func writeLunarEclipseMapFooter(
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
limbApplied bool,
legendRows int,
) {
text := options.FooterNote
@@ -497,7 +507,8 @@ func writeLunarEclipseMapFooter(
}
}
// 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。
note := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
note := limbnote.Join(timenote.Scale(options.TimeScale, instant, options.Location, options.Language),
limbnote.Contacts(limbApplied, options.Language), options.Language)
lines := []string{text}
if options.FooterNote != "" {
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-frame.X-24, 11)
@@ -511,20 +522,12 @@ func writeLunarEclipseMapFooter(
}
}
func normalizeDegree180(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
// lunarEclipseOrthographicCenter 给出月食正射图的视点:食甚时刻的月下点(月球位于天顶的地点)。
func lunarEclipseOrthographicCenter(at time.Time) (float64, float64) {
jd := basic.Date2JD(at.UTC())
tt := basic.UTC2TT(jd)
ra, dec := basic.HMoonTrueRaDec(tt)
return normalizeDegree180(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec
return geodata.NormalizeLongitude(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec
}
// writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。
@@ -558,8 +561,7 @@ func writeLunarEclipseUmbralVisibilityShapes(b *strings.Builder, frame svgmap.Fr
}
// writeLunarEclipseUmbralVisibleShapes 输出"本影阶段任意时刻在地平上"的取样半球:本影区间按固定档位
// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域——
// 实测 1932-03-22 西经 75.5° 一带上边界 0.5° 经度内从 +19.9° 掉到 −27.2°,1° 列的弦会切掉十几度)。
// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域)。
// 这些形状同时用于底带的裁剪(取并集)与半影带的掩膜(取补集),两侧严丝合缝。
func writeLunarEclipseUmbralVisibleShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
instants := lunarEclipseUmbralVisibilityInstants(info)
+1 -7
View File
@@ -12,13 +12,7 @@ import (
// TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility 固定全球月食地图在
// 极区的可见性契约。等距圆柱投影在高纬把经度拉伸:1° 的球面地平弧在极点附近可以横跨近
// 180° 经度,只连端点就会把地平线以外画进可见区。1904-09-24 的 P1 在南极点附近有一个
// 月心高度约 −0.05° 的见证点,曾经被画进 visible-at-p1 多边形。
// TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility pins the polar
// visibility contract of the global lunar-eclipse map. Equirectangular projection stretches
// longitude near a pole: a 1-degree spherical horizon arc can span almost 180 degrees there, so
// joining endpoints alone paints sky below the horizon as visible. At P1 of 1904-09-24 a witness
// near the south pole sits about 0.05 degrees below the Moon's horizon and used to be painted
// inside the visible-at-p1 polygon.
// 月心高度约 −0.05° 的见证点,不得落入 visible-at-p1 多边形。
func TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility(t *testing.T) {
date := time.Date(1904, 9, 24, 0, 0, 0, 0, time.UTC)
info, ok := eclipsecore.LunarEclipseOnDate(date)
+1
View File
@@ -28,6 +28,7 @@ func lunarEclipseInfoFromBasic(
) LunarEclipseInfo {
info := LunarEclipseInfo{
Type: mapBasicLunarEclipseType(result.Type),
LunarLimbApplied: result.LunarLimbApplied,
PenumbralMagnitude: result.PenumbralMagnitude,
UmbralMagnitude: result.Magnitude,
PenumbralStart: ttJDEToTime(result.PenumbralStart, location),
+7 -1
View File
@@ -13,6 +13,8 @@ import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/ephemtest"
)
// 半影阶段开关的契约:零值输出与不区分半影阶段的基线逐字节一致,打开后补画 U1–U4 地平边界、
@@ -47,6 +49,7 @@ func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
if len(render) != len(lunarPenumbralPhaseBaseline) {
t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render))
}
values := ephemtest.NewValues(t, "lunar_penumbral_phase_digest")
for _, want := range lunarPenumbralPhaseBaseline {
draw, ok := render[want.name]
if !ok {
@@ -57,7 +60,10 @@ func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
t.Fatalf("%s: chart missing", want.name)
}
sum := md5.Sum([]byte(doc))
if got := hex.EncodeToString(sum[:]); got != want.digest || len(doc) != want.length {
got := hex.EncodeToString(sum[:])
want.digest = values.WantString(want.name+"/md5", want.digest, got)
want.length = int(values.Want(want.name+"/bytes", float64(want.length), float64(len(doc))))
if got != want.digest || len(doc) != want.length {
t.Fatalf("%s: md5=%s bytes=%d, want md5=%s bytes=%d",
want.name, got, len(doc), want.digest, want.length)
}
+6 -2
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -71,6 +72,8 @@ type LocalSolarEclipseSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;精修接触时刻与食甚时的食分、遮蔽率,食甚时刻不变、月盘仍按圆面绘制 / lunar limb profile for contacts, magnitude and obscuration.
LunarLimb basic.LunarLimb
}
type localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult
@@ -125,7 +128,7 @@ func localSolarEclipseSVG(
lon,
lat,
height,
basic.LocalSolarEclipseDiagramOptions{StepDays: solarEclipseDurationToDays(options.Step)},
basic.LocalSolarEclipseDiagramOptions{StepDays: solarEclipseDurationToDays(options.Step), LunarLimb: options.LunarLimb},
)
if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 {
return "", false
@@ -170,7 +173,8 @@ func renderLocalSolarEclipseSVG(
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
scaleNote := timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language)
scaleNote := limbnote.Join(timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language),
limbnote.Contacts(info.LunarLimbApplied, options.Language), options.Language)
footerLines := localSolarEclipseSVGFooterLines(direction, localSolarEclipseSVGFooterNoteText(options), scaleNote, width, height, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), height, 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
+46 -12
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/limbnote"
"b612.me/astro/internal/timenote"
"fmt"
"html"
@@ -57,6 +58,8 @@ type SolarEclipseMapSVGOptions struct {
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// LunarLimb 月缘剖面,nil 为圆月面;修正全球始末、食甚点站心面板、中心带界线与带宽、偏食可见域外边界,页脚只声明实际生效的层 / lunar limb profile for contacts, local panel, central limits and partial boundary.
LunarLimb basic.LunarLimb
// Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false.
Projection EclipseMapProjection
@@ -137,27 +140,34 @@ func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (stri
// SolarEclipseMapSVGNASABulletinSplitK 使用 NASA bulletin Split-K 绘制地图。
// SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map.
func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
module := solarEclipseMapModule(options)
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
global: eclipsecore.SolarEclipseOnDateNASABulletinSplitK,
global: module.SolarEclipseOnDateNASABulletinSplitK,
panel: eclipsecore.SolarEclipseGeocentricPanelAt,
partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK,
central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
partial: module.SolarEclipsePartialFootprintsNASABulletinSplitK,
central: module.SolarEclipseCentralPathNASABulletinSplitK,
local: module.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
})
}
// SolarEclipseMapSVGIAUSingleK 使用 IAU Single-K 模型绘制地图。
// SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map.
func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
module := solarEclipseMapModule(options)
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
global: eclipsecore.SolarEclipseOnDateIAUSingleK,
global: module.SolarEclipseOnDateIAUSingleK,
panel: eclipsecore.SolarEclipseGeocentricPanelIAUSingleK,
partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK,
central: eclipsecore.SolarEclipseCentralPathIAUSingleK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK,
partial: module.SolarEclipsePartialFootprintsIAUSingleK,
central: module.SolarEclipseCentralPathIAUSingleK,
local: module.GeometricLocalSolarEclipseOnDateIAUSingleK,
})
}
// solarEclipseMapModule 返回按选项月缘配置的核心模块,用于全局结果、中心带、偏食可见域外边界与食甚点站心面板;食分线、等时线与地心面板保持圆月面。
func solarEclipseMapModule(options SolarEclipseMapSVGOptions) *eclipsecore.Eclipse {
return eclipsecore.NewWithInstance(nil).WithLunarLimb(options.LunarLimb)
}
func solarEclipseMapSVG(
date time.Time,
options SolarEclipseMapSVGOptions,
@@ -211,6 +221,11 @@ func solarEclipseMapSVG(
if calculators.panel != nil {
geocentric, hasGeocentric = calculators.panel(date)
}
// 带宽与画出的南北界同源:南北界真正按月缘修正时取中心路径食甚点带宽。
if solarEclipseMapLimbLayers(partial, central, hasCentral, local, hasLocal).CentralLimits {
partial.Eclipse.PathWidthDefined = central.PathWidthDefined
partial.Eclipse.PathWidthKM = central.Greatest.WidthKM
}
// 几何(直射点、闭合弧、等时线取值)必须用民用时刻算;只有写出的文字换时标。
// 时刻标记按输出时标的整点取点(那里本就是另一个物理时刻),位置随整点标注移动。
civilPartial := partial
@@ -232,6 +247,22 @@ func solarEclipseMapSVG(
local, hasLocal, geocentric, hasGeocentric, options, projection), true
}
// solarEclipseMapLimbLayers 汇总各图层是否真正按月缘修正;各层彼此独立,页脚与带宽同步都读这里。
func solarEclipseMapLimbLayers(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
local eclipsecore.LocalSolarEclipseInfo,
hasLocal bool,
) limbnote.SolarMapLayers {
return limbnote.SolarMapLayers{
GlobalContacts: partial.Eclipse.LunarLimbApplied,
LocalPanel: hasLocal && local.LunarLimbApplied,
CentralLimits: hasCentral && central.CentralLimitsLimbApplied,
PartialBoundary: partial.PartialBoundaryLimbApplied,
}
}
// solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。
func solarEclipseMapHasCentralBand(partial eclipsecore.SolarEclipsePartialFootprintsInfo) bool {
return len(partial.CentralBandFootprints) > 0 || len(partial.CentralShadowFootprints) > 0 ||
@@ -479,7 +510,8 @@ func (plan solarEclipseMapPlan) render(
options.Width-44, options.Height-36)
fmt.Fprintf(&builder, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(titleText))
writeSolarEclipseMapSummary(&builder, labels, partial.Eclipse, local, hasLocal, geocentric, hasGeocentric, options)
limbLayers := solarEclipseMapLimbLayers(partial, central, hasCentral, local, hasLocal)
writeSolarEclipseMapSummary(&builder, labels, partial.Eclipse, local, hasLocal, geocentric, hasGeocentric, limbLayers, options)
writeSolarEclipseMapSectionTitle(&builder, labels, layout, options, hasCentral)
frame.WriteOcean(&builder)
@@ -708,7 +740,7 @@ func solarEclipseAppendEquirectangularPoleRim(
for step := 1; step <= steps; step++ {
longitude := lastLongitude + delta*float64(step)/float64(steps)
result = append(result, svgmap.GeoPoint{
Longitude: normalizeDegree180(longitude),
Longitude: geodata.NormalizeLongitude(longitude),
Latitude: poleLatitude,
})
}
@@ -795,7 +827,7 @@ func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
ttJDE := solarEclipseTimeToTTJDE(value)
ra, dec := basic.HSunApparentRaDec(ttJDE)
ut1JDE := basic.TT2UT1(ttJDE)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(ut1JDE)*15)
longitude := geodata.NormalizeLongitude(ra - basic.ApparentSiderealTime(ut1JDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
@@ -1111,6 +1143,7 @@ func writeSolarEclipseMapSummary(
hasLocal bool,
geocentric eclipsecore.SolarEclipseGeocentricPanel,
hasGeocentric bool,
limbLayers limbnote.SolarMapLayers,
options SolarEclipseMapSVGOptions,
) {
start := info.PartialBeginOnEarth.In(options.Location)
@@ -1192,7 +1225,8 @@ func writeSolarEclipseMapSummary(
summaryX, conjunctionY, html.EscapeString(conjunctionText))
}
// 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。
zoneNote := timenote.Scale(options.TimeScale, maximum, options.Location, options.Language)
zoneNote := limbnote.Join(timenote.Scale(options.TimeScale, maximum, options.Location, options.Language),
limbnote.SolarMap(limbLayers, options.Language), options.Language)
labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle")
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
summaryX, conjunctionY+20, html.EscapeString(zoneNote))
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
)
// 一秒步长请求曾经要跑满整条 18605 个瞬时足迹的扫描;夹到两分钟后应与默认请求同量级。
// 一秒步长请求应被夹到两分钟,耗时与默认请求同量级。
func BenchmarkSolarEclipseMapSVGPartialStepOneSecond(b *testing.B) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
options := SolarEclipseMapSVGOptions{
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
)
@@ -90,7 +91,7 @@ func writeSolarEclipseScaleBar(
if radius <= 0 || slot.Width <= 0 || slot.Height <= 0 {
return
}
kilometresPerPixel := (math.Pi / 2 * 6371.0088) / radius
kilometresPerPixel := (math.Pi / 2 * geodata.EarthMeanRadiusKM) / radius
target := 130.0 * kilometresPerPixel
step := solarEclipseScaleStep(target, slot.Width, func(candidate float64) float64 {
barWidth := candidate / kilometresPerPixel
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
)
// 图例只能画一次:横版曾经在渲染器与横版面板里各画一次,而且两处传入的中心带参数还不一致。
// 图例只能画一次,横版渲染器与面板不得重复绘制。
func TestSolarEclipseMapLegendRendersOnce(t *testing.T) {
for _, test := range []struct {
name string
+203
View File
@@ -0,0 +1,203 @@
package svg
// 全球日食图开月缘时,面板数值与画出的界线同源;剖面失败时整张图保持圆月面。
import (
"fmt"
"math"
"strings"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/moon/limb"
)
type failingEclipseLimb struct{}
func (failingEclipseLimb) LimbRadiusKM(_, _, _ float64) (float64, bool) { return 0, false }
func TestSolarEclipseMapLunarLimbPanelMatchesLimbResults(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
profile := limb.Default()
circular, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC})
if !ok {
t.Fatal("circular map missing")
}
corrected, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: profile})
if !ok {
t.Fatal("limb map missing")
}
module := eclipsecore.NewWithInstance(nil).WithLunarLimb(profile)
global, _ := module.SolarEclipseOnDateNASABulletinSplitK(date)
if !global.LunarLimbApplied {
t.Fatal("global limb result not flagged")
}
// 与地图同一组路径选项,带宽才和画出的南北界同源。
path, ok := module.SolarEclipseCentralPathNASABulletinSplitK(date, eclipsecore.SolarEclipsePathOptions{
Step: 2 * time.Minute, TargetSpacingKM: 150, SkipCentralBand: true,
})
if !ok || !path.PathWidthDefined {
t.Fatal("limb central path missing")
}
limbWidth := fmt.Sprintf("%.1f km", path.Greatest.WidthKM)
circularGlobal, _ := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date)
circularWidth := fmt.Sprintf("%.1f km", circularGlobal.PathWidthKM)
if limbWidth == circularWidth {
t.Fatalf("limb width %s equals circular width; fixture no longer distinguishes them", limbWidth)
}
if !strings.Contains(corrected, limbWidth) || strings.Contains(corrected, circularWidth) {
t.Fatalf("limb map must show width %s and not %s", limbWidth, circularWidth)
}
if !strings.Contains(circular, circularWidth) {
t.Fatalf("circular map must show width %s", circularWidth)
}
local, ok := module.GeometricLocalSolarEclipseOnDateNASABulletinSplitK(date, global.GreatestLongitude, global.GreatestLatitude, 0)
if !ok || !local.LunarLimbApplied {
t.Fatal("limb local panel result missing or not flagged")
}
if magnitude := fmt.Sprintf("%.4f", local.Magnitude); !strings.Contains(corrected, magnitude) {
t.Fatalf("limb map must show the limb local magnitude %s", magnitude)
}
begin := global.PartialBeginOnEarth.UTC().Format("15:04:05")
if begin == circularGlobal.PartialBeginOnEarth.UTC().Format("15:04:05") {
t.Fatal("limb P1 rounds to the circular value; pick a fixture where it moves")
}
if !strings.Contains(corrected, begin) || strings.Contains(circular, begin) {
t.Fatalf("limb P1 %s must appear only in the limb map", begin)
}
if !strings.Contains(corrected, "按月缘剖面修正全球始末、食甚点站心值、中心带界线、可见域边界") {
t.Fatal("limb map does not declare every corrected layer")
}
}
// selectiveEclipseLimb 只在位置角 [low, high) 内给出剖面值并报告半径范围;外切扫描靠范围剪枝停在日心方向附近,需要全方向的判据退回圆月面。
type selectiveEclipseLimb struct{ low, high float64 }
func (s selectiveEclipseLimb) LimbRadiusKM(longitude, latitude, angle float64) (float64, bool) {
if angle < s.low || angle >= s.high {
return 0, false
}
return limb.Default().LimbRadiusKM(longitude, latitude, angle)
}
func (selectiveEclipseLimb) RadiusRangeKM() (float64, float64) { return limb.Default().RadiusRangeKM() }
func (selectiveEclipseLimb) AngleStepDeg() float64 { return limb.Default().AngleStepDeg() }
func TestSolarEclipseMapFooterDeclaresOnlyCorrectedLayers(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
circular, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC})
if !ok {
t.Fatal("circular map missing")
}
// 位置角 45°~90° 只覆盖偏食外边界的接触点:全球始末、站心面板与中心界线都退回圆月面。
selective, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: selectiveEclipseLimb{45, 90}})
if !ok {
t.Fatal("selective-limb map missing")
}
if !strings.Contains(selective, "按月缘剖面修正可见域边界(食分线与等时线仍按圆月面)") {
t.Fatal("selective map must declare only the visibility boundary")
}
for _, layer := range []string{"全球始末", "食甚点站心值", "中心带界线"} {
if strings.Contains(selective, layer) {
t.Errorf("selective map declares uncorrected layer %s", layer)
}
}
circularGlobal, _ := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(date)
if width := fmt.Sprintf("%.1f km", circularGlobal.PathWidthKM); !strings.Contains(selective, width) {
t.Errorf("selective map must keep the circular width %s while the limits stay circular", width)
}
if strings.Contains(circular, "按月缘剖面修正") {
t.Error("circular map declares a limb correction")
}
}
func TestEclipseSVGsWithFailingLimbStayCircular(t *testing.T) {
solarDate := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
lunarDate := time.Date(2025, time.September, 7, 0, 0, 0, 0, time.UTC)
failing := failingEclipseLimb{}
pairs := map[string][2]func() (string, bool){
"local solar": {
func() (string, bool) {
return LocalSolarEclipseSVG(solarDate, -96.8, 32.78, 0, LocalSolarEclipseSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return LocalSolarEclipseSVG(solarDate, -96.8, 32.78, 0, LocalSolarEclipseSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"solar map": {
func() (string, bool) {
return SolarEclipseMapSVG(solarDate, SolarEclipseMapSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return SolarEclipseMapSVG(solarDate, SolarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"lunar": {
func() (string, bool) { return LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Location: time.UTC}) },
func() (string, bool) {
return LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"lunar map": {
func() (string, bool) {
return LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
"lunar detailed": {
func() (string, bool) {
return LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Location: time.UTC})
},
func() (string, bool) {
return LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Location: time.UTC, LunarLimb: failing})
},
},
}
for name, pair := range pairs {
circular, ok := pair[0]()
if !ok {
t.Fatalf("%s: circular chart missing", name)
}
failed, ok := pair[1]()
if !ok {
t.Fatalf("%s: failing-limb chart missing", name)
}
if failed != circular {
t.Errorf("%s: failing limb profile changed the chart", name)
}
}
}
// TestSolarEclipseMapContactMarkerMatchesCorrectedTime 检查全球图的 P1/P4 标记与页脚时刻同源:
// 月缘修正过时刻时,标记位置必须取修正后的接触点,而不是圆月面接触点。
func TestSolarEclipseMapContactMarkerMatchesCorrectedTime(t *testing.T) {
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
module := eclipsecore.NewWithInstance(nil).WithLunarLimb(limb.Default())
global, ok := module.SolarEclipseOnDateNASABulletinSplitK(date)
if !ok {
t.Fatal("global result missing")
}
if !global.LunarLimbApplied || !global.PartialBeginContact.Defined {
t.Fatal("the default limb profile must correct the global contacts")
}
circular, ok := eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK(date, eclipsecore.SolarEclipsePartialFootprintOptions{})
if !ok {
t.Fatal("circular footprints missing")
}
// 修正后的接触点必须真的离开圆月面接触点,否则这条回归就测不到东西。
moved := math.Hypot(
(global.PartialBeginContact.Longitude-circular.P1.Longitude)*math.Cos(circular.P1.Latitude*math.Pi/180),
global.PartialBeginContact.Latitude-circular.P1.Latitude)
if moved < 1e-4 {
t.Fatalf("corrected P1 did not move (%.6f deg)", moved)
}
if delta := global.PartialBeginOnEarth.Sub(circular.P1.Time).Seconds(); math.Abs(delta) < 0.5 {
t.Fatalf("corrected P1 time moved by only %.3f s", delta)
}
}
+1
View File
@@ -50,6 +50,7 @@ func localSolarEclipseInfoFieldsFromBasic(
visibleThreshold := localSolarEclipseVisibilityThreshold(height, lat)
return LocalSolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
LunarLimbApplied: result.LunarLimbApplied,
Type: mapBasicSolarEclipseType(result.Type),
Longitude: lon,
Latitude: lat,
@@ -0,0 +1,6 @@
{
"2025-03-14/dec": "+02°40\u0026#39;54.6\u0026#34;",
"2025-03-14/ra": "11h38m23.0s",
"2026-03-03/dec": "+06°24\u0026#39;05.4\u0026#34;",
"2026-03-03/ra": "10h56m15.0s"
}
@@ -0,0 +1,30 @@
{
"2016-08-18 半影 1200x800 zh UTC/bytes": 241472,
"2016-08-18 半影 1200x800 zh UTC/md5": "b74c49dceb93ecb0d14054d46d29c2ac",
"2016-08-18 详细版 en/bytes": 261576,
"2016-08-18 详细版 en/md5": "d4799bb8e1ca37b62a720bba373b94eb",
"2025-03-14 全食 1200x800 zh UTC/bytes": 255228,
"2025-03-14 全食 1200x800 zh UTC/md5": "35803c58d004859285d2e6f5d2ae7f44",
"2025-03-14 全食 960x640 zh CST/bytes": 250322,
"2025-03-14 全食 960x640 zh CST/md5": "314dcafb7d30d76ae3b19fd6a048c9a8",
"2026-03-03 全食 1200x800 en UTC/bytes": 247498,
"2026-03-03 全食 1200x800 en UTC/md5": "3c47a2d2610542b12179543598f36531",
"2026-03-03 全食 1200x800 zh UTC/bytes": 247428,
"2026-03-03 全食 1200x800 zh UTC/md5": "e64e51c143f9190ac013f3b6bf329f70",
"2026-03-03 全食 960x640 en CST/bytes": 243630,
"2026-03-03 全食 960x640 en CST/md5": "5eb2a1bc355201f6e1cb6027c496a6c8",
"2026-03-03 全食 960x640 zh CST/bytes": 243568,
"2026-03-03 全食 960x640 zh CST/md5": "162ee33df427370b2d3055e66b8c2a00",
"2026-03-03 北极投影 zh/bytes": 159015,
"2026-03-03 北极投影 zh/md5": "d725d389b047a189a6b3b51dcb6d1291",
"2026-03-03 南极投影 zh/bytes": 84949,
"2026-03-03 南极投影 zh/md5": "d6ae40520fab72b9885751fb7907c3de",
"2026-03-03 详细版 en/bytes": 271080,
"2026-03-03 详细版 en/md5": "9f20d5c3a7408d0f8a0071ad08357e75",
"2026-03-03 详细版 zh/bytes": 270931,
"2026-03-03 详细版 zh/md5": "e938aa1d6eb4221eefdd1d0675ffb70f",
"2029-01-01 全食 960x640 en CST/bytes": 231513,
"2029-01-01 全食 960x640 en CST/md5": "2118fff17ef4dabfd09ac59bef4794e9",
"2029-01-01 详细版 zh/bytes": 257099,
"2029-01-01 详细版 zh/md5": "d9a1acb5188eb92706a00012dfc78448"
}
+438
View File
@@ -0,0 +1,438 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/timenote"
)
const (
transitLocalDefaultWidth = 960
transitLocalDefaultHeight = 680
)
// TransitLocalSVGOptions 控制站心凌日日面图。
// TransitLocalSVGOptions controls a topocentric planetary-transit solar-disk chart.
type TransitLocalSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 480 时使用 960x680 默认值。
// Width and Height are SVG canvas dimensions. Width values below 640 and height values below 480 use the 960x680 defaults.
Width int
Height int
// Step 是行星轨迹采样步长;非正值使用两分钟。
// Step is the planet-track sampling step. Non-positive values use two minutes.
Step time.Duration
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
Language string
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// TransitLocalSVG 绘制距 date 最近一次水星或金星凌日在观测地(经度 lon、纬度 lat,东经北纬为正,海拔 height 米)
// 的站心日面图:上北左东的天空视向,画出行星中心轨迹、各接触与凌甚时的行星圆面,以及接触时刻表。
// 轨迹上太阳中心在视地平下(计入标准大气折射)的部分画成灰色虚线。该地几何上看不到凌日、找不到凌日或选项无效时返回 false。
// TransitLocalSVG renders the topocentric solar-disk view of the Mercury or Venus transit closest to date, seen from
// longitude lon and latitude lat (east and north positive) at height metres: north up and east left as on the sky, with
// the planet-centre track, the planet disk at each contact and at greatest, and a contact table. Track parts where the
// Sun's centre is below the apparent horizon (standard refraction) are drawn grey and dashed. It returns false when the
// site sees no geometric transit, no transit is found or the options are invalid.
func TransitLocalSVG(planet TransitPlanet, date time.Time, lon, lat, height float64, options TransitLocalSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
local := basic.PlanetTransitLocal(transit, basic.Observer{Longitude: lon, Latitude: lat, Height: height})
if !local.Valid {
return "", false
}
if options.Step <= 0 {
options.Step = 2 * time.Minute
}
frames := basic.PlanetTransitLocalFrames(transit, local, options.Step.Hours()/24)
if len(frames) < 2 {
return "", false
}
options = normalizeTransitLocalSVGOptions(date, options)
return renderTransitLocalSVG(planet, local, frames, options), true
}
func normalizeTransitLocalSVGOptions(date time.Time, options TransitLocalSVGOptions) TransitLocalSVGOptions {
if options.Width < 640 {
options.Width = transitLocalDefaultWidth
}
if options.Height < 480 {
options.Height = transitLocalDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
// transitLocalEvent 是日面上标注的一个接触或凌甚。
type transitLocalEvent struct {
label, name string
contact basic.PlanetTransitLocalContact
separation float64
}
func transitLocalEvents(local basic.PlanetTransitLocalResult, language string) []transitLocalEvent {
names := []string{"一触", "二触", "凌甚", "三触", "四触"}
if language == "en" {
names = []string{"C1", "C2", "Greatest", "C3", "C4"}
}
external := local.SunSemidiameterArcsec + local.PlanetSemidiameterArcsec
internal := local.SunSemidiameterArcsec - local.PlanetSemidiameterArcsec
candidates := []transitLocalEvent{
{"C1", names[0], local.ExternalIngress, external},
{"C2", names[1], local.InternalIngress, internal},
{"G", names[2], local.Greatest, local.MinimumSeparationArcsec},
{"C3", names[3], local.InternalEgress, internal},
{"C4", names[4], local.ExternalEgress, external},
}
events := candidates[:0]
for _, event := range candidates {
if event.contact.Valid {
events = append(events, event)
}
}
return events
}
// transitLocalSunUp 判断太阳中心几何高度 altitude 是否在视地平上(标准大气折射)。
func transitLocalSunUp(altitude float64) bool {
return altitude > -basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
}
func transitDisplayTime(value time.Time, scale astro.TimeScale, location *time.Location) time.Time {
if value.IsZero() {
return value
}
if scale == astro.TimeScaleUT1 {
return basic.UT1FromUTC(value)
}
return value.In(location)
}
func renderTransitLocalSVG(
planet TransitPlanet,
local basic.PlanetTransitLocalResult,
frames []basic.PlanetTransitLocalFrame,
options TransitLocalSVGOptions,
) string {
width, height := float64(options.Width), float64(options.Height)
at := func(jd float64) time.Time {
return transitDisplayTime(basic.JD2DateByZone(jd, time.UTC, false), options.TimeScale, options.Location)
}
title := options.Title
if title == "" {
date := at(local.Greatest.JD).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s Transit of %s, Local Circumstances", date, transitMapPlanetName(planet, "en"))
} else {
title = fmt.Sprintf("%s %s凌日站心图", date, transitMapPlanetName(planet, "zh"))
}
}
site := transitLocalSiteText(local.Observer, options.Language)
summary := transitLocalSummaryText(local, options.Language)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs><radialGradient id="transit-sun" cx="40%" cy="34%" r="64%"><stop offset="0%" stop-color="#fff8bb"/><stop offset="58%" stop-color="#ffd55f"/><stop offset="100%" stop-color="#f5a623"/></radialGradient></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"/>`, width-44, height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, width-52, 24)
}
fmt.Fprintf(&b, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(titleText))
for index, line := range []string{site, summary} {
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
width/2, 72+float64(index)*19, html.EscapeString(line))
}
// 日面区占左侧,接触表占右侧。
panelWidth := 300.0
top, bottom := 112.0, height-86
diskLeft, diskRight := 46.0, width-panelWidth-60
cx, cy := (diskLeft+diskRight)/2, (top+bottom)/2
extent := local.SunSemidiameterArcsec + 2.2*local.PlanetSemidiameterArcsec + 60
scale := math.Min((diskRight-diskLeft)/2, (bottom-top)/2) / extent
mapX := func(x float64) float64 { return cx - x*scale } // 东在左
mapY := func(y float64) float64 { return cy - y*scale }
sunRadius := local.SunSemidiameterArcsec * scale
fmt.Fprintf(&b, `<circle class="transit-sun" cx="%.3f" cy="%.3f" r="%.3f" fill="url(#transit-sun)" stroke="#c78211" stroke-width="1.4"/>`, cx, cy, sunRadius)
writeTransitLocalAxes(&b, cx, cy, sunRadius, options.Language)
writeTransitLocalTrack(&b, frames, mapX, mapY)
events := transitLocalEvents(local, options.Language)
planetRadius := math.Max(local.PlanetSemidiameterArcsec*scale, 2)
table := &svgchart.LabelTable{}
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
fill, stroke := "#1d1d1d", "#000000"
if !transitLocalSunUp(event.contact.SunAltitudeDeg) {
fill, stroke = "#9a9a9a", "#6d6d6d"
}
fmt.Fprintf(&b, `<circle class="transit-planet-%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.92" stroke="%s" stroke-width="0.8"/>`,
strings.ToLower(event.label), x, y, planetRadius, fill, stroke)
table.Reserve(x-planetRadius, y-planetRadius, 2*planetRadius, 2*planetRadius)
}
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
// 标签放在远离日心的一侧,避开日面内的轨迹与圆面。
dx, dy := x-cx, y-cy
norm := math.Hypot(dx, dy)
if norm < 1e-6 {
dx, dy, norm = 0, -1, 1
}
text := event.name + " " + at(event.contact.JD).Format("15:04:05")
var placement svgchart.LabelPlacement
placed := false
// 相邻接触相隔仅数分钟时近处位次全被占,逐圈外推并画引线,不得丢标签。
for ring := 0; ring < 6 && !placed; ring++ {
offset := planetRadius + 12 + float64(ring)*16
candidates := []svgchart.LabelPlacement{}
for _, turn := range []float64{0, 0.5, -0.5, 1, -1} {
ux := dx/norm*math.Cos(turn) - dy/norm*math.Sin(turn)
uy := dx/norm*math.Sin(turn) + dy/norm*math.Cos(turn)
anchor := "start"
if ux < -0.3 {
anchor = "end"
} else if ux < 0.3 {
anchor = "middle"
}
candidate := svgchart.LabelPlacement{X: x + ux*offset, Y: y + uy*offset + 4, Anchor: anchor}
// 标签平移进白色图框内、接触表左侧,不能伸出画面。
box := svgchart.LabelTextBox(candidate.X, candidate.Y, 11, text, anchor)
candidate.X += math.Max(0, 30-box.X) - math.Max(0, box.X+box.Width-(width-panelWidth-44))
candidate.Y += math.Max(0, top-8-box.Y) - math.Max(0, box.Y+box.Height-(height-30))
candidates = append(candidates, candidate)
}
placement, placed = table.Place(text, 11, candidates)
if placed && transitLocalLabelGap(placement, text, x, y) > planetRadius+16 {
// 引线连到标签框上离接触点最近的位置。
box := svgchart.LabelTextBox(placement.X, placement.Y, 11, text, placement.Anchor)
endX := math.Max(box.X, math.Min(x, box.X+box.Width))
endY := math.Max(box.Y, math.Min(y, box.Y+box.Height))
fmt.Fprintf(&b, `<line class="transit-event-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#555" stroke-width="0.7" opacity="0.7"/>`,
x, y, endX, endY)
}
}
if !placed {
continue
}
fmt.Fprintf(&b, `<text class="transit-event-label" x="%.3f" y="%.3f" fill="#222" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" text-anchor="%s">%s</text>`,
placement.X, placement.Y, placement.Anchor, html.EscapeString(text))
}
writeTransitLocalContactTable(&b, local, events, at, width-panelWidth-36, top+10, panelWidth, options.Language)
writeTransitLocalFooter(&b, local, options, width, height)
b.WriteString(`</svg>`)
return b.String()
}
// transitLocalLabelGap 是接触点到标签框的最近距离。
func transitLocalLabelGap(placement svgchart.LabelPlacement, text string, x, y float64) float64 {
box := svgchart.LabelTextBox(placement.X, placement.Y, 11, text, placement.Anchor)
return math.Hypot(x-math.Max(box.X, math.Min(x, box.X+box.Width)), y-math.Max(box.Y, math.Min(y, box.Y+box.Height)))
}
func transitLocalSiteText(observer basic.Observer, language string) string {
ew, ns := "E", "N"
if language != "en" {
ew, ns = "东经", "北纬"
}
lon, lat := observer.Longitude, observer.Latitude
if lon < 0 {
ew = map[bool]string{true: "W", false: "西经"}[language == "en"]
lon = -lon
}
if lat < 0 {
ns = map[bool]string{true: "S", false: "南纬"}[language == "en"]
lat = -lat
}
if language == "en" {
return fmt.Sprintf("Site %.4f°%s %.4f°%s, height %.0f m", lon, ew, lat, ns, observer.Height)
}
return fmt.Sprintf("观测地 %s %.4f° %s %.4f°,海拔 %.0f 米", ew, lon, ns, lat, observer.Height)
}
func transitLocalSummaryText(local basic.PlanetTransitLocalResult, language string) string {
kind := map[bool]string{true: "完整凌日", false: "掠凌(无内切接触)"}[local.HasInternal]
visible := map[bool]string{true: "可见", false: "全程太阳在地平下"}[local.Visible]
if language == "en" {
kind = map[bool]string{true: "full transit", false: "grazing transit (no internal contacts)"}[local.HasInternal]
visible = map[bool]string{true: "visible", false: "Sun below the horizon throughout"}[local.Visible]
return fmt.Sprintf("Topocentric %s, %s | minimum separation %.1f″ | Sun %.1f″ | planet %.2f″",
kind, visible, local.MinimumSeparationArcsec, local.SunSemidiameterArcsec, local.PlanetSemidiameterArcsec)
}
return fmt.Sprintf("站心%s,%s | 最小角距 %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″",
kind, visible, local.MinimumSeparationArcsec, local.SunSemidiameterArcsec, local.PlanetSemidiameterArcsec)
}
// writeTransitLocalAxes 画过日心的南北、东西虚线与方向字。
func writeTransitLocalAxes(b *strings.Builder, cx, cy, radius float64, language string) {
reach := radius + 18
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx, cy-reach, cx, cy+reach)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx-reach, cy, cx+reach, cy)
labels := [4]string{"北", "南", "东", "西"}
if language == "en" {
labels = [4]string{"N", "S", "E", "W"}
}
for index, item := range []struct{ x, y float64 }{
{cx, cy - reach - 6}, {cx, cy + reach + 16}, {cx - reach - 12, cy + 5}, {cx + reach + 12, cy + 5},
} {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b5222" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
item.x, item.y, labels[index])
}
}
// writeTransitLocalTrack 画行星中心轨迹:太阳在视地平上的段为实线,地平下为灰色虚线。
func writeTransitLocalTrack(b *strings.Builder, frames []basic.PlanetTransitLocalFrame, mapX, mapY func(float64) float64) {
start := 0
for index := 1; index <= len(frames); index++ {
if index < len(frames) && transitLocalSunUp(frames[index].SunAltitudeDeg) == transitLocalSunUp(frames[start].SunAltitudeDeg) {
continue
}
// 段尾取下一段首点,相邻两段首尾相接。
end := index
if end == len(frames) {
end--
}
class, color, dash := "transit-track", "#24518a", "none"
if !transitLocalSunUp(frames[start].SunAltitudeDeg) {
class, color, dash = "transit-track-below-horizon", "#8a8a8a", "5 4"
}
fmt.Fprintf(b, `<path class="%s" d="`, class)
for position := start; position <= end; position++ {
command := "L"
if position == start {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, mapX(frames[position].PlanetXArcsec), mapY(frames[position].PlanetYArcsec))
}
fmt.Fprintf(b, `" fill="none" stroke="%s" stroke-width="1.4" stroke-dasharray="%s" stroke-linecap="round"/>`, color, dash)
start = index
}
}
// transitLocalEventPosition 取时刻最接近 jd 的采样帧位置;接触与凌甚时刻本身在采样里。
func transitLocalEventPosition(frames []basic.PlanetTransitLocalFrame, jd float64, mapX, mapY func(float64) float64) (float64, float64) {
best := 0
for index := range frames {
if math.Abs(frames[index].JD-jd) < math.Abs(frames[best].JD-jd) {
best = index
}
}
return mapX(frames[best].PlanetXArcsec), mapY(frames[best].PlanetYArcsec)
}
func writeTransitLocalContactTable(
b *strings.Builder,
local basic.PlanetTransitLocalResult,
events []transitLocalEvent,
at func(float64) time.Time,
x, y, width float64,
language string,
) {
heading := [4]string{"接触时刻", "时刻", "太阳高度", "方位"}
if language == "en" {
heading = [4]string{"Contacts", "Time", "Sun alt.", "Az."}
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="700">%s</text>`, x, y, heading[0])
columns := []float64{x, x + 78, x + 176, x + 252}
rowY := y + 26
for index, text := range []string{"", heading[1], heading[2], heading[3]} {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Arial, sans-serif" font-size="11">%s</text>`, columns[index], rowY, text)
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#bbb" stroke-width="0.8"/>`, x, rowY+6, x+width-8, rowY+6)
for _, event := range events {
rowY += 22
fill := "#222"
if !transitLocalSunUp(event.contact.SunAltitudeDeg) {
fill = "#8a8a8a"
}
cells := []string{
event.name,
at(event.contact.JD).Format("15:04:05"),
fmt.Sprintf("%+.1f°", event.contact.SunAltitudeDeg),
fmt.Sprintf("%.1f°", event.contact.SunAzimuthDeg),
}
for index, text := range cells {
fmt.Fprintf(b, `<text class="transit-contact-row" x="%.3f" y="%.3f" fill="%s" font-family="Arial, sans-serif" font-size="12">%s</text>`,
columns[index], rowY, fill, html.EscapeString(text))
}
}
rowY += 30
lines := []string{}
duration := time.Duration((local.ExternalEgress.JD - local.ExternalIngress.JD) * 86400 * float64(time.Second)).Round(time.Second)
if language == "en" {
lines = append(lines, "Duration C1–C4 "+duration.String())
} else {
lines = append(lines, "一触至四触历时 "+duration.String())
}
for _, item := range []struct {
jd float64
zh, en string
}{{local.Sunrise, "凌日中日出 ", "Sunrise during transit "}, {local.Sunset, "凌日中日落 ", "Sunset during transit "}} {
if item.jd == 0 {
continue
}
label := item.zh
if language == "en" {
label = item.en
}
lines = append(lines, label+at(item.jd).Format("15:04:05"))
}
for _, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Arial, sans-serif" font-size="12">%s</text>`, x, rowY, html.EscapeString(line))
rowY += 19
}
}
func writeTransitLocalFooter(b *strings.Builder, local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width, height float64) {
note := options.FooterNote
if note == "" {
if options.Language == "en" {
note = "Sky view, north up and east left. Topocentric contacts; Sun altitudes are geometric centre altitudes, grey parts have the Sun's centre below the apparent horizon (standard refraction)."
} else {
note = "天空视向,上北左东;站心接触时刻,太阳高度为日心几何高度,灰色部分太阳中心在视地平下(计入标准大气折射)。"
}
}
scaleNote := timenote.Scale(options.TimeScale, basic.JD2DateByZone(local.Greatest.JD, time.UTC, false), options.Location, options.Language)
lines := svgchart.FooterLinesWithScale(svgchart.WrapText(note, width-80, 12), scaleNote, width-80, 12, 3)
baselines, _ := svgchart.FooterBlock(len(lines), height, 12, svgchart.FooterLineHeight(12), svgchart.FooterBottomPadding(12)+18)
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
baselines[index], html.EscapeString(line))
}
}
+82
View File
@@ -0,0 +1,82 @@
package svg
import (
"regexp"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/internal/svgchart"
)
func TestTransitLocalSVG(t *testing.T) {
venus := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
chart, ok := TransitLocalSVG(TransitVenus, venus, 116.4, 39.9, 50, TransitLocalSVGOptions{Location: time.FixedZone("UTC+8", 8*3600)})
if !ok {
t.Fatal("Beijing 2012 render failed")
}
for _, want := range []string{"金星凌日站心图", `class="transit-track"`, "transit-planet-c1", "transit-planet-c2",
"transit-planet-g", "transit-planet-c3", "transit-planet-c4", "一触 06:10:13", "四触 12:49:17"} {
if !strings.Contains(chart, want) {
t.Fatalf("Beijing: missing %s", want)
}
}
if strings.Contains(chart, "transit-track-below-horizon") || strings.Contains(chart, "NaN") {
t.Fatal("Beijing sees the whole transit in daylight")
}
// 伦敦:日出时凌日已在进行,日出前的轨迹画成地平下虚线。
chart, ok = TransitLocalSVG(TransitVenus, venus, -0.1, 51.5, 20, TransitLocalSVGOptions{Language: "en"})
if !ok || !strings.Contains(chart, "transit-track-below-horizon") || !strings.Contains(chart, "Sunrise during transit") ||
!strings.Contains(chart, "Local Circumstances") {
t.Fatal("London must show the below-horizon track and sunrise row in English")
}
// 1999 水星掠凌,南纬 60° 只有一触、凌甚、四触。
chart, ok = TransitLocalSVG(TransitMercury, time.Date(1999, 11, 15, 0, 0, 0, 0, time.UTC), 150, -60, 0, TransitLocalSVGOptions{})
if !ok || !strings.Contains(chart, "掠凌") || strings.Contains(chart, "transit-planet-c2") || strings.Contains(chart, "transit-planet-c3") {
t.Fatal("grazing site must omit internal contacts")
}
if !strings.Contains(chart, "1999-11-15") {
t.Fatal("same-day query must find the 1999-11-15 transit")
}
// 一、二触相隔约 2 分钟,二触标签不得被避让丢弃。
chart, ok = TransitLocalSVG(TransitMercury, time.Date(2032, 11, 13, 0, 0, 0, 0, time.UTC), 116.4074, 39.9042, 0,
TransitLocalSVGOptions{Location: time.FixedZone("UTC+8", 8*3600)})
if !ok {
t.Fatal("Beijing 2032 render failed")
}
for _, want := range []string{">一触 ", ">二触 ", ">凌甚 ", ">三触 ", ">四触 "} {
if strings.Count(chart, want) != 1 {
t.Fatalf("Beijing 2032: label %s must appear exactly once on the disk", want)
}
}
for _, match := range regexp.MustCompile(`class="transit-event-label" x="([0-9.-]+)" y="([0-9.-]+)"[^>]*text-anchor="(\w+)">([^<]*)<`).FindAllStringSubmatch(chart, -1) {
x, _ := strconv.ParseFloat(match[1], 64)
y, _ := strconv.ParseFloat(match[2], 64)
if box := svgchart.LabelTextBox(x, y, 11, match[4], match[3]); box.X < 30 || box.X+box.Width > 960-344 {
t.Fatalf("Beijing 2032: label %q leaves the disk frame (box x %.1f..%.1f)", match[4], box.X, box.X+box.Width)
}
}
}
func TestTransitLocalSVGOptions(t *testing.T) {
venus := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
if _, ok := TransitLocalSVG(3, venus, 0, 0, 0, TransitLocalSVGOptions{}); ok {
t.Fatal("unsupported planet must fail")
}
if _, ok := TransitLocalSVG(TransitVenus, venus, 0, 0, 0, TransitLocalSVGOptions{
TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("CST", 8*3600)}); ok {
t.Fatal("UT1 with non-UTC location must fail")
}
if _, ok := TransitLocalSVG(TransitVenus, venus, 0, 95, 0, TransitLocalSVGOptions{}); ok {
t.Fatal("invalid latitude must fail")
}
chart, ok := TransitLocalSVG(TransitVenus, venus, 116.4, 39.9, 0, TransitLocalSVGOptions{TimeScale: astro.TimeScaleUT1, Title: "X"})
if !ok || !strings.Contains(chart, ">X<") || !strings.Contains(chart, "UT1") {
t.Fatal("UT1 labels and custom title")
}
}
+536
View File
@@ -0,0 +1,536 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/internal/timenote"
)
const (
transitMapDefaultWidth = 960
transitMapDefaultHeight = 640
)
// TransitPlanet 选择凌日行星 / selects the transiting planet.
type TransitPlanet int
const (
// TransitMercury 是水星凌日 / Mercury transit.
TransitMercury TransitPlanet = 1
// TransitVenus 是金星凌日 / Venus transit.
TransitVenus TransitPlanet = 2
)
// TransitIsochrones 是等时线开关的位组合 / bit set of contact isochrones.
type TransitIsochrones uint8
const (
// TransitIsochroneC1 画一触等时线 / draw C1 isochrones.
TransitIsochroneC1 TransitIsochrones = 1 << iota
// TransitIsochroneC2 画二触等时线;掠凌没有内切接触时忽略 / draw C2 isochrones, ignored for a grazing transit.
TransitIsochroneC2
// TransitIsochroneC3 画三触等时线;掠凌没有内切接触时忽略 / draw C3 isochrones, ignored for a grazing transit.
TransitIsochroneC3
// TransitIsochroneC4 画四触等时线 / draw C4 isochrones.
TransitIsochroneC4
// TransitIsochroneExternal 是一触与四触 / C1 and C4.
TransitIsochroneExternal = TransitIsochroneC1 | TransitIsochroneC4
// TransitIsochroneAll 是全部四个接触 / all four contacts.
TransitIsochroneAll = TransitIsochroneC1 | TransitIsochroneC2 | TransitIsochroneC3 | TransitIsochroneC4
)
// TransitMapSVGOptions 控制全球凌日可见图。
// TransitMapSVGOptions controls a global planetary-transit visibility map.
type TransitMapSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。
// Width and Height are SVG canvas dimensions. Width values below 640 and height values below 420 use the 960x640 defaults.
Width int
Height int
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
Language string
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值使用等经纬投影,正射投影视点取凌甚时的太阳直射点。
// Projection selects the map projection. The zero value uses equirectangular; orthographic centres on the subsolar point at greatest.
Projection EclipseMapProjection
// Isochrones 选择要画的站心接触等时线,可按位组合;零值不画(默认关闭,需显式开启)。
// Isochrones selects the topocentric contact isochrones to draw and may combine flags. The zero value draws none (off by default).
Isochrones TransitIsochrones
// IsochroneStep 是等时线的时间间隔;非正值按行星自动取(水星 20 秒、金星 1 分钟)。
// IsochroneStep is the isochrone spacing. Non-positive values select 20 s for Mercury and 1 min for Venus.
IsochroneStep time.Duration
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:按一触、凌甚、四触太阳中心在视地平上
// (计入标准大气折射)的区域分区;Options.Isochrones 显式开启时叠加所选接触的站心等时线。找不到凌日或选项无效时返回 false。
// TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: regions follow where the
// Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4. Topocentric isochrones of the
// selected contacts are overlaid only when Options.Isochrones enables them. It returns false when no transit is found
// or the options are invalid.
func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok || !validEclipseMapProjection(options.Projection) {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeTransitMapSVGOptions(date, options)
if !transit.HasInternal {
options.Isochrones &^= TransitIsochroneC2 | TransitIsochroneC3
}
options.Isochrones &= TransitIsochroneAll
if options.IsochroneStep <= 0 {
options.IsochroneStep = time.Minute
if planet == TransitMercury {
options.IsochroneStep = 20 * time.Second
}
}
projection := internalEclipseMapProjection(options.Projection)
if projection == "" {
projection = svgmap.ProjectionEquirectangular
}
return renderTransitMapSVG(planet, transit, options, projection), true
}
// closestPlanetTransit 返回距 date 最近的一次地心凌日;行星无效或找不到时第二个返回值为 false。
func closestPlanetTransit(planet TransitPlanet, date time.Time) (basic.PlanetTransitResult, bool) {
jd := basic.Date2JD(date.UTC())
var transit basic.PlanetTransitResult
switch planet {
case TransitMercury:
transit = basic.ClosestMercuryTransit(jd)
case TransitVenus:
transit = basic.ClosestVenusTransit(jd)
default:
return transit, false
}
return transit, transit.Valid
}
func normalizeTransitMapSVGOptions(date time.Time, options TransitMapSVGOptions) TransitMapSVGOptions {
if options.Width < 640 {
options.Width = transitMapDefaultWidth
}
if options.Height < 420 {
options.Height = transitMapDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
// transitMapContacts 是地心一触、凌甚、四触的 UTC 时刻。
type transitMapContacts struct {
start, greatest, end time.Time
internalStart, internalEnd time.Time
hasInternal bool
}
func transitMapContactsOf(transit basic.PlanetTransitResult) transitMapContacts {
at := func(jd float64) time.Time { return basic.JD2DateByZone(jd, time.UTC, false) }
contacts := transitMapContacts{
start: at(transit.ExternalIngress),
greatest: at(transit.Greatest),
end: at(transit.ExternalEgress),
hasInternal: transit.HasInternal,
}
if transit.HasInternal {
contacts.internalStart, contacts.internalEnd = at(transit.InternalIngress), at(transit.InternalEgress)
}
return contacts
}
func renderTransitMapSVG(
planet TransitPlanet,
transit basic.PlanetTransitResult,
options TransitMapSVGOptions,
projection svgmap.Projection,
) string {
contacts := transitMapContactsOf(transit)
// 底部比月食图多留两行:图例下要放可折行的说明与时标声明。
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 122)
if projection == svgmap.ProjectionOrthographic {
center := solarEclipseSubsolarPoint(contacts.greatest)
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
}
title := options.Title
if title == "" {
date := transitMapDisplayTime(contacts.greatest, options).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s Transit of %s Global Visibility", date, transitMapPlanetName(planet, "en"))
} else {
title = fmt.Sprintf("%s %s凌日全球可见图", date, transitMapPlanetName(planet, "zh"))
}
}
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>`)
writeTransitMapDefinitions(&b, frame, contacts)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
options.Width-44, options.Height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
}
fmt.Fprintf(&b, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(titleText))
writeTransitMapSummary(&b, transit, contacts, options)
frame.WriteOcean(&b)
frame.WriteGraticule(&b, "transit-map-clip")
frame.WriteLand(&b, "transit-map-clip")
writeTransitMapRegions(&b, frame, contacts)
if options.Isochrones != 0 {
writeTransitMapIsochrones(&b, frame, transit, options)
}
frame.WriteFrame(&b)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows)
b.WriteString(`</svg>`)
return b.String()
}
func transitMapPlanetName(planet TransitPlanet, language string) string {
if language == "en" {
if planet == TransitMercury {
return "Mercury"
}
return "Venus"
}
if planet == TransitMercury {
return "水星"
}
return "金星"
}
// transitMapDisplayTime 把 UTC 时刻换成图上显示的时刻:UT1 时标给 UT1 读数,否则换到 Location。
func transitMapDisplayTime(value time.Time, options TransitMapSVGOptions) time.Time {
return transitDisplayTime(value, options.TimeScale, options.Location)
}
// transitMapSunUpPath 返回 value 时刻太阳中心在视地平上的区域面路径(evenodd)与地平边界。
// 视地平计入标准大气折射:太阳中心距直射点小于 90°+34′ 即可见,太阳视差 8.8″ 忽略。
// 半径超过 90° 的球冠环在拆分日界线时分不清内外,所以改画对日点为心、半径不足 90° 的不可见冠,
// 再用图框减去它得到可见区。
func transitMapSunUpPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
subsolar := solarEclipseSubsolarPoint(value)
antisolar := svgmap.GeoPoint{Longitude: geodata.NormalizeLongitude(subsolar.Longitude + 180), Latitude: -subsolar.Latitude}
radius := 90 - basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
boundary := geodata.SphericalCircle(antisolar, radius, 360)
closed := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
path := fmt.Sprintf("M %.3f %.3f H %.3f V %.3f H %.3f Z %s",
frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height, frame.X, lunarEclipseGeoPolygonPath(boundary, frame))
return path, closed
}
// 标准大气,与 basic 站心凌日的视地平判定一致。
const (
transitMapRefractionPressureHPa = 1010.0
transitMapRefractionTemperatureC = 10.0
)
// writeTransitMapDefinitions 输出一触、凌甚、四触三个太阳在地平上半球的形状与排除掩膜,分区口径同月食可见图。
func writeTransitMapDefinitions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
b.WriteString(frame.ClipDefinition("transit-map-clip"))
startPath, _ := transitMapSunUpPath(contacts.start, frame)
endPath, _ := transitMapSunUpPath(contacts.end, frame)
greatestPath, _ := transitMapSunUpPath(contacts.greatest, frame)
fmt.Fprintf(b, `<path id="transit-sun-c1-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, startPath)
fmt.Fprintf(b, `<path id="transit-sun-c4-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, endPath)
fmt.Fprintf(b, `<path id="transit-sun-greatest-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, greatestPath)
b.WriteString(`<clipPath id="transit-sun-c4"><use href="#transit-sun-c4-shape"/></clipPath>`)
b.WriteString(`<clipPath id="transit-sun-greatest"><use href="#transit-sun-greatest-shape"/></clipPath>`)
writeLunarEclipseVisibilityMask(b, "transit-not-visible-mask", frame,
"transit-sun-c1-shape", "transit-sun-c4-shape", "transit-sun-greatest-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c1-c4-mask", frame, "transit-sun-c1-shape", "transit-sun-c4-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-greatest-mask", frame, "transit-sun-greatest-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c4-mask", frame, "transit-sun-c4-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c1-mask", frame, "transit-sun-c1-shape")
}
const (
transitMapColorEntire = "#5e846d"
transitMapColorSunrise = "#4e9da0"
transitMapColorSunset = "#e2aa4b"
transitMapColorNone = "#747b7d"
transitMapColorC1 = "#a3392f"
transitMapColorC2 = "#c0702a"
transitMapColorC3 = "#7a4ca0"
transitMapColorC4 = "#2f5ea3"
)
// writeTransitMapRegions 着色四类可见区:全程可见、日出时凌日已开始、日落时凌日未结束、不可见。
// 只有凌甚前后太阳在地平上的极区透镜与两端可见、凌甚不可见的高纬带都归入日落带,同月食可见图。
func writeTransitMapRegions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
_, startBoundary := transitMapSunUpPath(contacts.start, frame)
_, endBoundary := transitMapSunUpPath(contacts.end, frame)
fmt.Fprintf(b, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitVisibilityRegions)
if frame.IsPolar() {
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone)
} else {
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone)
}
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunrise)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorEntire)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="0.34"/></g>`, transitMapColorSunset)
b.WriteString(`</g>`)
writeEclipseMapGeoLine(b, frame, startBoundary, "c1-horizon", "#a56c16", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
writeEclipseMapGeoLine(b, frame, endBoundary, "c4-horizon", "#197a82", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
}
// writeTransitMapIsochrones 画选中接触的站心等时线:每条线是同一时刻看到该接触的地点,只保留该时刻太阳在视地平上的部分。
// 每隔若干条标一次时刻,标签按 LabelTable 避让。
func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) {
table := &svgchart.LabelTable{}
format := "15:04"
if options.IsochroneStep%time.Minute != 0 {
format = "15:04:05"
}
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
class string
color string
flag TransitIsochrones
}{
{basic.PlanetTransitC1, "c1-isochrone", transitMapColorC1, TransitIsochroneC1},
{basic.PlanetTransitC2, "c2-isochrone", transitMapColorC2, TransitIsochroneC2},
{basic.PlanetTransitC3, "c3-isochrone", transitMapColorC3, TransitIsochroneC3},
{basic.PlanetTransitC4, "c4-isochrone", transitMapColorC4, TransitIsochroneC4},
} {
if options.Isochrones&contact.flag == 0 {
continue
}
lines := basic.PlanetTransitContactIsochrones(transit, contact.kind, options.IsochroneStep)
labelEvery := transitMapIsochroneLabelEvery(len(lines))
for index, line := range lines {
segments := make([][]svgmap.GeoPoint, 0, len(line.Segments))
for _, segment := range line.Segments {
points := make([]svgmap.GeoPoint, len(segment))
for position, point := range segment {
points[position] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, points)
writeEclipseMapGeoLine(b, frame, points, contact.class, contact.color, 0.9, "none", "transit-map-clip", eclipseMapSourceTransitContactIsochrones)
}
if index%labelEvery != 0 {
continue
}
text := transitMapDisplayTime(basic.JD2DateByZone(line.JD, time.UTC, false), options).Format(format)
placement, ok := table.Place(text, 10, transitMapIsochroneLabelCandidates(segments, frame))
if !ok {
continue
}
fmt.Fprintf(b, `<text class="%s-label" x="%.3f" y="%.3f" fill="%s" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="10" text-anchor="%s">%s</text>`,
contact.class, placement.X, placement.Y, contact.color, placement.Anchor, html.EscapeString(text))
}
}
}
// transitMapIsochroneLabelEvery 让每个接触最多标八条线。
func transitMapIsochroneLabelEvery(count int) int {
every := int(math.Ceil(float64(count) / 8))
if every < 1 {
every = 1
}
return every
}
// transitMapIsochroneLabelCandidates 从最长支路的中点向两端取画布内的候选位置。
func transitMapIsochroneLabelCandidates(segments [][]svgmap.GeoPoint, frame svgmap.Frame) []svgchart.LabelPlacement {
longest := -1
for index, segment := range segments {
if longest < 0 || len(segment) > len(segments[longest]) {
longest = index
}
}
if longest < 0 {
return nil
}
segment := segments[longest]
middle := len(segment) / 2
candidates := make([]svgchart.LabelPlacement, 0, 32)
for offset := 0; offset <= middle && len(candidates) < 32; offset++ {
for _, index := range []int{middle + offset, middle - offset} {
if index < 0 || index >= len(segment) || (offset == 0 && index != middle) {
continue
}
x, y, visible := frame.Project(segment[index].Longitude, segment[index].Latitude)
if !visible || x < frame.X+24 || x > frame.X+frame.Width-24 || y < frame.Y+14 || y > frame.Y+frame.Height-6 {
continue
}
candidates = append(candidates, svgchart.LabelPlacement{X: x, Y: y - 4, Anchor: "middle"})
}
}
return candidates
}
func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResult, contacts transitMapContacts, options TransitMapSVGOptions) {
clock := func(value time.Time) string { return transitMapDisplayTime(value, options).Format("15:04:05") }
zone, _ := transitMapDisplayTime(contacts.greatest, options).Zone()
if zone == "" {
zone = "UTC"
}
if options.TimeScale == astro.TimeScaleUT1 {
zone = "UT1"
}
greatest, separation := "凌甚", "最小角距"
if options.Language == "en" {
greatest, separation = "Greatest", "least separation"
}
parts := []string{"C1 " + clock(contacts.start)}
if contacts.hasInternal {
parts = append(parts, "C2 "+clock(contacts.internalStart))
}
parts = append(parts, greatest+" "+clock(contacts.greatest))
if contacts.hasInternal {
parts = append(parts, "C3 "+clock(contacts.internalEnd))
}
parts = append(parts, "C4 "+clock(contacts.end))
line := fmt.Sprintf("%s (%s)", strings.Join(parts, " | "), zone)
fmt.Fprintf(b, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(line, float64(options.Width)-60, 13)))
note := fmt.Sprintf("%s %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″(地心)", separation,
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
if options.Language == "en" {
note = fmt.Sprintf("%s %.1f″ | solar semidiameter %.1f″ | planet semidiameter %.2f″ (geocentric)", separation,
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
}
fmt.Fprintf(b, `<text x="%.3f" y="100" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(note))
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions) int {
type entry struct {
text, color string
line bool
}
labels := []string{"全程可见", "日出时已在凌日", "日落时仍在凌日", "不可见", "一触等时线", "二触等时线", "三触等时线", "四触等时线"}
if options.Language == "en" {
labels = []string{"Entire transit", "Transit at sunrise", "Transit at sunset", "Not visible",
"C1 isochrones", "C2 isochrones", "C3 isochrones", "C4 isochrones"}
}
entries := []entry{
{labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false},
{labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false},
}
for index, item := range []struct {
flag TransitIsochrones
color string
}{
{TransitIsochroneC1, transitMapColorC1}, {TransitIsochroneC2, transitMapColorC2},
{TransitIsochroneC3, transitMapColorC3}, {TransitIsochroneC4, transitMapColorC4},
} {
if options.Isochrones&item.flag != 0 {
entries = append(entries, entry{labels[4+index], item.color, true})
}
}
width := func(item entry) float64 {
return lunarEclipseDetailedMapLegendIconWidth + svgchart.EstimatedTextWidth(item.text, lunarEclipseDetailedMapLegendFontSize)
}
available := canvasWidth - 2*lunarEclipseDetailedPageInset
var rows [][]entry
used := 0.0
for _, item := range entries {
if len(rows) == 0 || used+lunarEclipseDetailedMapLegendGap+width(item) > available {
rows = append(rows, nil)
used = -lunarEclipseDetailedMapLegendGap
}
rows[len(rows)-1] = append(rows[len(rows)-1], item)
used += lunarEclipseDetailedMapLegendGap + width(item)
}
baseY := frame.Y + frame.Height + 31
for rowIndex, row := range rows {
total := -lunarEclipseDetailedMapLegendGap
for _, item := range row {
total += lunarEclipseDetailedMapLegendGap + width(item)
}
x := math.Max(lunarEclipseDetailedPageInset, frame.X+frame.Width/2-total/2)
y := baseY + float64(rowIndex)*lunarEclipseDetailedMapLegendLineStep
for _, item := range row {
if item.line {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.4"/>`, x, y-4, x+18, y-4, item.color)
} else {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, item.color)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
x+lunarEclipseDetailedMapLegendIconWidth, y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(item.text))
x += width(item) + lunarEclipseDetailedMapLegendGap
}
}
return len(rows)
}
func writeTransitMapFooter(
b *strings.Builder,
frame svgmap.Frame,
options TransitMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
legendRows int,
) {
text := options.FooterNote
if text == "" {
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4"
if options.Isochrones != 0 {
text += "; isochrones are topocentric contact times"
}
text += "."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
if options.Isochrones != 0 {
text += ";等时线为站心接触时刻"
}
text += "。"
}
}
const footerBottomPadding = 26.0
// 页脚紧跟图例末行,不与图例重叠;行数按画布底边截断,时标声明独占最后一行。
baseline := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 18
maxWidth := float64(options.Width) - frame.X - 24
lines := svgchart.WrapText(text, maxWidth, 11)
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language), maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline, float64(options.Height)-footerBottomPadding))
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>`,
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}
+91
View File
@@ -0,0 +1,91 @@
package svg
import (
"fmt"
"math"
"strings"
"testing"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/svgmap"
)
func TestTransitMapSVGRendersRegionsAndIsochrones(t *testing.T) {
cases := []struct {
name string
planet TransitPlanet
date time.Time
}{
{"venus-2012", TransitVenus, time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)},
{"mercury-2019", TransitMercury, time.Date(2019, 11, 11, 0, 0, 0, 0, time.UTC)},
}
for _, tc := range cases {
for _, projection := range []EclipseMapProjection{"", EclipseMapProjectionOrthographic, EclipseMapProjectionNorthPolar} {
chart, ok := TransitMapSVG(tc.planet, tc.date, TransitMapSVGOptions{Location: time.UTC, Projection: projection, Isochrones: TransitIsochroneAll})
if !ok {
t.Fatalf("%s %q: render failed", tc.name, projection)
}
for _, want := range []string{"transit-entire-region", "transit-sunrise-region", "transit-sunset-region",
"c1-horizon", "c4-horizon", `class="c1-isochrone"`, `class="c2-isochrone"`, `class="c3-isochrone"`,
`class="c4-isochrone"`, "c1-isochrone-label"} {
if !strings.Contains(chart, want) {
t.Fatalf("%s %q: missing %s", tc.name, projection, want)
}
}
if strings.Contains(chart, "NaN") || strings.Contains(chart, "Inf") {
t.Fatalf("%s %q: non-finite coordinate", tc.name, projection)
}
}
}
}
func TestTransitMapSVGOptions(t *testing.T) {
date := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
if _, ok := TransitMapSVG(3, date, TransitMapSVGOptions{}); ok {
t.Fatal("unsupported planet must fail")
}
if _, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{
TimeScale: astro.TimeScaleUT1, Location: time.FixedZone("CST", 8*3600)}); ok {
t.Fatal("UT1 with non-UTC location must fail")
}
chart, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Location: time.UTC, Language: "en"})
if !ok || strings.Contains(chart, "isochrone") || !strings.Contains(chart, "Transit of Venus") {
t.Fatal("isochrones must be off by default and English labels kept")
}
chart, ok = TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Location: time.UTC, Isochrones: TransitIsochroneC2, IsochroneStep: 5 * time.Minute})
if !ok || !strings.Contains(chart, `class="c2-isochrone"`) || strings.Contains(chart, `class="c1-isochrone"`) ||
!strings.Contains(chart, "二触等时线") || strings.Contains(chart, "一触等时线") {
t.Fatal("only the selected contact must be drawn and listed in the legend")
}
}
// 太阳在视地平上的区域由图框减去对日点夜冠得到,边界距直射点 90°+地平折射。
// 曾直接画 90° 半球环,拆分日界线时分不清内外而整片填反。
func TestTransitMapSunUpBoundaryIncludesRefraction(t *testing.T) {
transit := basic.ClosestVenusTransit(basic.Date2JD(time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)))
contacts := transitMapContactsOf(transit)
radius := 90 + basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
frame := eclipseMapFrame(960, 640, svgmap.ProjectionEquirectangular, 142, 92)
for _, at := range []time.Time{contacts.start, contacts.greatest, contacts.end} {
path, _ := transitMapSunUpPath(at, frame)
if !strings.HasPrefix(path, fmt.Sprintf("M %.3f %.3f H", frame.X, frame.Y)) {
t.Fatalf("%v: sun-up path must start with the frame rectangle", at)
}
subsolar := solarEclipseSubsolarPoint(at)
_, boundary := transitMapSunUpPath(at, frame)
for _, point := range boundary {
distance := transitMapTestAngularDistance(point, subsolar)
if math.Abs(distance-radius) > 1e-6 {
t.Fatalf("%v: boundary point %.4f deg from subsolar point, want %.4f", at, distance, radius)
}
}
}
}
func transitMapTestAngularDistance(a, b svgmap.GeoPoint) float64 {
r := math.Pi / 180
cosine := math.Sin(a.Latitude*r)*math.Sin(b.Latitude*r) + math.Cos(a.Latitude*r)*math.Cos(b.Latitude*r)*math.Cos((a.Longitude-b.Longitude)*r)
return math.Acos(math.Max(-1, math.Min(1, cosine))) / r
}