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

- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
2026-08-06 12:00:56 +08:00
parent 25dc7ac0bc
commit 9ee2163cc7
137 changed files with 21770 additions and 1746 deletions
+54 -13
View File
@@ -63,6 +63,9 @@ type SolarEclipsePartialFootprintOptions struct {
// BoundaryPoints 是每个瞬时半影边界的角向采样点数;<=0 时使用 180。
// BoundaryPoints is the angular sample count for each instantaneous penumbral boundary; values <= 0 use 180.
BoundaryPoints int
// CentralShadowStep 是本影/反本影瞬时足迹的采样步长;<=0 时不计算。
// CentralShadowStep is the umbral/antumbral footprint step; values <= 0 disable it.
CentralShadowStep time.Duration
}
// SolarEclipsePartialAreaOptions 是 SolarEclipsePartialFootprintOptions 的兼容别名。
@@ -89,11 +92,29 @@ type SolarEclipsePartialFootprintsInfo struct {
Eclipse SolarEclipseInfo
// Footprints 是按时间采样的瞬时半影足迹, sampled instantaneous penumbral footprints.
Footprints []SolarEclipsePartialFootprint
// CentralShadowFootprints 是按时间采样的本影/反本影足迹。
// CentralShadowFootprints are sampled umbral/antumbral footprints.
CentralShadowFootprints []SolarEclipsePartialFootprint
// P1-P4 是半影与地球的外切/内切接触点;不存在的内切点保持零值。
// P1-P4 are external/internal penumbral contacts; absent internal contacts remain zero.
P1 SolarEclipsePathPoint
P2 SolarEclipsePathPoint
P3 SolarEclipsePathPoint
P4 SolarEclipsePathPoint
// U1-U4 是本影/反本影与地球的外切/内切接触点;不存在时保持零值。
// U1-U4 are external/internal umbral/antumbral contacts; absent contacts remain zero.
U1 SolarEclipsePathPoint
U2 SolarEclipsePathPoint
U3 SolarEclipsePathPoint
U4 SolarEclipsePathPoint
// Step 是实际采用的基础时间采样步长, effective base time step.
Step time.Duration
// BoundaryPoints 是实际采用的边界角向采样点数。
// BoundaryPoints is the effective angular sample count for each boundary.
BoundaryPoints int
// CentralShadowStep 是本影/反本影足迹的实际采样步长;0 表示未计算。
// CentralShadowStep is the effective umbral/antumbral footprint step; zero means disabled.
CentralShadowStep time.Duration
}
// SolarEclipsePartialAreaInfo 是 SolarEclipsePartialFootprintsInfo 的兼容别名。
@@ -103,55 +124,55 @@ type SolarEclipsePartialAreaInfo = SolarEclipsePartialFootprintsInfo
type solarEclipsePathCalculator func(float64, basic.SolarEclipsePathOptions) basic.SolarEclipsePathResult
type solarEclipsePartialFootprintsCalculator func(float64, basic.SolarEclipsePartialFootprintOptions) basic.SolarEclipsePartialFootprintsResult
// SolarEclipseCentralPath 日食中心路径查询 / central solar eclipse path query.
// SolarEclipseCentralPath 计算指定日期附近的日食中心路径,默认使用 NASA bulletin Split-K 模型。
// SolarEclipseCentralPath computes the central path near the given date, using NASA bulletin Split-K by default.
func SolarEclipseCentralPath(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return SolarEclipseCentralPathNASABulletinSplitK(date, options)
}
// SolarEclipseCentralPathNASABulletinSplitK 日食中心路径查询(NASA bulletin Split-K / central solar eclipse path query with NASA bulletin Split-K.
// SolarEclipseCentralPathNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食中心路径。
// SolarEclipseCentralPathNASABulletinSplitK computes the central path with the NASA bulletin Split-K model.
func SolarEclipseCentralPathNASABulletinSplitK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathNASABulletinSplitK)
}
// SolarEclipseCentralPathIAUSingleK 日食中心路径查询(IAU Single-K / central solar eclipse path query with IAU Single-K.
// SolarEclipseCentralPathIAUSingleK 使用 IAU Single-K 模型计算日食中心路径。
// SolarEclipseCentralPathIAUSingleK computes the central path with the IAU Single-K model.
func SolarEclipseCentralPathIAUSingleK(date time.Time, options SolarEclipsePathOptions) (SolarEclipsePath, bool) {
return solarEclipseCentralPath(date, options, basic.SolarEclipseCentralPathIAUSingleK)
}
// SolarEclipsePartialFootprints 日食偏食足迹查询 / solar eclipse penumbral footprints query.
// SolarEclipsePartialFootprints 计算指定日期附近的日食半影足迹,默认使用 NASA bulletin Split-K 模型。
// SolarEclipsePartialFootprints computes penumbral footprint samples near the given date, using NASA bulletin Split-K by default.
func SolarEclipsePartialFootprints(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipsePartialFootprintsNASABulletinSplitK 日食偏食足迹查询(NASA bulletin Split-K / solar eclipse penumbral footprints query with NASA bulletin Split-K.
// SolarEclipsePartialFootprintsNASABulletinSplitK 使用 NASA bulletin Split-K 模型计算日食半影足迹。
// SolarEclipsePartialFootprintsNASABulletinSplitK computes penumbral footprint samples with the NASA bulletin Split-K model.
func SolarEclipsePartialFootprintsNASABulletinSplitK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsNASABulletinSplitK)
}
// SolarEclipsePartialFootprintsIAUSingleK 日食偏食足迹查询(IAU Single-K / solar eclipse penumbral footprints query with IAU Single-K.
// SolarEclipsePartialFootprintsIAUSingleK 使用 IAU Single-K 模型计算日食半影足迹。
// SolarEclipsePartialFootprintsIAUSingleK computes penumbral footprint samples with the IAU Single-K model.
func SolarEclipsePartialFootprintsIAUSingleK(date time.Time, options SolarEclipsePartialFootprintOptions) (SolarEclipsePartialFootprintsInfo, bool) {
return solarEclipsePartialFootprints(date, options, basic.SolarEclipsePartialFootprintsIAUSingleK)
}
// SolarEclipsePartialArea 偏食足迹兼容包装 / compatibility wrapper for penumbral footprints.
// SolarEclipsePartialArea 计算半影足迹,是 SolarEclipsePartialFootprints 的兼容包装。
// SolarEclipsePartialArea computes penumbral footprint samples and is a compatibility wrapper for SolarEclipsePartialFootprints.
func SolarEclipsePartialArea(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprints(date, options)
}
// SolarEclipsePartialAreaNASABulletinSplitK 偏食足迹兼容包装(NASA bulletin Split-K / compatibility wrapper for penumbral footprints with NASA bulletin Split-K.
// SolarEclipsePartialAreaNASABulletinSplitK 是 SolarEclipsePartialFootprintsNASABulletinSplitK 的兼容包装。
// SolarEclipsePartialAreaNASABulletinSplitK is a compatibility wrapper for SolarEclipsePartialFootprintsNASABulletinSplitK.
func SolarEclipsePartialAreaNASABulletinSplitK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprintsNASABulletinSplitK(date, options)
}
// SolarEclipsePartialAreaIAUSingleK 偏食足迹兼容包装(IAU Single-K / compatibility wrapper for penumbral footprints with IAU Single-K.
// SolarEclipsePartialAreaIAUSingleK 是 SolarEclipsePartialFootprintsIAUSingleK 的兼容包装。
// SolarEclipsePartialAreaIAUSingleK is a compatibility wrapper for SolarEclipsePartialFootprintsIAUSingleK.
func SolarEclipsePartialAreaIAUSingleK(date time.Time, options SolarEclipsePartialAreaOptions) (SolarEclipsePartialAreaInfo, bool) {
return SolarEclipsePartialFootprintsIAUSingleK(date, options)
@@ -192,10 +213,20 @@ func solarEclipsePartialFootprints(
}
footprints := SolarEclipsePartialFootprintsInfo{
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location),
Step: solarEclipsePathStepDuration(result.StepDays),
BoundaryPoints: result.BoundaryPoints,
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
Footprints: solarEclipsePartialFootprintsFromBasic(result.Footprints, location),
CentralShadowFootprints: solarEclipsePartialFootprintsFromBasic(result.CentralShadowFootprints, location),
P1: solarEclipseOptionalPathPointFromBasic(result.P1, location),
P2: solarEclipseOptionalPathPointFromBasic(result.P2, location),
P3: solarEclipseOptionalPathPointFromBasic(result.P3, location),
P4: solarEclipseOptionalPathPointFromBasic(result.P4, location),
U1: solarEclipseOptionalPathPointFromBasic(result.U1, location),
U2: solarEclipseOptionalPathPointFromBasic(result.U2, location),
U3: solarEclipseOptionalPathPointFromBasic(result.U3, location),
U4: solarEclipseOptionalPathPointFromBasic(result.U4, location),
Step: solarEclipsePathStepDuration(result.StepDays),
BoundaryPoints: result.BoundaryPoints,
CentralShadowStep: solarEclipsePathStepDuration(result.CentralShadowStepDays),
}
return footprints, true
}
@@ -217,6 +248,9 @@ func basicSolarEclipsePartialFootprintOptions(options SolarEclipsePartialFootpri
if options.Step > 0 {
basicOptions.StepDays = options.Step.Hours() / 24
}
if options.CentralShadowStep > 0 {
basicOptions.CentralShadowStepDays = options.CentralShadowStep.Hours() / 24
}
return basicOptions
}
@@ -245,6 +279,13 @@ func solarEclipsePathPointFromBasic(point basic.SolarEclipsePathPoint, location
}
}
func solarEclipseOptionalPathPointFromBasic(point basic.SolarEclipsePathPoint, location *time.Location) SolarEclipsePathPoint {
if point.JDE == 0 {
return SolarEclipsePathPoint{}
}
return solarEclipsePathPointFromBasic(point, location)
}
func solarEclipsePartialFootprintsFromBasic(
footprints []basic.SolarEclipsePartialFootprint,
location *time.Location,
+18 -2
View File
@@ -64,8 +64,9 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
footprints, ok := SolarEclipsePartialFootprints(
time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
SolarEclipsePartialFootprintOptions{
Step: 30 * time.Minute,
BoundaryPoints: 72,
Step: 30 * time.Minute,
BoundaryPoints: 72,
CentralShadowStep: 10 * time.Minute,
},
)
if !ok {
@@ -80,6 +81,9 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
if footprints.BoundaryPoints != 72 {
t.Fatalf("boundary points mismatch: got %d want 72", footprints.BoundaryPoints)
}
if footprints.CentralShadowStep != 10*time.Minute || len(footprints.CentralShadowFootprints) == 0 {
t.Fatalf("central shadow sampling mismatch: step=%s footprints=%d", footprints.CentralShadowStep, len(footprints.CentralShadowFootprints))
}
for _, item := range []struct {
name string
@@ -89,6 +93,15 @@ func TestSolarEclipsePartialFootprintsKeepLocation(t *testing.T) {
{name: "Footprints[0].Time", tm: footprints.Footprints[0].Time},
{name: "Footprints[last].Time", tm: footprints.Footprints[len(footprints.Footprints)-1].Time},
{name: "Boundary point", tm: footprints.Footprints[0].Boundaries[0][0].Time},
{name: "P1 contact", tm: footprints.P1.Time},
{name: "P2 contact", tm: footprints.P2.Time},
{name: "P3 contact", tm: footprints.P3.Time},
{name: "P4 contact", tm: footprints.P4.Time},
{name: "U1 contact", tm: footprints.U1.Time},
{name: "U2 contact", tm: footprints.U2.Time},
{name: "U3 contact", tm: footprints.U3.Time},
{name: "U4 contact", tm: footprints.U4.Time},
{name: "Central shadow", tm: footprints.CentralShadowFootprints[0].Time},
} {
if item.tm.Location() != loc {
t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
@@ -107,6 +120,9 @@ func TestSolarEclipsePartialFootprintsWorkForPartialOnly(t *testing.T) {
if footprints.Eclipse.Type != SolarEclipsePartial {
t.Fatalf("unexpected eclipse type: got %s want %s", footprints.Eclipse.Type, SolarEclipsePartial)
}
if !footprints.U1.Time.IsZero() || !footprints.U4.Time.IsZero() || len(footprints.CentralShadowFootprints) != 0 {
t.Fatal("partial-only eclipse must not return central-shadow contacts or footprints")
}
}
func TestSolarEclipsePartialFootprintsReturnFalseForNoEvent(t *testing.T) {
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
)
const (
@@ -23,7 +24,7 @@ const (
// LunarEclipseSVGOptions 控制月食穿影 SVG 输出。
// LunarEclipseSVGOptions controls lunar eclipse shadow-path SVG output.
type LunarEclipseSVGOptions struct {
// Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
// Width Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
// Width/Height are SVG canvas size; values <= 0 use defaults.
Width int
Height int
@@ -168,7 +169,7 @@ func renderLunarEclipseSVG(
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, options.Width, options.Height, options.Width, options.Height)
b.WriteString(`<defs>`)
b.WriteString(lunarEclipseSVGMoonSymbol)
b.WriteString(svgasset.MoonFaceSymbol())
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
-9
View File
@@ -1,9 +0,0 @@
package svg
import _ "embed"
// lunarEclipseSVGMoonSymbol is a compact public-domain Moon face derived from
// labs/Full_Moon_clip_art.svg and simplified for small eclipse contact disks.
//
//go:embed lunar_eclipse_moon.svg
var lunarEclipseSVGMoonSymbol string
File diff suppressed because one or more lines are too long
+341
View File
@@ -0,0 +1,341 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
const (
lunarEclipseMapDefaultWidth = 960
lunarEclipseMapDefaultHeight = 640
)
// LunarEclipseMapSVGOptions 控制无国界全球可见性地图。
// LunarEclipseMapSVGOptions controls a border-free global visibility map.
type LunarEclipseMapSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。
// Width and Height are SVG canvas dimensions in user units. 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
// Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false.
Projection EclipseMapProjection
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// 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)
}
// 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)
}
// 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)
}
func lunarEclipseMapSVG(
date time.Time,
options LunarEclipseMapSVGOptions,
calculator func(time.Time) (eclipsecore.LunarEclipseInfo, bool),
) (string, bool) {
if !validEclipseMapProjection(options.Projection) {
return "", false
}
info, ok := calculator(date)
if !ok || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
return "", false
}
options = normalizeLunarEclipseMapSVGOptions(date, options)
projection := internalEclipseMapProjection(options.Projection)
if projection == "" {
projection = svgmap.ProjectionEquirectangular
}
return renderLunarEclipseMapSVG(info, options, projection), true
}
func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapSVGOptions) LunarEclipseMapSVGOptions {
if options.Width < 640 {
options.Width = lunarEclipseMapDefaultWidth
}
if options.Height < 420 {
options.Height = lunarEclipseMapDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
func renderLunarEclipseMapSVG(
info eclipsecore.LunarEclipseInfo,
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
) string {
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92)
startPath, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
endPath, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
title := options.Title
if title == "" {
date := info.Maximum.In(options.Location).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s %s Global Visibility", date, lunarEclipseSVGTypeName(info.Type, "en"))
} else {
title = fmt.Sprintf("%s %s全球可见图", date, lunarEclipseSVGTypeName(info.Type, "zh"))
}
}
var builder strings.Builder
fmt.Fprintf(&builder, `<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))
builder.WriteString(`<defs>`)
builder.WriteString(frame.ClipDefinition("lunar-map-clip"))
fmt.Fprintf(&builder, `<path id="lunar-visible-p1-shape" d="%s"/>`, startPath)
fmt.Fprintf(&builder, `<path id="lunar-visible-p4-shape" d="%s"/>`, endPath)
builder.WriteString(`<clipPath id="lunar-visible-p4"><use href="#lunar-visible-p4-shape"/></clipPath>`)
writeLunarEclipseVisibilityMasks(&builder, frame)
builder.WriteString(`</defs>`)
builder.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&builder, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
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(title))
writeLunarEclipseMapSummary(&builder, info, options)
frame.WriteOcean(&builder)
frame.WriteGraticule(&builder, "lunar-map-clip")
frame.WriteLand(&builder, "lunar-map-clip")
fmt.Fprintf(&builder, `<g class="lunar-visibility-regions" clip-path="url(#lunar-map-clip)">`)
writeLunarEclipseUnavailableRegion(&builder, frame)
builder.WriteString(`<use class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/>`)
builder.WriteString(`<use class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#4e9da0" fill-opacity="0.34"/>`)
builder.WriteString(`<g clip-path="url(#lunar-visible-p4)"><use class="entire-eclipse-region" href="#lunar-visible-p1-shape" fill="#5e846d" fill-opacity="0.34"/></g>`)
builder.WriteString(`</g>`)
writeEclipseMapGeoLine(&builder, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip")
writeEclipseMapGeoLine(&builder, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip")
frame.WriteFrame(&builder)
writeLunarEclipseMapLegend(&builder, frame, options.Language)
writeLunarEclipseMapFooter(&builder, frame, options, projection)
builder.WriteString(`</svg>`)
return builder.String()
}
func writeLunarEclipseVisibilityMasks(builder *strings.Builder, frame svgmap.Frame) {
writeLunarEclipseVisibilityMask(builder, "lunar-not-visible-mask", frame,
"lunar-visible-p1-shape", "lunar-visible-p4-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p4-mask", frame, "lunar-visible-p4-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-mask", frame, "lunar-visible-p1-shape")
}
func writeLunarEclipseVisibilityMask(
builder *strings.Builder,
id string,
frame svgmap.Frame,
excludedShapeIDs ...string,
) {
fmt.Fprintf(builder, `<mask id="%s" maskUnits="userSpaceOnUse" x="%.3f" y="%.3f" width="%.3f" height="%.3f">`,
id, frame.X, frame.Y, frame.Width, frame.Height)
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#ffffff"/>`,
frame.X, frame.Y, frame.Width, frame.Height)
for _, shapeID := range excludedShapeIDs {
fmt.Fprintf(builder, `<use href="#%s" fill="#000000"/>`, shapeID)
}
builder.WriteString(`</mask>`)
}
func eclipseMapFrame(width, height int, projection svgmap.Projection, top, bottom float64) svgmap.Frame {
availableWidth := float64(width) - 90
availableHeight := float64(height) - top - bottom
mapWidth := math.Min(availableWidth, availableHeight*2)
mapHeight := mapWidth / 2
if projection != svgmap.ProjectionEquirectangular {
mapWidth = math.Min(availableWidth, availableHeight)
mapHeight = mapWidth
}
return svgmap.Frame{
X: (float64(width) - mapWidth) / 2,
Y: top,
Width: mapWidth,
Height: mapHeight,
Projection: projection,
}
}
func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
center := lunarEclipseSubpoint(value)
boundary := svgmap.SphericalCircle(center, 90, 360)
path := lunarEclipseVisibilityPathForCenter(center, frame)
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return path, closedBoundary
}
func lunarEclipseVisibilityPathForCenter(center svgmap.GeoPoint, frame svgmap.Frame) string {
var builder strings.Builder
for _, polygon := range svgmap.VisibleHemispherePolygons(center, frame.Projection, 360) {
appendEclipseMapPolygonPath(&builder, frame, polygon)
}
return builder.String()
}
func lunarEclipseVisibilityPolygons(
center svgmap.GeoPoint,
projection svgmap.Projection,
samples int,
) [][]svgmap.GeoPoint {
return svgmap.VisibleHemispherePolygons(center, projection, samples)
}
func lunarEclipseSubpoint(value time.Time) svgmap.GeoPoint {
ttJDE := timeToTTJDE(value)
ra, dec := basic.HMoonTrueRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
func appendEclipseMapPolygonPath(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) {
if len(points) < 3 {
return
}
for index, point := range points {
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
continue
}
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(builder, `%s %.3f %.3f `, command, x, y)
}
builder.WriteString(`Z `)
}
func writeEclipseMapGeoLine(
builder *strings.Builder,
frame svgmap.Frame,
points []svgmap.GeoPoint,
className, color string,
strokeWidth float64,
dash, clipID string,
) {
for _, segment := range svgmap.PolylineSegments(points, frame.Projection) {
if len(segment) < 2 {
continue
}
fmt.Fprintf(builder, `<path class="%s" d="`, className)
for index, point := range segment {
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
continue
}
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(builder, `%s %.3f %.3f `, command, x, y)
}
fmt.Fprintf(builder, `" clip-path="url(#%s)" fill="none" stroke="%s" stroke-width="%.2f" stroke-dasharray="%s" stroke-linecap="round"/>`,
clipID, color, strokeWidth, dash)
}
}
func writeLunarEclipseUnavailableRegion(builder *strings.Builder, frame svgmap.Frame) {
if frame.IsPolar() {
fmt.Fprintf(builder, `<circle class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="#747b7d" fill-opacity="0.34"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2)
return
}
fmt.Fprintf(builder, `<rect class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="#747b7d" fill-opacity="0.34"/>`,
frame.X, frame.Y, frame.Width, frame.Height)
}
func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions) {
start := info.PenumbralStart.In(options.Location)
maximum := info.Maximum.In(options.Location)
end := info.PenumbralEnd.In(options.Location)
zone, _ := maximum.Zone()
if zone == "" {
zone = "UTC"
}
text := fmt.Sprintf("P1 %s | 食甚 %s | P4 %s (%s) | 半影食分 %.3f | 本影食分 %.3f",
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone,
info.PenumbralMagnitude, info.UmbralMagnitude)
if options.Language == "en" {
text = fmt.Sprintf("P1 %s | Greatest %s | P4 %s (%s) | penumbral magnitude %.3f | umbral magnitude %.3f",
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone,
info.PenumbralMagnitude, info.UmbralMagnitude)
}
fmt.Fprintf(builder, `<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(text))
}
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, language string) {
labels := []string{"全程可见", "带食月出", "带食月落", "不可见"}
colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"}
if language == "en" {
labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"}
}
y := frame.Y + frame.Height + 31
itemWidth := frame.Width / 4
for index, label := range labels {
x := frame.X + float64(index)*itemWidth
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, colors[index])
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
x+24, y, html.EscapeString(label))
}
}
func writeLunarEclipseMapFooter(
builder *strings.Builder,
frame svgmap.Frame,
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
) {
text := options.FooterNote
if text == "" {
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, float64(options.Height)-38, html.EscapeString(text))
}
func normalizeDegree180(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
+260
View File
@@ -0,0 +1,260 @@
package svg
import (
"encoding/xml"
"errors"
"io"
"math"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgmap"
)
func TestLunarEclipseMapSVGVisibilityRegions(t *testing.T) {
diagram, ok := LunarEclipseMapSVG(
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC},
)
if !ok {
t.Fatal("expected lunar-eclipse visibility map")
}
for _, want := range []string{
"月全食全球可见图", "P1", "P4", "食甚", "全程可见", "带食月出", "带食月落", "不可见",
`class="entire-eclipse-region"`, "moonrise-region", "moonset-region",
`class="p1-horizon"`, `class="p4-horizon"`, `class="land"`, "不含行政边界",
} {
if !strings.Contains(diagram, want) {
t.Fatalf("lunar-eclipse map missing %q", want)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("lunar-eclipse map is not valid XML: %v", err)
}
}
func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
cst := time.FixedZone("CST", 8*60*60)
diagram, ok := LunarEclipseMapSVG(
time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst},
)
if !ok {
t.Fatal("expected lunar-eclipse visibility map")
}
for _, want := range []string{
`mask id="lunar-not-visible-mask"`,
`mask id="lunar-not-p4-mask"`,
`mask id="lunar-not-p1-mask"`,
`class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)"`,
`class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)"`,
`class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("lunar-eclipse map does not render exclusive visibility regions: missing %q", want)
}
}
if strings.Contains(diagram, `class="entire-eclipse-region"`) && strings.Contains(diagram, `fill-opacity="0.70"`) {
t.Fatal("entire-eclipse region still uses the opaque stacked-overlay style")
}
}
func TestLunarEclipseMapSVGSupportsForcedPolarProjection(t *testing.T) {
diagram, ok := LunarEclipseMapSVG(
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Projection: EclipseMapProjectionSouthPolar},
)
if !ok {
t.Fatal("expected forced south-polar lunar-eclipse map")
}
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "南极方位等距投影"} {
if !strings.Contains(diagram, want) {
t.Fatalf("south-polar lunar-eclipse map missing %q", want)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("south-polar lunar-eclipse map is not valid XML: %v", err)
}
}
func TestLunarEclipseVisibilityPolygonsContainOnlyVisibleHemisphere(t *testing.T) {
tests := []struct {
name string
projection svgmap.Projection
center svgmap.GeoPoint
visible svgmap.GeoPoint
hidden svgmap.GeoPoint
}{
{
name: "equirectangular across antimeridian",
projection: svgmap.ProjectionEquirectangular,
center: svgmap.GeoPoint{Longitude: 170, Latitude: 12},
visible: svgmap.GeoPoint{Longitude: 170, Latitude: 12},
hidden: svgmap.GeoPoint{Longitude: -10, Latitude: -12},
},
{
name: "north polar center inside projection",
projection: svgmap.ProjectionNorthPolar,
center: svgmap.GeoPoint{Longitude: 30, Latitude: 20},
visible: svgmap.GeoPoint{Longitude: 30, Latitude: 80},
hidden: svgmap.GeoPoint{Longitude: -150, Latitude: 10},
},
{
name: "north polar center outside projection",
projection: svgmap.ProjectionNorthPolar,
center: svgmap.GeoPoint{Longitude: 30, Latitude: -20},
visible: svgmap.GeoPoint{Longitude: 30, Latitude: 10},
hidden: svgmap.GeoPoint{Longitude: 30, Latitude: 90},
},
{
name: "south polar center inside projection",
projection: svgmap.ProjectionSouthPolar,
center: svgmap.GeoPoint{Longitude: -45, Latitude: -20},
visible: svgmap.GeoPoint{Longitude: -45, Latitude: -80},
hidden: svgmap.GeoPoint{Longitude: 135, Latitude: -10},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frame := svgmap.Frame{Width: 360, Height: 360, Projection: test.projection}
polygons := lunarEclipseVisibilityPolygons(test.center, test.projection, 360)
if len(polygons) == 0 {
t.Fatal("visibility polygon is empty")
}
if !projectedPointInLunarVisibility(frame, polygons, test.visible) {
t.Fatalf("visible point %#v is outside the rendered region", test.visible)
}
if projectedPointInLunarVisibility(frame, polygons, test.hidden) {
t.Fatalf("hidden point %#v is inside the rendered region", test.hidden)
}
})
}
}
func TestLunarEclipseVisibilityPathDoesNotUseTriangleFan(t *testing.T) {
for _, projection := range []svgmap.Projection{
svgmap.ProjectionEquirectangular,
svgmap.ProjectionNorthPolar,
svgmap.ProjectionSouthPolar,
} {
frame := svgmap.Frame{Width: 720, Height: 360, Projection: projection}
if projection != svgmap.ProjectionEquirectangular {
frame.Width = 360
}
path := lunarEclipseVisibilityPathForCenter(
svgmap.GeoPoint{Longitude: 170, Latitude: 12}, frame,
)
if subpaths := strings.Count(path, "M "); subpaths != 1 {
t.Fatalf("%s visibility path has %d subpaths, want one continuous outline", projection, subpaths)
}
if strings.Contains(path, "NaN") || strings.Contains(path, "Inf") {
t.Fatalf("%s visibility path contains a non-finite coordinate", projection)
}
}
}
func TestLunarEclipseVisibilityPolygonsMatchSphericalHorizon(t *testing.T) {
tests := []struct {
projection svgmap.Projection
center svgmap.GeoPoint
}{
{svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: 170, Latitude: 18}},
{svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: -170, Latitude: -18}},
{svgmap.ProjectionEquirectangular, svgmap.GeoPoint{Longitude: 170, Latitude: 0}},
{svgmap.ProjectionNorthPolar, svgmap.GeoPoint{Longitude: 35, Latitude: 18}},
{svgmap.ProjectionNorthPolar, svgmap.GeoPoint{Longitude: 35, Latitude: -18}},
{svgmap.ProjectionSouthPolar, svgmap.GeoPoint{Longitude: -70, Latitude: -18}},
{svgmap.ProjectionSouthPolar, svgmap.GeoPoint{Longitude: -70, Latitude: 18}},
}
for _, test := range tests {
frame := svgmap.Frame{Width: 720, Height: 360, Projection: test.projection}
if test.projection != svgmap.ProjectionEquirectangular {
frame.Width = 360
}
polygons := lunarEclipseVisibilityPolygons(test.center, test.projection, 360)
for latitude := -75.0; latitude <= 75; latitude += 15 {
if test.projection == svgmap.ProjectionNorthPolar && latitude <= 0 {
continue
}
if test.projection == svgmap.ProjectionSouthPolar && latitude >= 0 {
continue
}
for longitude := -165.0; longitude <= 165; longitude += 30 {
point := svgmap.GeoPoint{Longitude: longitude, Latitude: latitude}
dot := lunarVisibilityDot(test.center, point)
if math.Abs(dot) < 0.02 {
continue
}
got := projectedPointInLunarVisibility(frame, polygons, point)
if got != (dot > 0) {
t.Fatalf("%s center=%#v point=%#v inside=%v dot=%.6f",
test.projection, test.center, point, got, dot)
}
}
}
}
}
func TestLunarEclipseMapSVGRejectsNoEventAndInvalidProjection(t *testing.T) {
if _, ok := LunarEclipseMapSVG(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), LunarEclipseMapSVGOptions{}); ok {
t.Fatal("unexpected lunar-eclipse map for a no-event date")
}
if _, ok := LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC), LunarEclipseMapSVGOptions{Projection: "invalid"}); ok {
t.Fatal("invalid projection was accepted")
}
}
func validateEclipseMapXML(value string) error {
decoder := xml.NewDecoder(strings.NewReader(value))
for {
if _, err := decoder.Token(); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
func projectedPointInLunarVisibility(frame svgmap.Frame, polygons [][]svgmap.GeoPoint, point svgmap.GeoPoint) bool {
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
return false
}
for _, polygon := range polygons {
projected := make([][2]float64, 0, len(polygon))
for _, vertex := range polygon {
px, py, projectedOK := frame.Project(vertex.Longitude, vertex.Latitude)
if projectedOK {
projected = append(projected, [2]float64{px, py})
}
}
if pointInLunarVisibilityPolygon(x, y, projected) {
return true
}
}
return false
}
func pointInLunarVisibilityPolygon(x, y float64, polygon [][2]float64) bool {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
a, b := polygon[current], polygon[previous]
crosses := (a[1] > y) != (b[1] > y)
if crosses && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] {
inside = !inside
}
}
return inside
}
func lunarVisibilityDot(center, point svgmap.GeoPoint) float64 {
centerLongitude := center.Longitude * math.Pi / 180
centerLatitude := center.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
latitude := point.Latitude * math.Pi / 180
return math.Sin(centerLatitude)*math.Sin(latitude) +
math.Cos(centerLatitude)*math.Cos(latitude)*math.Cos(longitude-centerLongitude)
}
+57
View File
@@ -0,0 +1,57 @@
package svg
import "b612.me/astro/internal/svgmap"
// EclipseMapProjection 控制全球日月食地图投影;零值根据事件几何选择投影。
// EclipseMapProjection controls a global eclipse map projection. The zero value selects the projection from the event geometry.
type EclipseMapProjection string
const (
// EclipseMapProjectionAuto 根据事件几何自动选择投影。
// EclipseMapProjectionAuto selects a projection from event geometry.
EclipseMapProjectionAuto EclipseMapProjection = ""
// EclipseMapProjectionEquirectangular 使用等经纬投影。
// EclipseMapProjectionEquirectangular uses the equirectangular projection.
EclipseMapProjectionEquirectangular EclipseMapProjection = "equirectangular"
// EclipseMapProjectionNorthPolar 使用北极方位等距投影。
// EclipseMapProjectionNorthPolar uses the north-polar azimuthal equidistant projection.
EclipseMapProjectionNorthPolar EclipseMapProjection = "north-polar"
// EclipseMapProjectionSouthPolar 使用南极方位等距投影。
// EclipseMapProjectionSouthPolar uses the south-polar azimuthal equidistant projection.
EclipseMapProjectionSouthPolar EclipseMapProjection = "south-polar"
)
func internalEclipseMapProjection(value EclipseMapProjection) svgmap.Projection {
return svgmap.Projection(value)
}
func validEclipseMapProjection(value EclipseMapProjection) bool {
switch value {
case EclipseMapProjectionAuto, EclipseMapProjectionEquirectangular,
EclipseMapProjectionNorthPolar, EclipseMapProjectionSouthPolar:
return true
default:
return false
}
}
func eclipseMapProjectionLabel(projection svgmap.Projection, language string) string {
if language == "en" {
switch projection {
case svgmap.ProjectionNorthPolar:
return "North-polar azimuthal equidistant projection"
case svgmap.ProjectionSouthPolar:
return "South-polar azimuthal equidistant projection"
default:
return "Equirectangular projection"
}
}
switch projection {
case svgmap.ProjectionNorthPolar:
return "北极方位等距投影"
case svgmap.ProjectionSouthPolar:
return "南极方位等距投影"
default:
return "等经纬投影"
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ const (
// LocalSolarEclipseSVGOptions 控制站心日食视圆 SVG 输出。
// LocalSolarEclipseSVGOptions controls local solar eclipse disk SVG output.
type LocalSolarEclipseSVGOptions struct {
// Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
// Width Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。
// Width/Height are SVG canvas size; values <= 0 use defaults.
Width int
Height int
+826
View File
@@ -0,0 +1,826 @@
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
const (
solarEclipseMapDefaultWidth = 960
solarEclipseMapDefaultHeight = 640
)
// SolarEclipseMapSVGOptions 控制无国界全球日食地图。
// SolarEclipseMapSVGOptions controls a border-free global solar-eclipse map.
type SolarEclipseMapSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。
// Width and Height are SVG canvas dimensions in user units. 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
// 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
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
MapTitle string
EventsTitle string
FooterNote string
// PartialStep 是半影足迹请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持时间序列不超过 30000 个采样点。
// PartialStep is the requested partial-footprint time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the time series within 30000 samples.
PartialStep time.Duration
// BoundaryPoints 是每个瞬时偏食足迹的角向采样数;非正值使用 180,正值限制在 12..1440。
// BoundaryPoints is the angular sample count for each instantaneous partial footprint. Values <= 0 use 180; positive values are clamped to 12..1440.
BoundaryPoints int
// PenumbralOutlineStep 控制半影边界轮廓采样;零值使用 60 分钟,负值禁用,正值小于一分钟时使用一分钟。
// PenumbralOutlineStep controls sampled penumbral boundary outlines. Zero uses 60 minutes, negative values disable them, and positive values below one minute use one minute.
PenumbralOutlineStep time.Duration
// CentralShadowStep 控制本影/反本影轮廓采样;零值使用 10 分钟,负值禁用,正值小于一分钟时使用一分钟。
// CentralShadowStep controls sampled umbral/antumbral outlines. Zero uses 10 minutes, negative values disable them, and positive values below one minute use one minute.
CentralShadowStep time.Duration
// CentralStep 是中心路径请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持基础路径不超过 30000 个采样点。
// CentralStep is the requested central-path time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the base path within 30000 samples.
CentralStep time.Duration
// TargetSpacingKM 是中心线地面间距上限,单位为千米;非正值使用 150 km,非有限值禁用加密。
// TargetSpacingKM is the requested maximum center-line ground spacing in kilometers. Values <= 0 use 150 km; non-finite values disable refinement.
TargetSpacingKM float64
// TimeLabelStep 控制中心线上的 HH:MM 标签;零值使用 30 分钟,负值禁用标签,正值小于一分钟时使用一分钟。
// TimeLabelStep controls HH:MM labels along the central line. Zero uses 30 minutes, negative values disable labels, and positive values below one minute use one minute.
TimeLabelStep time.Duration
}
type solarEclipseMapCalculators struct {
partial func(time.Time, eclipsecore.SolarEclipsePartialFootprintOptions) (eclipsecore.SolarEclipsePartialFootprintsInfo, bool)
central func(time.Time, eclipsecore.SolarEclipsePathOptions) (eclipsecore.SolarEclipsePath, bool)
local func(time.Time, float64, float64, float64) (eclipsecore.LocalSolarEclipseInfo, bool)
}
// SolarEclipseMapSVG 使用 NASA bulletin Split-K 绘制完整偏食可见范围,并在存在时绘制全食或环食中心线。
// SolarEclipseMapSVG renders the full partial-visibility sweep and, when present, the total or annular central path using NASA bulletin Split-K.
func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
return SolarEclipseMapSVGNASABulletinSplitK(date, options)
}
// SolarEclipseMapSVGNASABulletinSplitK 使用 NASA bulletin Split-K 绘制地图。
// SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map.
func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK,
central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
})
}
// SolarEclipseMapSVGIAUSingleK 使用 IAU Single-K 模型绘制地图。
// SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map.
func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK,
central: eclipsecore.SolarEclipseCentralPathIAUSingleK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK,
})
}
func solarEclipseMapSVG(
date time.Time,
options SolarEclipseMapSVGOptions,
calculators solarEclipseMapCalculators,
) (string, bool) {
if !validEclipseMapProjection(options.Projection) {
return "", false
}
options = normalizeSolarEclipseMapSVGOptions(date, options)
partial, ok := calculators.partial(date, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: options.PartialStep,
BoundaryPoints: options.BoundaryPoints,
CentralShadowStep: options.CentralShadowStep,
})
if !ok {
return "", false
}
central, hasCentral := calculators.central(date, eclipsecore.SolarEclipsePathOptions{
Step: options.CentralStep,
TargetSpacingKM: options.TargetSpacingKM,
})
local, hasLocal := calculators.local(
partial.Eclipse.GreatestEclipse,
partial.Eclipse.GreatestLongitude,
partial.Eclipse.GreatestLatitude,
0,
)
projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, options.Projection)
return renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal, options, projection), true
}
func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapSVGOptions) SolarEclipseMapSVGOptions {
if options.Width < 640 {
options.Width = solarEclipseMapDefaultWidth
}
if options.Height < 420 {
options.Height = solarEclipseMapDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
if options.PartialStep <= 0 {
options.PartialStep = 2 * time.Minute
}
if options.BoundaryPoints <= 0 {
options.BoundaryPoints = 180
}
if options.PenumbralOutlineStep < 0 {
options.PenumbralOutlineStep = 0
} else if options.PenumbralOutlineStep == 0 {
options.PenumbralOutlineStep = time.Hour
} else if options.PenumbralOutlineStep < time.Minute {
options.PenumbralOutlineStep = time.Minute
}
if options.CentralShadowStep < 0 {
options.CentralShadowStep = 0
} else if options.CentralShadowStep == 0 {
options.CentralShadowStep = 10 * time.Minute
} else if options.CentralShadowStep < time.Minute {
options.CentralShadowStep = time.Minute
}
if options.CentralStep <= 0 {
options.CentralStep = 2 * time.Minute
}
if options.TargetSpacingKM <= 0 {
options.TargetSpacingKM = 150
}
if options.TimeLabelStep < 0 {
options.TimeLabelStep = 0
} else if options.TimeLabelStep == 0 {
options.TimeLabelStep = 30 * time.Minute
} else if options.TimeLabelStep < time.Minute {
options.TimeLabelStep = time.Minute
}
return options
}
func resolveSolarEclipseMapProjection(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
requested EclipseMapProjection,
) svgmap.Projection {
if requested != EclipseMapProjectionAuto {
return internalEclipseMapProjection(requested)
}
focus := partial.Eclipse.GreatestLatitude
minimum, maximum := focus, focus
for _, footprint := range partial.Footprints {
for _, boundary := range footprint.Boundaries {
for _, point := range boundary {
minimum = math.Min(minimum, point.Latitude)
maximum = math.Max(maximum, point.Latitude)
}
}
}
if hasCentral {
for _, series := range [][]eclipsecore.SolarEclipsePathPoint{central.CenterLine, central.NorthernLimit, central.SouthernLimit} {
for _, point := range series {
minimum = math.Min(minimum, point.Latitude)
maximum = math.Max(maximum, point.Latitude)
}
}
}
resolved := svgmap.ResolveProjection("", focus, minimum, maximum)
if resolved == svgmap.ProjectionEquirectangular && focus >= 65 {
return svgmap.ProjectionNorthPolar
}
if resolved == svgmap.ProjectionEquirectangular && focus <= -65 {
return svgmap.ProjectionSouthPolar
}
return resolved
}
func renderSolarEclipseMapSVG(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
local eclipsecore.LocalSolarEclipseInfo,
hasLocal bool,
options SolarEclipseMapSVGOptions,
projection svgmap.Projection,
) string {
layout := solarEclipseMapLayoutFor(options, projection)
frame := layout.frame
title := solarEclipseMapTitle(partial.Eclipse, options)
partialPath := solarEclipsePartialSweepPath(partial, frame)
var builder strings.Builder
fmt.Fprintf(&builder, `<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))
builder.WriteString(`<defs>`)
builder.WriteString(frame.ClipDefinition("solar-map-clip"))
builder.WriteString(`</defs>`)
builder.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&builder, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
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(title))
writeSolarEclipseMapSummary(&builder, partial.Eclipse, local, hasLocal, options)
writeSolarEclipseMapSectionTitle(&builder, layout, options, hasCentral)
frame.WriteOcean(&builder)
frame.WriteGraticule(&builder, "solar-map-clip")
frame.WriteLand(&builder, "solar-map-clip")
fmt.Fprintf(&builder, `<path class="partial-eclipse-region" d="%s" clip-path="url(#solar-map-clip)" fill="#dfb84d" fill-opacity="0.46" fill-rule="nonzero"/>`, partialPath)
writeSolarEclipseTerminator(&builder, partial.Eclipse, frame)
writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options)
if hasCentral {
writeSolarEclipseCentralPath(&builder, central, frame, partial.Eclipse.Type)
}
writeSolarEclipseCentralShadowOutlines(&builder, partial.CentralShadowFootprints, frame)
if hasCentral {
writeSolarEclipseTimeMarkers(&builder, central, frame, options)
writeSolarEclipseAxisMarkers(&builder, central, frame, options)
}
writeSolarEclipseContactMarkers(&builder, partial, frame)
writeSolarEclipseGreatestMarker(&builder, partial.Eclipse, frame, options.Language)
writeSolarEclipseSubsolarMarker(&builder, partial.Eclipse, frame, options.Language)
frame.WriteFrame(&builder)
writeSolarEclipseMapLegend(&builder, layout, partial.Eclipse, hasCentral, options)
writeSolarEclipseGlobalEventsPanel(&builder, partial, central, hasCentral, layout, options)
writeSolarEclipseMapFooter(&builder, frame, options, projection)
builder.WriteString(`</svg>`)
return builder.String()
}
func solarEclipsePartialSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) string {
var builder strings.Builder
for _, footprint := range info.Footprints {
segments := make([][]svgmap.GeoPoint, 0, len(footprint.Boundaries))
for _, source := range footprint.Boundaries {
segment := make([]svgmap.GeoPoint, len(source))
for index, point := range source {
segment[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, segment)
}
boundary := svgmap.JoinPolylineSegments(segments)
if len(boundary) < 3 {
continue
}
if len(boundary) > 1 && svgmap.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) {
boundary = boundary[:len(boundary)-1]
}
polygon := solarEclipsePartialFootprintPolygon(boundary, footprint.Time, footprint.Closed, frame.Projection)
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) {
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
}
}
return builder.String()
}
func solarEclipsePartialFootprintPolygon(
boundary []svgmap.GeoPoint,
value time.Time,
closed bool,
projection svgmap.Projection,
) []svgmap.GeoPoint {
polygon := append([]svgmap.GeoPoint(nil), boundary...)
if len(boundary) < 2 {
return polygon
}
if !closed {
terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(value), 90, 360)
polygon = append(polygon, svgmap.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])...)
}
if interior, ok := solarEclipseSphericalBoundaryCentroid(polygon); ok && projection == svgmap.ProjectionEquirectangular {
polygon = solarEclipseAppendEquirectangularPoleRim(polygon, interior)
}
return polygon
}
func solarEclipseSphericalBoundaryCentroid(points []svgmap.GeoPoint) (svgmap.GeoPoint, bool) {
var sum solarEclipseMapVector
for _, point := range points {
value := solarEclipseMapUnitVector(point)
sum[0] += value[0]
sum[1] += value[1]
sum[2] += value[2]
}
length := math.Sqrt(solarEclipseMapDot(sum, sum))
if length < 1e-12 {
return svgmap.GeoPoint{}, false
}
for index := range sum {
sum[index] /= length
}
return svgmap.GeoPoint{
Longitude: math.Atan2(sum[1], sum[0]) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, sum[2]))) * 180 / math.Pi,
}, true
}
func solarEclipseAppendEquirectangularPoleRim(
polygon []svgmap.GeoPoint,
interior svgmap.GeoPoint,
) []svgmap.GeoPoint {
interiorAngle := solarEclipseSphericalPolygonAngle(polygon, interior)
if math.Abs(interiorAngle) < math.Pi {
return polygon
}
poleLatitude := 0.0
for _, candidate := range []float64{90, -90} {
angle := solarEclipseSphericalPolygonAngle(polygon, svgmap.GeoPoint{Latitude: candidate})
if math.Abs(angle) >= math.Pi && math.Signbit(angle) == math.Signbit(interiorAngle) {
poleLatitude = candidate
break
}
}
if poleLatitude == 0 {
return polygon
}
firstLongitude, lastLongitude := solarEclipseUnwrappedLongitudeEndpoints(polygon)
delta := firstLongitude - lastLongitude
if math.Abs(delta) < 180 {
return polygon
}
steps := int(math.Ceil(math.Abs(delta) / 90))
result := append([]svgmap.GeoPoint(nil), polygon...)
for step := 1; step <= steps; step++ {
longitude := lastLongitude + delta*float64(step)/float64(steps)
result = append(result, svgmap.GeoPoint{
Longitude: normalizeDegree180(longitude),
Latitude: poleLatitude,
})
}
return result
}
func solarEclipseUnwrappedLongitudeEndpoints(points []svgmap.GeoPoint) (float64, float64) {
first := points[0].Longitude
previous := first
for _, point := range points[1:] {
longitude := point.Longitude
for longitude-previous > 180 {
longitude -= 360
}
for longitude-previous < -180 {
longitude += 360
}
previous = longitude
}
return first, previous
}
type solarEclipseMapVector [3]float64
func solarEclipseSphericalPolygonAngle(points []svgmap.GeoPoint, target svgmap.GeoPoint) float64 {
if len(points) < 3 {
return 0
}
reference := solarEclipseMapUnitVector(target)
angle := 0.0
for index, point := range points {
current, currentOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(point))
next, nextOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(points[(index+1)%len(points)]))
if !currentOK || !nextOK {
return math.Copysign(2*math.Pi, angle)
}
angle += math.Atan2(
solarEclipseMapDot(reference, solarEclipseMapCross(current, next)),
solarEclipseMapDot(current, next),
)
}
return angle
}
func solarEclipseMapUnitVector(point svgmap.GeoPoint) solarEclipseMapVector {
longitude := point.Longitude * math.Pi / 180
latitude := point.Latitude * math.Pi / 180
return solarEclipseMapVector{
math.Cos(latitude) * math.Cos(longitude),
math.Cos(latitude) * math.Sin(longitude),
math.Sin(latitude),
}
}
func solarEclipseMapTangentDirection(
reference, value solarEclipseMapVector,
) (solarEclipseMapVector, bool) {
projection := solarEclipseMapDot(reference, value)
result := solarEclipseMapVector{
value[0] - projection*reference[0],
value[1] - projection*reference[1],
value[2] - projection*reference[2],
}
length := math.Sqrt(solarEclipseMapDot(result, result))
if length < 1e-12 {
return solarEclipseMapVector{}, false
}
return solarEclipseMapVector{result[0] / length, result[1] / length, result[2] / length}, true
}
func solarEclipseMapDot(a, b solarEclipseMapVector) float64 {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
}
func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector {
return solarEclipseMapVector{
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0],
}
}
func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
ttJDE := solarEclipseTimeToTTJDE(value)
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
func appendEclipseMapPolygonPathConsistent(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) {
if projectedPolygonArea(frame, points) < 0 {
points = append([]svgmap.GeoPoint(nil), points...)
reverseGeoPoints(points)
}
appendEclipseMapPolygonPath(builder, frame, points)
}
func projectedPolygonArea(frame svgmap.Frame, points []svgmap.GeoPoint) float64 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
x1, y1, ok1 := frame.Project(point.Longitude, point.Latitude)
x2, y2, ok2 := frame.Project(next.Longitude, next.Latitude)
if ok1 && ok2 {
area += x1*y2 - x2*y1
}
}
return area / 2
}
func writeSolarEclipseCentralPath(
builder *strings.Builder,
path eclipsecore.SolarEclipsePath,
frame svgmap.Frame,
eclipseType eclipsecore.SolarEclipseType,
) {
count := len(path.NorthernLimit)
paired := count == len(path.SouthernLimit) && count >= 2
for index := 0; paired && index < count; index++ {
paired = !path.NorthernLimit[index].Time.IsZero() &&
path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time)
}
if paired {
polygon := make([]svgmap.GeoPoint, 0, 2*count)
for _, point := range path.NorthernLimit[:count] {
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
for index := count - 1; index >= 0; index-- {
point := path.SouthernLimit[index]
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
color := solarEclipseCentralPathColor(eclipseType)
fmt.Fprintf(builder, `<g class="central-eclipse-band" clip-path="url(#solar-map-clip)" fill="%s" fill-opacity="0.82">`, color)
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) {
builder.WriteString(`<path d="`)
appendEclipseMapPolygonPathConsistent(builder, frame, fragment)
builder.WriteString(`"/>`)
}
builder.WriteString(`</g>`)
}
writeSolarPathLine(builder, frame, path.NorthernLimit, "northern-central-limit", "#7c2f28", 1.2, "")
writeSolarPathLine(builder, frame, path.SouthernLimit, "southern-central-limit", "#7c2f28", 1.2, "")
writeSolarPathLine(builder, frame, path.CenterLine, "solar-center-line", "#263f58", 1.8, "5 3")
}
func writeSolarPathLine(
builder *strings.Builder,
frame svgmap.Frame,
points []eclipsecore.SolarEclipsePathPoint,
className, color string,
width float64,
dash string,
) {
geographic := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
writeEclipseMapGeoLine(builder, frame, geographic, className, color, width, dash, "solar-map-clip")
}
func writeSolarEclipseGreatestMarker(
builder *strings.Builder,
info eclipsecore.SolarEclipseInfo,
frame svgmap.Frame,
language string,
) {
x, y, ok := frame.Project(info.GreatestLongitude, info.GreatestLatitude)
if !ok {
return
}
label := "食甚"
if language == "en" {
label = "Greatest"
}
fmt.Fprintf(builder, `<g class="solar-greatest-marker"><circle cx="%.3f" cy="%.3f" r="5" fill="#c44336" stroke="#fff" stroke-width="1.3"/><text x="%.3f" y="%.3f" fill="#182124" stroke="#fff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" font-weight="700" text-anchor="middle">%s</text></g>`,
x, y, x, y-10, html.EscapeString(label))
}
func solarEclipseMapTitle(info eclipsecore.SolarEclipseInfo, options SolarEclipseMapSVGOptions) string {
if options.Title != "" {
return options.Title
}
date := info.GreatestEclipse.In(options.Location).Format("2006-01-02")
if options.Language == "en" {
return fmt.Sprintf("%s %s Global Visibility", date, solarEclipseMapTypeName(info.Type, "en"))
}
return fmt.Sprintf("%s %s全球见食图", date, solarEclipseMapTypeName(info.Type, "zh"))
}
func writeSolarEclipseMapSummary(
builder *strings.Builder,
info eclipsecore.SolarEclipseInfo,
local eclipsecore.LocalSolarEclipseInfo,
hasLocal bool,
options SolarEclipseMapSVGOptions,
) {
start := info.PartialBeginOnEarth.In(options.Location)
maximum := info.GreatestEclipse.In(options.Location)
end := info.PartialEndOnEarth.In(options.Location)
zone, _ := maximum.Zone()
if zone == "" {
zone = "UTC"
}
text := fmt.Sprintf("偏食始 %s | 食甚 %s | 偏食终 %s (%s) | 食分 %.3f | Gamma %.4f",
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma)
if options.Language == "en" {
text = fmt.Sprintf("Partial begins %s | Greatest %s | Partial ends %s (%s) | magnitude %.3f | Gamma %.4f",
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma)
}
fmt.Fprintf(builder, `<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(text))
details := make([]string, 0, 4)
if info.HasCentral {
if options.Language == "en" {
details = append(details, fmt.Sprintf("path width %.1f km", info.PathWidthKM))
} else {
details = append(details, fmt.Sprintf("食带宽 %.1f km", info.PathWidthKM))
}
}
if info.HasSaros {
if options.Language == "en" {
details = append(details, fmt.Sprintf("Solar Saros %d, member %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count))
} else {
details = append(details, fmt.Sprintf("太阳沙罗 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count))
}
}
if hasLocal {
if options.Language == "en" {
details = append(details, fmt.Sprintf("Sun alt %.1f° az %.1f°", local.SunAltitude, local.SunAzimuth))
} else {
details = append(details, fmt.Sprintf("食甚点太阳高度 %.1f° 方位 %.1f°", local.SunAltitude, local.SunAzimuth))
}
if local.HasCentral && !local.CentralStart.IsZero() && !local.CentralEnd.IsZero() {
duration := local.CentralEnd.Sub(local.CentralStart)
if duration > 0 {
if options.Language == "en" {
details = append(details, "central duration "+formatSolarEclipseMapDuration(duration))
} else {
details = append(details, "中心食持续 "+formatSolarEclipseMapDuration(duration))
}
}
}
}
if len(details) > 0 {
fmt.Fprintf(builder, `<text x="%.3f" y="106" fill="#596164" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(strings.Join(details, " | ")))
}
}
func formatSolarEclipseMapDuration(value time.Duration) string {
seconds := int(math.Round(value.Seconds()))
if seconds < 0 {
seconds = -seconds
}
return fmt.Sprintf("%02d:%02d", seconds/60, seconds%60)
}
func writeSolarEclipseMapLegend(
builder *strings.Builder,
layout solarEclipseMapLayout,
info eclipsecore.SolarEclipseInfo,
hasCentral bool,
options SolarEclipseMapSVGOptions,
) {
type legendItem struct {
label string
kind string
color string
dash string
}
items := []legendItem{{label: "偏食可见区", kind: "fill", color: "#dfb84d"}}
if hasCentral {
items = append(items,
legendItem{label: solarEclipseCentralPathLabel(info.Type, options.Language), kind: "fill", color: solarEclipseCentralPathColor(info.Type)},
legendItem{label: "中心线", kind: "line", color: "#263f58", dash: "5 3"},
)
}
if options.PenumbralOutlineStep > 0 {
items = append(items, legendItem{
label: solarEclipseOutlineLegendLabel("penumbra", info.Type, options.PenumbralOutlineStep, options.Language),
kind: "line", color: "#b07a18", dash: "3 3",
})
}
items = append(items, legendItem{label: "食甚晨昏圈", kind: "line", color: "#6f7778", dash: "4 3"})
if hasCentral && options.CentralShadowStep > 0 {
items = append(items, legendItem{
label: solarEclipseOutlineLegendLabel("central", info.Type, options.CentralShadowStep, options.Language),
kind: "line", color: "#7b5a42",
})
}
contactLabel := "P/U 影锥接触"
if !hasCentral {
contactLabel = "P 半影接触"
}
if options.Language == "en" {
items[0].label = "Partial-eclipse visibility"
if hasCentral {
items[2].label = "Center line"
}
for index := range items {
if items[index].label == "食甚晨昏圈" {
items[index].label = "Terminator at greatest"
}
}
contactLabel = "P/U shadow contacts"
if !hasCentral {
contactLabel = "P penumbral contacts"
}
}
items = append(items, legendItem{label: contactLabel, kind: "contact", color: "#a52d70"})
columns := len(items)
if columns > 4 {
columns = 4
}
legendWidth := layout.panelX + layout.panelWidth - layout.frame.X
itemWidth := legendWidth / float64(columns)
baseY := layout.frame.Y + layout.frame.Height + 29
builder.WriteString(`<g class="solar-map-legend">`)
for index, item := range items {
row, column := index/columns, index%columns
x := layout.frame.X + float64(column)*itemWidth
y := baseY + float64(row)*22
switch item.kind {
case "line":
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.6" stroke-dasharray="%s"/>`,
x, y-4, x+20, y-4, item.color, item.dash)
case "contact":
fmt.Fprintf(builder, `<circle cx="%.3f" cy="%.3f" r="3" fill="%s" stroke="#ffffff" stroke-width="0.8"/>`,
x+8, y-4, item.color)
default:
fmt.Fprintf(builder, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`,
x, y-8, item.color)
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
x+25, y, html.EscapeString(item.label))
}
builder.WriteString(`</g>`)
}
func solarEclipseOutlineLegendLabel(kind string, eclipseType eclipsecore.SolarEclipseType, step time.Duration, language string) string {
interval := solarEclipseMapStepLabel(step, language)
if kind == "penumbra" {
if language == "en" {
return "Penumbral outlines (" + interval + ")"
}
return "半影时刻线(" + interval + ""
}
name := "本影轮廓"
if eclipseType == eclipsecore.SolarEclipseAnnular {
name = "反本影轮廓"
} else if eclipseType == eclipsecore.SolarEclipseHybrid {
name = "本影/反本影轮廓"
}
if language == "en" {
name = "Umbral outlines"
if eclipseType == eclipsecore.SolarEclipseAnnular {
name = "Antumbral outlines"
} else if eclipseType == eclipsecore.SolarEclipseHybrid {
name = "Umbral/antumbral outlines"
}
return name + " (" + interval + ")"
}
return name + "" + interval + ""
}
func solarEclipseMapStepLabel(step time.Duration, language string) string {
if step%time.Minute != 0 {
return step.String()
}
minutes := int(step / time.Minute)
if language == "en" {
return fmt.Sprintf("%d min", minutes)
}
return fmt.Sprintf("%d 分钟", minutes)
}
func writeSolarEclipseMapFooter(
builder *strings.Builder,
frame svgmap.Frame,
options SolarEclipseMapSVGOptions,
projection svgmap.Projection,
) {
text := options.FooterNote
if text == "" {
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, float64(options.Height)-38, html.EscapeString(text))
}
func solarEclipseMapTypeName(value eclipsecore.SolarEclipseType, language string) string {
if language == "en" {
switch value {
case eclipsecore.SolarEclipseTotal:
return "Total Solar Eclipse"
case eclipsecore.SolarEclipseAnnular:
return "Annular Solar Eclipse"
case eclipsecore.SolarEclipseHybrid:
return "Hybrid Solar Eclipse"
default:
return "Partial Solar Eclipse"
}
}
switch value {
case eclipsecore.SolarEclipseTotal:
return "日全食"
case eclipsecore.SolarEclipseAnnular:
return "日环食"
case eclipsecore.SolarEclipseHybrid:
return "全环食"
default:
return "日偏食"
}
}
func solarEclipseCentralPathLabel(value eclipsecore.SolarEclipseType, language string) string {
if language == "en" {
switch value {
case eclipsecore.SolarEclipseAnnular:
return "Annular path"
case eclipsecore.SolarEclipseHybrid:
return "Hybrid central path"
default:
return "Path of totality"
}
}
switch value {
case eclipsecore.SolarEclipseAnnular:
return "环食带"
case eclipsecore.SolarEclipseHybrid:
return "全环食中心带"
default:
return "全食带"
}
}
func solarEclipseCentralPathColor(value eclipsecore.SolarEclipseType) string {
if value == eclipsecore.SolarEclipseAnnular {
return "#a94f3f"
}
if value == eclipsecore.SolarEclipseHybrid {
return "#76506f"
}
return "#38516d"
}
func reverseGeoPoints(points []svgmap.GeoPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}
+625
View File
@@ -0,0 +1,625 @@
package svg
import (
"fmt"
"html"
"math"
"sort"
"strings"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
type solarEclipseMapLayout struct {
frame svgmap.Frame
panelX float64
panelY float64
panelWidth float64
panelHeight float64
}
type solarEclipseGlobalEventRow struct {
kind string
name string
time time.Time
point eclipsecore.SolarEclipsePathPoint
hasPoint bool
}
func solarEclipseMapLayoutFor(options SolarEclipseMapSVGOptions, projection svgmap.Projection) solarEclipseMapLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(26, math.Min(45, width*0.04))
gap := math.Max(16, math.Min(24, width*0.025))
panelWidth := math.Max(148, math.Min(238, width*0.22))
availableWidth := width - 2*margin - gap - panelWidth
bottomReserve := 92.0
if projection != svgmap.ProjectionEquirectangular {
bottomReserve = 118
}
availableHeight := math.Max(110, height-142-bottomReserve)
mapWidth := math.Min(availableWidth, availableHeight*2)
mapHeight := mapWidth / 2
if projection != svgmap.ProjectionEquirectangular {
mapWidth = math.Min(availableWidth, availableHeight)
mapHeight = mapWidth
}
mapY := 142 + math.Max(0, (availableHeight-mapHeight)/2)
groupWidth := mapWidth + gap + panelWidth
mapX := math.Max(margin, (width-groupWidth)/2)
return solarEclipseMapLayout{
frame: svgmap.Frame{
X: mapX, Y: mapY, Width: mapWidth, Height: mapHeight, Projection: projection,
},
panelX: mapX + mapWidth + gap,
panelY: mapY,
panelWidth: panelWidth,
panelHeight: mapHeight,
}
}
func writeSolarEclipseMapSectionTitle(
builder *strings.Builder,
layout solarEclipseMapLayout,
options SolarEclipseMapSVGOptions,
hasCentral bool,
) {
label := options.MapTitle
if label == "" {
if options.Language == "en" && hasCentral {
label = "Global visibility and central path"
} else if options.Language == "en" {
label = "Global visibility"
} else if hasCentral {
label = "全球见食范围与中心食带"
} else {
label = "全球见食范围"
}
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.frame.X, layout.frame.Y-10, html.EscapeString(label))
}
func writeSolarEclipseGlobalEventsPanel(
builder *strings.Builder,
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
path eclipsecore.SolarEclipsePath,
hasCentral bool,
layout solarEclipseMapLayout,
options SolarEclipseMapSVGOptions,
) {
rows := solarEclipseGlobalEventRows(partial, path, hasCentral, options.Language)
title := options.EventsTitle
if title == "" {
if options.Language == "en" {
title = "Global phases"
} else {
title = "全球阶段"
}
}
fmt.Fprintf(builder, `<g class="solar-global-events">`)
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.panelHeight)
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.panelX, layout.panelY+13, html.EscapeString(title))
rowTop := layout.panelY + 27
rowHeight := math.Max(21, (layout.panelHeight-27)/float64(len(rows)))
for index, row := range rows {
y := rowTop + float64(index)*rowHeight
if index > 0 {
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#e0e1df" stroke-width="0.8"/>`,
layout.panelX, y-5, layout.panelX+layout.panelWidth, y-5)
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="11" font-weight="700">%s</text>`,
layout.panelX, y+10, html.EscapeString(row.name))
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#1c2528" font-family="Arial, sans-serif" font-size="10" text-anchor="end">%s</text>`,
layout.panelX+layout.panelWidth, y+10, html.EscapeString(row.time.In(options.Location).Format("15:04:05")))
if row.hasPoint && rowHeight >= 45 {
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#4c585a" font-family="Arial, sans-serif" font-size="10">%s</text>`,
layout.panelX, y+27, html.EscapeString(solarEclipseFormatCoordinates(row.point.Longitude, row.point.Latitude)))
}
if row.hasPoint && rowHeight >= 52 {
detail := solarEclipseGlobalEventDetail(row, partial.Eclipse, options.Language)
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#697476" font-family="Arial, sans-serif" font-size="9">%s</text>`,
layout.panelX, y+43, html.EscapeString(detail))
}
}
builder.WriteString(`</g>`)
}
func solarEclipseGlobalEventRows(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
path eclipsecore.SolarEclipsePath,
hasCentral bool,
language string,
) []solarEclipseGlobalEventRow {
info := partial.Eclipse
names := []string{"偏食始", "中心食始", "食甚", "中心食终", "偏食终"}
if language == "en" {
names = []string{"Partial begins", "Central begins", "Greatest", "Central ends", "Partial ends"}
}
rows := make([]solarEclipseGlobalEventRow, 0, 11)
appendContact := func(name string, point eclipsecore.SolarEclipsePathPoint) {
if point.Time.IsZero() {
return
}
rows = append(rows, solarEclipseGlobalEventRow{
kind: "shadow-contact", name: name, time: point.Time, point: point, hasPoint: true,
})
}
appendContact("P1 "+names[0], partial.P1)
appendContact("P2", partial.P2)
appendContact("U1", partial.U1)
appendContact("U2", partial.U2)
if hasCentral && len(path.CenterLine) > 0 {
rows = append(rows, solarEclipseGlobalEventRow{
kind: "central-start", name: names[1], time: info.CentralBeginOnEarth,
point: path.CenterLine[0], hasPoint: true,
})
}
greatest := eclipsecore.SolarEclipsePathPoint{
Time: info.GreatestEclipse, Longitude: info.GreatestLongitude,
Latitude: info.GreatestLatitude, WidthKM: info.PathWidthKM,
}
if hasCentral {
greatest = path.Greatest
}
rows = append(rows, solarEclipseGlobalEventRow{
kind: "greatest", name: names[2], time: info.GreatestEclipse,
point: greatest, hasPoint: true,
})
appendContact("U3", partial.U3)
appendContact("U4", partial.U4)
if hasCentral && len(path.CenterLine) > 0 {
rows = append(rows, solarEclipseGlobalEventRow{
kind: "central-end", name: names[3], time: info.CentralEndOnEarth,
point: path.CenterLine[len(path.CenterLine)-1], hasPoint: true,
})
}
appendContact("P3", partial.P3)
appendContact("P4 "+names[4], partial.P4)
if len(rows) == 0 || partial.P1.Time.IsZero() {
rows = append(rows, solarEclipseGlobalEventRow{kind: "partial-start", name: names[0], time: info.PartialBeginOnEarth})
}
if partial.P4.Time.IsZero() {
rows = append(rows, solarEclipseGlobalEventRow{kind: "partial-end", name: names[4], time: info.PartialEndOnEarth})
}
sort.SliceStable(rows, func(i, j int) bool { return rows[i].time.Before(rows[j].time) })
return rows
}
func solarEclipseGlobalEventDetail(
row solarEclipseGlobalEventRow,
info eclipsecore.SolarEclipseInfo,
language string,
) string {
if row.kind == "greatest" {
if info.HasCentral {
if language == "en" {
return fmt.Sprintf("Path width %.1f km", info.PathWidthKM)
}
return fmt.Sprintf("食带宽 %.1f km", info.PathWidthKM)
}
if language == "en" {
return fmt.Sprintf("Magnitude %.3f", info.Magnitude)
}
return fmt.Sprintf("食分 %.3f", info.Magnitude)
}
if language == "en" {
return fmt.Sprintf("Sun altitude %+.1f°", row.point.SunAltitude)
}
return fmt.Sprintf("太阳高度 %+.1f°", row.point.SunAltitude)
}
func writeSolarEclipseTerminator(
builder *strings.Builder,
info eclipsecore.SolarEclipseInfo,
frame svgmap.Frame,
) {
terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(info.GreatestEclipse), 90, 360)
if len(terminator) > 0 {
terminator = append(terminator, terminator[0])
}
writeEclipseMapGeoLine(
builder, frame, terminator, "solar-greatest-terminator", "#6f7778", 1.05, "4 3", "solar-map-clip",
)
}
func writeSolarEclipsePenumbralOutlines(
builder *strings.Builder,
info eclipsecore.SolarEclipsePartialFootprintsInfo,
frame svgmap.Frame,
options SolarEclipseMapSVGOptions,
) {
if options.PenumbralOutlineStep <= 0 {
return
}
selected := solarEclipseFootprintsAtStep(
info.Footprints,
options.PenumbralOutlineStep,
options.Location,
info.Eclipse.GreatestEclipse,
)
labelPositions := make([][2]float64, 0, len(selected))
for _, footprint := range selected {
writeSolarEclipseFootprintBoundary(
builder, footprint, frame, "solar-penumbral-outline", "#b07a18", 0.75, "3 3",
)
if mapTimeDistance(footprint.Time, info.Eclipse.GreatestEclipse) <= info.Step/2 {
continue
}
x, y, ok := solarEclipseFootprintLabelPosition(footprint, frame)
if !ok || solarEclipseMapLabelOverlaps(x, y, labelPositions) {
continue
}
labelPositions = append(labelPositions, [2]float64{x, y})
labelTime := solarEclipseMapAlignedTime(footprint.Time, options.PenumbralOutlineStep, options.Location)
fmt.Fprintf(builder, `<text class="solar-penumbral-time-label" x="%.3f" y="%.3f" fill="#8b5b08" stroke="#ffffff" stroke-width="2.4" paint-order="stroke" font-family="Arial, sans-serif" font-size="8" font-weight="700" text-anchor="middle">%s</text>`,
x, y-4, html.EscapeString(labelTime.Format("15:04")))
}
}
func writeSolarEclipseCentralShadowOutlines(
builder *strings.Builder,
footprints []eclipsecore.SolarEclipsePartialFootprint,
frame svgmap.Frame,
) {
for _, footprint := range footprints {
writeSolarEclipseFootprintBoundary(
builder, footprint, frame, "solar-central-shadow-outline", "#7b5a42", 0.8, "",
)
}
}
func solarEclipseFootprintLabelPosition(
footprint eclipsecore.SolarEclipsePartialFootprint,
frame svgmap.Frame,
) (float64, float64, bool) {
bestX, bestY, bestScore := 0.0, 0.0, math.Inf(1)
centerX := frame.X + frame.Width/2
for _, boundary := range footprint.Boundaries {
for _, point := range boundary {
x, y, visible := frame.Project(point.Longitude, point.Latitude)
if !visible || x < frame.X+24 || x > frame.X+frame.Width-24 ||
y < frame.Y+14 || y > frame.Y+frame.Height-14 {
continue
}
score := y + 0.05*math.Abs(x-centerX)
if score < bestScore {
bestX, bestY, bestScore = x, y, score
}
}
}
return bestX, bestY, !math.IsInf(bestScore, 1)
}
func solarEclipseMapLabelOverlaps(x, y float64, positions [][2]float64) bool {
for _, position := range positions {
if math.Abs(x-position[0]) < 52 && math.Abs(y-position[1]) < 17 {
return true
}
}
return false
}
func solarEclipseMapAlignedTime(value time.Time, step time.Duration, location *time.Location) time.Time {
local := value.In(location)
dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
elapsed := local.Sub(dayStart)
return dayStart.Add(((elapsed + step/2) / step) * step)
}
func writeSolarEclipseFootprintBoundary(
builder *strings.Builder,
footprint eclipsecore.SolarEclipsePartialFootprint,
frame svgmap.Frame,
className, color string,
strokeWidth float64,
dash string,
) {
for _, boundary := range footprint.Boundaries {
points := make([]svgmap.GeoPoint, len(boundary))
for index, point := range boundary {
points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
writeEclipseMapGeoLine(builder, frame, points, className, color, strokeWidth, dash, "solar-map-clip")
}
}
func solarEclipseFootprintsAtStep(
footprints []eclipsecore.SolarEclipsePartialFootprint,
step time.Duration,
location *time.Location,
include time.Time,
) []eclipsecore.SolarEclipsePartialFootprint {
if len(footprints) == 0 || step <= 0 {
return nil
}
targets := make([]time.Time, 0)
for value := firstMapTimeLabelAfter(footprints[0].Time, step, location); !value.After(footprints[len(footprints)-1].Time); value = value.Add(step) {
targets = append(targets, value)
}
if !include.IsZero() {
targets = append(targets, include)
}
sort.Slice(targets, func(i, j int) bool { return targets[i].Before(targets[j]) })
selected := make([]eclipsecore.SolarEclipsePartialFootprint, 0, len(targets))
index := 0
for _, target := range targets {
for index+1 < len(footprints) &&
mapTimeDistance(footprints[index+1].Time, target) < mapTimeDistance(footprints[index].Time, target) {
index++
}
candidate := footprints[index]
if len(selected) == 0 || !selected[len(selected)-1].Time.Equal(candidate.Time) {
selected = append(selected, candidate)
}
}
return selected
}
func mapTimeDistance(a, b time.Time) time.Duration {
value := a.Sub(b)
if value < 0 {
return -value
}
return value
}
func writeSolarEclipseContactMarkers(
builder *strings.Builder,
info eclipsecore.SolarEclipsePartialFootprintsInfo,
frame svgmap.Frame,
) {
type marker struct {
name string
point eclipsecore.SolarEclipsePathPoint
color string
labelDX float64
labelDY float64
textAnchor string
}
markers := []marker{
{name: "P1", point: info.P1, color: "#a52d70", labelDX: -7, labelDY: 14, textAnchor: "end"},
{name: "P2", point: info.P2, color: "#a52d70", labelDX: 7, labelDY: -7, textAnchor: "start"},
{name: "P3", point: info.P3, color: "#a52d70", labelDX: -7, labelDY: -7, textAnchor: "end"},
{name: "P4", point: info.P4, color: "#a52d70", labelDX: 7, labelDY: 14, textAnchor: "start"},
{name: "U1", point: info.U1, color: "#a12c25", labelDX: -7, labelDY: -7, textAnchor: "end"},
{name: "U2", point: info.U2, color: "#a12c25", labelDX: 7, labelDY: 14, textAnchor: "start"},
{name: "U3", point: info.U3, color: "#a12c25", labelDX: -7, labelDY: 14, textAnchor: "end"},
{name: "U4", point: info.U4, color: "#a12c25", labelDX: 7, labelDY: -7, textAnchor: "start"},
}
for _, marker := range markers {
if marker.point.Time.IsZero() {
continue
}
x, y, visible := frame.Project(marker.point.Longitude, marker.point.Latitude)
if !visible {
continue
}
labelX := x + marker.labelDX
textAnchor := marker.textAnchor
if labelX < frame.X+18 {
labelX = x + 7
textAnchor = "start"
} else if labelX > frame.X+frame.Width-18 {
labelX = x - 7
textAnchor = "end"
}
fmt.Fprintf(builder, `<g class="solar-shadow-contact solar-contact-%s"><circle cx="%.3f" cy="%.3f" r="2.7" fill="%s" stroke="#ffffff" stroke-width="0.9"/><text x="%.3f" y="%.3f" fill="%s" stroke="#ffffff" stroke-width="2.4" paint-order="stroke" font-family="Arial, sans-serif" font-size="8" font-weight="700" text-anchor="%s">%s</text></g>`,
strings.ToLower(marker.name), x, y, marker.color, labelX, y+marker.labelDY, marker.color,
textAnchor, marker.name)
}
}
func writeSolarEclipseAxisMarkers(
builder *strings.Builder,
path eclipsecore.SolarEclipsePath,
frame svgmap.Frame,
options SolarEclipseMapSVGOptions,
) {
if len(path.CenterLine) < 2 {
return
}
points := []eclipsecore.SolarEclipsePathPoint{path.CenterLine[0], path.CenterLine[len(path.CenterLine)-1]}
for index, point := range points {
x, y, visible := frame.Project(point.Longitude, point.Latitude)
if !visible {
continue
}
label := "中心线始"
if index == 1 {
label = "中心线终"
}
if options.Language == "en" {
label = "Axis enters"
if index == 1 {
label = "Axis exits"
}
}
fmt.Fprintf(builder, `<g class="solar-axis-contact" aria-label="%s"><title>%s</title><rect x="%.3f" y="%.3f" width="5" height="5" fill="#263f58" stroke="#ffffff" stroke-width="0.8"/></g>`,
html.EscapeString(label), html.EscapeString(label), x-2.5, y-2.5)
}
}
func writeSolarEclipseSubsolarMarker(
builder *strings.Builder,
info eclipsecore.SolarEclipseInfo,
frame svgmap.Frame,
language string,
) {
point := solarEclipseSubsolarPoint(info.GreatestEclipse)
x, y, visible := frame.Project(point.Longitude, point.Latitude)
if !visible {
return
}
label := "日下点"
if language == "en" {
label = "Subsolar"
}
fmt.Fprintf(builder, `<g class="solar-subsolar-marker"><circle cx="%.3f" cy="%.3f" r="4" fill="#f4c542" stroke="#714f00" stroke-width="1"/><path d="M %.3f %.3f h 8 M %.3f %.3f v 8" fill="none" stroke="#714f00" stroke-width="1"/><text x="%.3f" y="%.3f" fill="#714f00" stroke="#ffffff" stroke-width="2.5" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text></g>`,
x, y, x-4, y, x, y-4, x, y-9, html.EscapeString(label))
}
func solarEclipseFormatCoordinates(longitude, latitude float64) string {
lonSuffix := "E"
if longitude < 0 {
lonSuffix = "W"
}
latSuffix := "N"
if latitude < 0 {
latSuffix = "S"
}
return fmt.Sprintf("%.4f°%s, %.4f°%s", math.Abs(longitude), lonSuffix, math.Abs(latitude), latSuffix)
}
func writeSolarEclipseTimeMarkers(
builder *strings.Builder,
path eclipsecore.SolarEclipsePath,
frame svgmap.Frame,
options SolarEclipseMapSVGOptions,
) {
if options.TimeLabelStep <= 0 || len(path.CenterLine) < 2 {
return
}
excluded := []time.Time{
path.Eclipse.CentralBeginOnEarth,
path.Eclipse.CentralEndOnEarth,
}
markers := solarEclipseTimeMarkerPoints(path.CenterLine, options.TimeLabelStep, options.Location, excluded)
projected := make([][2]float64, 0, len(markers))
for _, marker := range markers {
x, y, visible := frame.Project(marker.Longitude, marker.Latitude)
if !visible || x < frame.X+22 || x > frame.X+frame.Width-22 || y < frame.Y+12 || y > frame.Y+frame.Height-12 {
continue
}
tooClose := false
for _, previous := range projected {
if math.Hypot(x-previous[0], y-previous[1]) < 44 {
tooClose = true
break
}
}
if tooClose {
continue
}
projected = append(projected, [2]float64{x, y})
labelY := y - 8
if mapTimesNear(marker.Time, path.Eclipse.GreatestEclipse, solarEclipseGreatestTimeLabelWindow(options.TimeLabelStep)) {
labelY = y + 15
} else if labelY < frame.Y+10 {
labelY = y + 15
}
fmt.Fprintf(builder, `<g class="solar-time-marker"><circle cx="%.3f" cy="%.3f" r="2.3" fill="#263f58" stroke="#ffffff" stroke-width="1"/><text x="%.3f" y="%.3f" fill="#263f58" stroke="#ffffff" stroke-width="3" paint-order="stroke" font-family="Arial, sans-serif" font-size="9" font-weight="700" text-anchor="middle">%s</text></g>`,
x, y, x, labelY, html.EscapeString(marker.Time.In(options.Location).Format("15:04")))
}
}
func solarEclipseGreatestTimeLabelWindow(step time.Duration) time.Duration {
window := step / 3
if window < 10*time.Minute {
return 10 * time.Minute
}
return window
}
func solarEclipseTimeMarkerPoints(
points []eclipsecore.SolarEclipsePathPoint,
step time.Duration,
location *time.Location,
excluded []time.Time,
) []eclipsecore.SolarEclipsePathPoint {
if len(points) < 2 || step <= 0 {
return nil
}
start := points[0].Time
end := points[len(points)-1].Time
current := firstMapTimeLabelAfter(start, step, location)
window := step / 4
if window > 5*time.Minute {
window = 5 * time.Minute
}
if window < 30*time.Second {
window = 30 * time.Second
}
result := make([]eclipsecore.SolarEclipsePathPoint, 0)
segment := 1
for current.Before(end) {
for segment < len(points) && points[segment].Time.Before(current) {
segment++
}
if segment >= len(points) {
break
}
if !mapTimeNearAny(current, excluded, window) {
a, b := points[segment-1], points[segment]
span := b.Time.Sub(a.Time)
if span > 0 {
fraction := float64(current.Sub(a.Time)) / float64(span)
result = append(result, interpolateSolarEclipsePathPoint(a, b, fraction, current))
}
}
current = current.Add(step)
}
return result
}
func interpolateSolarEclipsePathPoint(
a, b eclipsecore.SolarEclipsePathPoint,
fraction float64,
value time.Time,
) eclipsecore.SolarEclipsePathPoint {
deltaLongitude := b.Longitude - a.Longitude
if deltaLongitude > 180 {
deltaLongitude -= 360
} else if deltaLongitude < -180 {
deltaLongitude += 360
}
longitude := a.Longitude + fraction*deltaLongitude
if longitude > 180 {
longitude -= 360
} else if longitude < -180 {
longitude += 360
}
return eclipsecore.SolarEclipsePathPoint{
Time: value,
Longitude: longitude,
Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude),
SunAltitude: a.SunAltitude + fraction*(b.SunAltitude-a.SunAltitude),
WidthKM: a.WidthKM + fraction*(b.WidthKM-a.WidthKM),
}
}
func firstMapTimeLabelAfter(value time.Time, step time.Duration, location *time.Location) time.Time {
local := value.In(location)
dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
elapsed := local.Sub(dayStart)
return dayStart.Add((elapsed/step + 1) * step)
}
func mapTimeNearAny(value time.Time, excluded []time.Time, window time.Duration) bool {
for _, candidate := range excluded {
if candidate.IsZero() {
continue
}
delta := value.Sub(candidate)
if delta < 0 {
delta = -delta
}
if delta <= window {
return true
}
}
return false
}
func mapTimesNear(a, b time.Time, window time.Duration) bool {
delta := a.Sub(b)
if delta < 0 {
delta = -delta
}
return delta <= window
}
+320
View File
@@ -0,0 +1,320 @@
package svg
import (
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgmap"
)
func TestSolarEclipseMapSVGTotalIncludesPartialAndCentralRegions(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute},
)
if !ok {
t.Fatal("expected total solar-eclipse map")
}
for _, want := range []string{
"日全食全球见食图", "偏食始", "偏食终", "偏食可见区", "全食带", "中心线",
"全球见食范围与中心食带", "全球阶段", "中心食始", "中心食终", "食带宽",
"太阳沙罗 139", "食甚点太阳高度", "中心食持续", "P2", "P3", "U1", "U4",
`class="partial-eclipse-region"`, `class="central-eclipse-band"`, `class="solar-center-line"`,
`class="northern-central-limit"`, `class="southern-central-limit"`, `class="solar-greatest-marker"`,
`class="solar-global-events"`, `class="solar-time-marker"`, `>17:00</text>`,
`class="solar-greatest-terminator"`, `class="solar-penumbral-outline"`,
`class="solar-central-shadow-outline"`, `class="solar-shadow-contact solar-contact-p1"`,
`class="solar-axis-contact"`, `class="solar-subsolar-marker"`,
`class="land"`, "不含行政边界",
} {
if !strings.Contains(diagram, want) {
t.Fatalf("total solar-eclipse map missing %q", want)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("total solar-eclipse map is not valid XML: %v", err)
}
}
func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute},
)
if !ok {
t.Fatal("expected partial solar-eclipse map")
}
for _, want := range []string{"日偏食全球见食图", `class="partial-eclipse-region"`, `<circle class="map-ocean"`, "北极方位等距投影"} {
if !strings.Contains(diagram, want) {
t.Fatalf("partial solar-eclipse map missing %q", want)
}
}
if strings.Contains(diagram, `class="central-eclipse-band"`) || strings.Contains(diagram, `class="solar-center-line"`) {
t.Fatal("partial-only map contains a central path")
}
if !strings.Contains(diagram, `class="solar-global-events"`) || !strings.Contains(diagram, "全球阶段") {
t.Fatal("partial-only map is missing global phase information")
}
if !strings.Contains(diagram, "全球见食范围") || strings.Contains(diagram, "全球见食范围与中心食带") {
t.Fatal("partial-only map claims to contain a central path")
}
if strings.Contains(diagram, `class="solar-time-marker"`) {
t.Fatal("partial-only map contains center-line time markers")
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("partial solar-eclipse map is not valid XML: %v", err)
}
}
func TestSolarEclipseMapSVGCanDisableTimeLabels(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{Location: time.UTC, TimeLabelStep: -1},
)
if !ok {
t.Fatal("expected total solar-eclipse map")
}
if strings.Contains(diagram, `class="solar-time-marker"`) {
t.Fatal("disabled solar time labels were rendered")
}
}
func TestSolarEclipseMapSVGCanDisableSampledShadowOutlines(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{
Location: time.UTC,
PenumbralOutlineStep: -1,
CentralShadowStep: -1,
},
)
if !ok {
t.Fatal("expected total solar-eclipse map")
}
if strings.Contains(diagram, `class="solar-penumbral-outline"`) ||
strings.Contains(diagram, `class="solar-central-shadow-outline"`) {
t.Fatal("disabled sampled shadow outlines were rendered")
}
if strings.Contains(diagram, "半影时刻线") || strings.Contains(diagram, "本影轮廓") {
t.Fatal("disabled sampled shadow outlines remain in the legend")
}
if !strings.Contains(diagram, `class="solar-greatest-terminator"`) ||
!strings.Contains(diagram, `class="solar-shadow-contact solar-contact-u1"`) {
t.Fatal("disabling sampled outlines removed required contact geometry")
}
}
func TestSolarEclipseMapSVGExplainsSampledShadowLines(t *testing.T) {
cst := time.FixedZone("UTC+8", 8*60*60)
date := time.Date(2035, 9, 2, 12, 0, 0, 0, cst)
options := SolarEclipseMapSVGOptions{
Width: 1200, Height: 800, Location: cst,
Projection: EclipseMapProjectionEquirectangular,
}
normalized := normalizeSolarEclipseMapSVGOptions(date, options)
if normalized.PenumbralOutlineStep != time.Hour {
t.Fatalf("default penumbral outline step = %s, want 1h", normalized.PenumbralOutlineStep)
}
explicit := normalizeSolarEclipseMapSVGOptions(date, SolarEclipseMapSVGOptions{PenumbralOutlineStep: 30 * time.Minute})
if explicit.PenumbralOutlineStep != 30*time.Minute {
t.Fatalf("explicit penumbral outline step = %s, want 30m", explicit.PenumbralOutlineStep)
}
diagram, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatal("expected 2035 total solar-eclipse map")
}
for _, want := range []string{
"半影时刻线(60 分钟)", "食甚晨昏圈", "本影轮廓(10 分钟)", "P/U 影锥接触",
`class="solar-penumbral-time-label"`, `class="solar-map-legend"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("solar-eclipse map does not explain %q", want)
}
}
}
func TestSolarEclipseMapSVGAntarcticEventUsesSouthPolarProjection(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2021, 12, 4, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute},
)
if !ok {
t.Fatal("expected Antarctic total solar-eclipse map")
}
for _, want := range []string{`<circle class="map-ocean"`, `<circle class="map-frame"`, "南极方位等距投影", `class="central-eclipse-band"`} {
if !strings.Contains(diagram, want) {
t.Fatalf("Antarctic solar-eclipse map missing %q", want)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("Antarctic solar-eclipse map is not valid XML: %v", err)
}
}
func TestSolarEclipseMapSVGAnnularLabelsCentralBand(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2023, 10, 14, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{PartialStep: 15 * time.Minute},
)
if !ok {
t.Fatal("expected annular solar-eclipse map")
}
if !strings.Contains(diagram, "日环食全球见食图") || !strings.Contains(diagram, "环食带") {
t.Fatal("annular map does not distinguish the annular path")
}
if !strings.Contains(diagram, "反本影轮廓(10 分钟)") {
t.Fatal("annular map does not explain the antumbral outlines")
}
}
func TestSolarEclipseMapSVG2012DoesNotFillAntimeridianSpikes(t *testing.T) {
cst := time.FixedZone("UTC+8", 8*60*60)
date := time.Date(2012, 5, 21, 12, 0, 0, 0, cst)
options := SolarEclipseMapSVGOptions{
Width: 1200,
Height: 800,
Location: cst,
Projection: EclipseMapProjectionEquirectangular,
PartialStep: 2 * time.Minute,
}
diagram, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatal("expected 2012 annular solar-eclipse map")
}
frame := solarEclipseMapLayoutFor(
normalizeSolarEclipseMapSVGOptions(date, options),
svgmap.ProjectionEquirectangular,
).frame
for _, point := range []svgmap.GeoPoint{
{Longitude: -170, Latitude: -50},
{Longitude: 170, Latitude: -50},
} {
if solarPartialRegionContainsGeoPoint(t, diagram, frame, point) {
t.Fatalf("known invisible point %#v is inside the rendered partial-eclipse region", point)
}
}
visible := svgmap.GeoPoint{Longitude: 0, Latitude: 85}
if !solarPartialRegionContainsGeoPoint(t, diagram, frame, visible) {
t.Fatalf("known visible Arctic point %#v is outside the rendered partial-eclipse region", visible)
}
}
func TestSolarEclipseMapSVG2012SupportsNorthPolarProjection(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(
time.Date(2012, 5, 21, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{
Projection: EclipseMapProjectionNorthPolar,
PartialStep: 10 * time.Minute,
},
)
if !ok {
t.Fatal("expected 2012 annular solar-eclipse north-polar map")
}
for _, want := range []string{
`<circle class="map-ocean"`, `<circle class="map-frame"`,
"北极方位等距投影", `class="central-eclipse-band"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("2012 north-polar solar-eclipse map missing %q", want)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("2012 north-polar solar-eclipse map is not valid XML: %v", err)
}
}
func TestSolarEclipseMapSVGRejectsNoEventAndInvalidProjection(t *testing.T) {
if _, ok := SolarEclipseMapSVG(time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{}); ok {
t.Fatal("unexpected solar-eclipse map for a no-event date")
}
if _, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{Projection: "invalid"}); ok {
t.Fatal("invalid projection was accepted")
}
}
func solarPartialRegionContainsGeoPoint(
t *testing.T,
diagram string,
frame svgmap.Frame,
point svgmap.GeoPoint,
) bool {
t.Helper()
const prefix = `<path class="partial-eclipse-region" d="`
start := strings.Index(diagram, prefix)
if start < 0 {
t.Fatal("partial-eclipse SVG path is missing")
}
value := diagram[start+len(prefix):]
end := strings.IndexByte(value, '"')
if end < 0 {
t.Fatal("partial-eclipse SVG path is malformed")
}
polygons := solarSVGPathPolygons(t, value[:end])
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
return false
}
for _, polygon := range polygons {
if solarSVGPointInPolygon(x, y, polygon) {
return true
}
}
return false
}
func solarSVGPathPolygons(t *testing.T, path string) [][][2]float64 {
t.Helper()
fields := strings.Fields(path)
polygons := make([][][2]float64, 0)
var current [][2]float64
for index := 0; index < len(fields); {
switch fields[index] {
case "M":
if len(current) > 0 {
polygons = append(polygons, current)
}
current = nil
index++
case "L":
index++
case "Z":
if len(current) > 0 {
polygons = append(polygons, current)
current = nil
}
index++
default:
if index+1 >= len(fields) {
t.Fatalf("incomplete SVG coordinate at token %d", index)
}
x, err := strconv.ParseFloat(fields[index], 64)
if err != nil {
t.Fatalf("invalid SVG x coordinate %q: %v", fields[index], err)
}
y, err := strconv.ParseFloat(fields[index+1], 64)
if err != nil {
t.Fatalf("invalid SVG y coordinate %q: %v", fields[index+1], err)
}
current = append(current, [2]float64{x, y})
index += 2
}
}
if len(current) > 0 {
polygons = append(polygons, current)
}
return polygons
}
func solarSVGPointInPolygon(x, y float64, polygon [][2]float64) bool {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
a, b := polygon[current], polygon[previous]
if (a[1] > y) != (b[1] > y) && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] {
inside = !inside
}
}
return inside
}