feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'05.2""},
|
||||
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40'54.3""},
|
||||
}
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 图例只能画一次:横版曾经在渲染器与横版面板里各画一次,而且两处传入的中心带参数还不一致。
|
||||
// 图例只能画一次,横版渲染器与面板不得重复绘制。
|
||||
func TestSolarEclipseMapLegendRendersOnce(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user