feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
+54
-13
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"/>`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 "等经纬投影"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user