2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
1373 lines
56 KiB
Go
1373 lines
56 KiB
Go
package svg
|
||
|
||
import (
|
||
"fmt"
|
||
"html"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"b612.me/astro/basic"
|
||
eclipsecore "b612.me/astro/eclipse"
|
||
"b612.me/astro/internal/geodata"
|
||
"b612.me/astro/internal/solarclosure"
|
||
"b612.me/astro/internal/svgchart"
|
||
"b612.me/astro/internal/svgmap"
|
||
)
|
||
|
||
const (
|
||
solarEclipseMapDefaultWidth = 960
|
||
solarEclipseMapDefaultHeight = 640
|
||
// 真实可用下限:更窄的画布上横版的地图框与右栏数据网格必然水平重叠,面板行距也会压到 1 px 以下。
|
||
solarEclipseMapMinWidth = 800
|
||
solarEclipseMapMinHeight = 560
|
||
// 等时线时刻取值上限,避免极长事件请求出上万条曲线。
|
||
solarEclipseMapGreatestTimeMaxLevels = 64
|
||
// NASA 全球图的等时线间隔,属于推荐取值而不是默认值:不请求就不画。
|
||
solarEclipseMapDefaultGreatestTimeStep = 30 * time.Minute
|
||
// 偏食足迹扫描的最密步长:并集需要一整条自洽的扫描序列,更密的请求只会成倍放大成本。
|
||
solarEclipseMapPartialSweepMinStep = 2 * time.Minute
|
||
// 中心线偏离包络不得超过 2 km;南北限界端部允许偏离食带宽的 1/4。
|
||
solarEclipseCentralEnvelopeAxisMissToleranceKM = 2.0
|
||
solarEclipseCentralEnvelopeLimitMissFraction = 0.25
|
||
)
|
||
|
||
// SolarEclipseMapSVGOptions 控制无国界全球日食地图。
|
||
// SolarEclipseMapSVGOptions controls a border-free global solar-eclipse map.
|
||
type SolarEclipseMapSVGOptions struct {
|
||
// Width 和 Height 是 SVG 画布尺寸;宽度小于 800 或高度小于 560 时使用 960x640 默认值,
|
||
// 这两个下限是横版地图框与两栏数据网格不重叠、面板行距不塌陷的最小值。
|
||
// 图例行数随可选图层增加,因此更窄或更矮的画布、以及开着全部可选图层的下限画布会返回 false。
|
||
// Width and Height are SVG canvas dimensions in user units. Width values below 800 and height
|
||
// values below 560 use the 960x640 defaults; those minima keep the landscape map frame and the
|
||
// two-column data grid apart with a non-collapsed panel row spacing. The legend gains rows with
|
||
// the optional layers, so canvases too small for that stack make the renderer return false.
|
||
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 是“全球阶段”数据块的标题,该块给出食甚经纬度与地球范围的中心食始/终;
|
||
// 为空时使用该块的本地化默认标题。
|
||
// EventsTitle titles the global-phases block that carries the greatest-eclipse coordinates
|
||
// and the earth-wide central begin/end; empty uses that block's localized default title.
|
||
EventsTitle string
|
||
FooterNote string
|
||
|
||
// PartialStep 是半影足迹请求的时间步长;非正值与小于两分钟的正值都使用两分钟。
|
||
// 偏食区填充是瞬时足迹的并集,成本随采样数成倍增长,而并集必须由一整条自洽的扫描序列生成,
|
||
// 更密的请求不改变产物(长事件还会为 30000 个采样点的上限进一步放大步长)。
|
||
// PartialStep is the requested partial-footprint time step. Values <= 0 and positive values below
|
||
// two minutes both use two minutes: the partial region is a union of instantaneous footprints whose
|
||
// cost grows with the sample count, and that union needs one self-consistent sweep, so a denser
|
||
// request does not change the product (long events enlarge the step further to stay 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 请求瞬时半影边界轮廓并给出采样间隔;默认不画(零值或负值都不画),
|
||
// 正值小于一分钟时使用一分钟。NASA 全球图没有这族线,打开会明显遮挡地球。
|
||
// PenumbralOutlineStep requests sampled instantaneous penumbral outlines and gives their
|
||
// interval. They are off by default (zero or negative draws none), and positive values below
|
||
// one minute use one minute. NASA world maps omit this family, which otherwise hides the globe.
|
||
PenumbralOutlineStep time.Duration
|
||
// CentralShadowStep 请求瞬时本影/反本影轮廓并给出采样间隔;默认不画(零值或负值都不画),
|
||
// 正值小于一分钟时使用一分钟。它同时决定本影足迹的采样密度。
|
||
// CentralShadowStep requests sampled instantaneous umbral/antumbral outlines and gives their
|
||
// interval. They are off by default (zero or negative draws none), and positive values below
|
||
// one minute use one minute. It also sets the umbral footprint sampling density.
|
||
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 是中心线地面间距上限,单位为千米;非正值(含 -Inf)使用 150 km,NaN 与 +Inf 禁用加密。
|
||
// TargetSpacingKM is the requested maximum center-line ground spacing in kilometers. Values <= 0 (including -Inf) use 150 km; NaN and +Inf 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
|
||
// GreatestTimeStep 控制食甚时刻等时线的时间间隔;默认不画(零值或负值都不画),
|
||
// 正值小于一分钟时使用一分钟;NASA 全球图用 solarEclipseMapDefaultGreatestTimeStep 的间隔。
|
||
// 等时线是固定时刻残差的零集延拓,成本正比于曲线长度而不是可见域面积。
|
||
// GreatestTimeStep controls the spacing of greatest-eclipse time isolines. They are off by
|
||
// default (zero or negative draws none), positive values below one minute use one minute,
|
||
// and solarEclipseMapDefaultGreatestTimeStep is the spacing of NASA world maps. Each isochrone
|
||
// continues the zero set of a fixed-instant residual, so its cost scales with curve length
|
||
// rather than with the visible area.
|
||
GreatestTimeStep time.Duration
|
||
// MagnitudeValues 是要绘制的地方最大食分等值线电平;nil 使用 NASA 全球图常用的
|
||
// 0.2/0.4/0.6/0.8,显式空切片关闭,非空切片按给定电平绘制。
|
||
// MagnitudeValues lists the local maximum-magnitude contour levels to draw. Nil uses the
|
||
// 0.2/0.4/0.6/0.8 set common to NASA world maps, an explicitly empty slice disables them,
|
||
// and a non-empty slice draws exactly those levels.
|
||
MagnitudeValues []float64
|
||
}
|
||
|
||
type solarEclipseMapCalculators struct {
|
||
global func(time.Time) (eclipsecore.SolarEclipseInfo, bool)
|
||
panel func(time.Time) (eclipsecore.SolarEclipseGeocentricPanel, bool)
|
||
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{
|
||
global: eclipsecore.SolarEclipseOnDateNASABulletinSplitK,
|
||
panel: eclipsecore.SolarEclipseGeocentricPanelAt,
|
||
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{
|
||
global: eclipsecore.SolarEclipseOnDateIAUSingleK,
|
||
panel: eclipsecore.SolarEclipseGeocentricPanelIAUSingleK,
|
||
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)
|
||
// 日期门与核心一致:当天没有日食就不出图。偏食足迹是“取最近一次”语义,
|
||
// 缺这道门会把邻近日期的图当成当天的图交出去。
|
||
global, ok := calculators.global(date)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
greatestTimes := solarEclipseGreatestTimeLevels(global, options)
|
||
partial, ok := calculators.partial(date, eclipsecore.SolarEclipsePartialFootprintOptions{
|
||
Step: options.PartialStep,
|
||
BoundaryPoints: options.BoundaryPoints,
|
||
CentralShadowStep: options.CentralShadowStep,
|
||
GreatestTimeValues: greatestTimes,
|
||
MagnitudeValues: options.MagnitudeValues,
|
||
})
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
central, hasCentral := calculators.central(date, eclipsecore.SolarEclipsePathOptions{
|
||
Step: options.CentralStep,
|
||
TargetSpacingKM: options.TargetSpacingKM,
|
||
SkipCentralBand: true,
|
||
})
|
||
local, hasLocal := calculators.local(
|
||
partial.Eclipse.GreatestEclipse,
|
||
partial.Eclipse.GreatestLongitude,
|
||
partial.Eclipse.GreatestLatitude,
|
||
0,
|
||
)
|
||
projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, options.Projection)
|
||
// 正射图的视点固定取食甚点,让整条中心食带尽量落在可见半球内。
|
||
center := svgmap.GeoPoint{
|
||
Longitude: partial.Eclipse.GreatestLongitude,
|
||
Latitude: partial.Eclipse.GreatestLatitude,
|
||
}
|
||
geocentric, hasGeocentric := eclipsecore.SolarEclipseGeocentricPanel{}, false
|
||
if calculators.panel != nil {
|
||
geocentric, hasGeocentric = calculators.panel(date)
|
||
}
|
||
plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric,
|
||
options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial))
|
||
// 数据块行距或图例带放不下时拒绝该画布,而不是把压叠的文字画出来。
|
||
if !plan.fits() {
|
||
return "", false
|
||
}
|
||
return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric,
|
||
options, projection), true
|
||
}
|
||
|
||
// solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。
|
||
func solarEclipseMapHasCentralBand(partial eclipsecore.SolarEclipsePartialFootprintsInfo) bool {
|
||
return len(partial.CentralBandFootprints) > 0 || len(partial.CentralShadowFootprints) > 0 ||
|
||
len(partial.CentralBandSegments) > 0
|
||
}
|
||
|
||
// solarEclipseMapPlan 是渲染前的版面:数据块、图例分行与版式几何,由一处分块逻辑同时供占位与绘制。
|
||
type solarEclipseMapPlan struct {
|
||
cells []solarEclipsePanelCell
|
||
layout solarEclipseMapLayout
|
||
legend [][]solarEclipseMapLegendItem
|
||
legendBottom float64
|
||
footerTop float64
|
||
}
|
||
|
||
func solarEclipseMapPlanFor(
|
||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||
local eclipsecore.LocalSolarEclipseInfo,
|
||
hasLocal bool,
|
||
geocentric eclipsecore.SolarEclipseGeocentricPanel,
|
||
hasGeocentric bool,
|
||
options SolarEclipseMapSVGOptions,
|
||
projection svgmap.Projection,
|
||
center svgmap.GeoPoint,
|
||
hasCentral bool,
|
||
hasCentralBand bool,
|
||
) solarEclipseMapPlan {
|
||
blocks := solarEclipseDetailedBlocks(partial, local, hasLocal, geocentric, hasGeocentric, options)
|
||
legend := solarEclipseMapLegendRows(
|
||
solarEclipseMapLegendItems(partial, hasCentral, hasCentralBand, options),
|
||
solarEclipseMapLegendAvailableWidth(options))
|
||
layout := solarEclipseMapLayoutForBlocks(options, projection, center, len(legend), solarEclipseLandscapeRowFields(blocks))
|
||
_, legendBelow := svgchart.EstimatedTextExtents(solarEclipseLegendFontSize)
|
||
footerAbove, _ := svgchart.EstimatedTextExtents(solarEclipseMapFooterFontSize)
|
||
return solarEclipseMapPlan{
|
||
cells: solarEclipsePanelCells(layout, blocks),
|
||
layout: layout,
|
||
legend: legend,
|
||
legendBottom: layout.legendY + float64(len(legend)-1)*solarEclipseLegendLineStep + legendBelow,
|
||
footerTop: float64(options.Height) - solarEclipseMapFooterBaselineInset - footerAbove,
|
||
}
|
||
}
|
||
|
||
// fits 报告版面上的数据块与图例带是否都放得下。
|
||
func (plan solarEclipseMapPlan) fits() bool {
|
||
if solarEclipsePanelsOverlap(plan.cells) {
|
||
return false
|
||
}
|
||
if len(plan.legend) == 0 {
|
||
return true
|
||
}
|
||
return plan.legendBottom < plan.footerTop
|
||
}
|
||
|
||
// reserveFixed 先占位不动的元素:数据块、图例、比例尺槽、页脚与圆盘四向标记。
|
||
func (plan solarEclipseMapPlan) reserveFixed(labels *svgchart.LabelTable, options SolarEclipseMapSVGOptions, projection svgmap.Projection) {
|
||
for _, cell := range plan.cells {
|
||
labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height)
|
||
}
|
||
legendX := plan.layout.frame.X
|
||
if plan.layout.nasa {
|
||
legendX = plan.layout.panelX
|
||
}
|
||
for row, items := range plan.legend {
|
||
y := plan.layout.legendY + float64(row)*solarEclipseLegendLineStep
|
||
x := legendX
|
||
for _, item := range items {
|
||
width := solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize)
|
||
labels.Reserve(x, y-9, width, 12)
|
||
x += width + solarEclipseLegendColumnGap
|
||
}
|
||
}
|
||
labels.Reserve(plan.layout.scaleSlot.X, plan.layout.scaleSlot.Y, plan.layout.scaleSlot.Width, plan.layout.scaleSlot.Height)
|
||
labels.ReserveText(plan.layout.margin, float64(options.Height)-solarEclipseMapFooterBaselineInset,
|
||
solarEclipseMapFooterFontSize, solarEclipseMapFooterText(options, projection), "start")
|
||
for _, cardinal := range solarEclipseCardinalLabels(plan.layout) {
|
||
labels.ReserveText(cardinal.x, cardinal.y, 13, cardinal.text, "middle")
|
||
}
|
||
}
|
||
|
||
func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapSVGOptions) SolarEclipseMapSVGOptions {
|
||
if options.Width < solarEclipseMapMinWidth {
|
||
options.Width = solarEclipseMapDefaultWidth
|
||
}
|
||
if options.Height < solarEclipseMapMinHeight {
|
||
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 < solarEclipseMapPartialSweepMinStep {
|
||
options.PartialStep = solarEclipseMapPartialSweepMinStep
|
||
}
|
||
if options.BoundaryPoints <= 0 {
|
||
options.BoundaryPoints = 180
|
||
}
|
||
// 瞬时半影与本影轮廓默认不画:它们会把地球盖住,NASA 的全球图也没有这两族。
|
||
if options.PenumbralOutlineStep > 0 && options.PenumbralOutlineStep < time.Minute {
|
||
options.PenumbralOutlineStep = time.Minute
|
||
} else if options.PenumbralOutlineStep < 0 {
|
||
options.PenumbralOutlineStep = 0
|
||
}
|
||
if options.CentralShadowStep > 0 && options.CentralShadowStep < time.Minute {
|
||
options.CentralShadowStep = time.Minute
|
||
} else if options.CentralShadowStep < 0 {
|
||
options.CentralShadowStep = 0
|
||
}
|
||
if options.MagnitudeValues == nil {
|
||
options.MagnitudeValues = []float64{0.2, 0.4, 0.6, 0.8}
|
||
}
|
||
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
|
||
}
|
||
// 零值与负值都表示不请求等时线:这是可选图层,与核心层和 moon/svg 一样必须显式请求。
|
||
if options.GreatestTimeStep < 0 {
|
||
options.GreatestTimeStep = 0
|
||
} else if options.GreatestTimeStep > 0 && options.GreatestTimeStep < time.Minute {
|
||
options.GreatestTimeStep = time.Minute
|
||
}
|
||
return options
|
||
}
|
||
|
||
// solarEclipseGreatestTimeLevels 把等时线时刻取值对齐到步长网格上,覆盖地球范围偏食窗口。
|
||
func solarEclipseGreatestTimeLevels(
|
||
info eclipsecore.SolarEclipseInfo,
|
||
options SolarEclipseMapSVGOptions,
|
||
) []time.Time {
|
||
if options.GreatestTimeStep <= 0 {
|
||
return nil
|
||
}
|
||
if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() {
|
||
return nil
|
||
}
|
||
current := solarEclipseMapAlignedTime(info.PartialBeginOnEarth, options.GreatestTimeStep, options.Location)
|
||
if current.Before(info.PartialBeginOnEarth) {
|
||
current = current.Add(options.GreatestTimeStep)
|
||
}
|
||
levels := make([]time.Time, 0, solarEclipseMapGreatestTimeMaxLevels)
|
||
for !current.After(info.PartialEndOnEarth) && len(levels) < solarEclipseMapGreatestTimeMaxLevels {
|
||
levels = append(levels, current)
|
||
current = current.Add(options.GreatestTimeStep)
|
||
}
|
||
return levels
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
// 高纬但跨越赤道的事件必须保留等距圆柱视图;极区视图只由半球守卫决定。
|
||
return svgmap.ResolveProjection("", focus, minimum, maximum)
|
||
}
|
||
|
||
func renderSolarEclipseMapSVG(
|
||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||
central eclipsecore.SolarEclipsePath,
|
||
hasCentral bool,
|
||
local eclipsecore.LocalSolarEclipseInfo,
|
||
hasLocal bool,
|
||
geocentric eclipsecore.SolarEclipseGeocentricPanel,
|
||
hasGeocentric bool,
|
||
options SolarEclipseMapSVGOptions,
|
||
projection svgmap.Projection,
|
||
center svgmap.GeoPoint,
|
||
) string {
|
||
plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric,
|
||
options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial))
|
||
return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection)
|
||
}
|
||
|
||
func (plan solarEclipseMapPlan) render(
|
||
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||
central eclipsecore.SolarEclipsePath,
|
||
hasCentral bool,
|
||
local eclipsecore.LocalSolarEclipseInfo,
|
||
hasLocal bool,
|
||
geocentric eclipsecore.SolarEclipseGeocentricPanel,
|
||
hasGeocentric bool,
|
||
options SolarEclipseMapSVGOptions,
|
||
projection svgmap.Projection,
|
||
) string {
|
||
layout := plan.layout
|
||
frame := layout.frame
|
||
title := solarEclipseMapTitle(partial.Eclipse, options)
|
||
// 图题居中,两侧各留出白色页框 22 px 与一点空隙。
|
||
titleText := title
|
||
if options.Title != "" {
|
||
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
|
||
}
|
||
partialPath, partialSource := solarEclipsePartialSweepPath(partial, frame)
|
||
|
||
// 一张已放置矩形表:固定元素先占位,地图标注再按候选偏移避让。
|
||
labels := &svgchart.LabelTable{}
|
||
plan.reserveFixed(labels, options, projection)
|
||
labels.ReserveText(float64(options.Width)/2, 47, 24, titleText, "middle")
|
||
|
||
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(titleText))
|
||
writeSolarEclipseMapSummary(&builder, labels, partial.Eclipse, local, hasLocal, geocentric, hasGeocentric, options)
|
||
writeSolarEclipseMapSectionTitle(&builder, labels, 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" data-source="%s" d="%s" clip-path="url(#solar-map-clip)" fill="#f7dd93" fill-opacity="0.16" fill-rule="nonzero"/>`, partialSource, partialPath)
|
||
writeSolarEclipsePartialBoundary(&builder, partial.PartialBandContours, frame)
|
||
writeSolarEclipseRiseSetCurves(&builder, partial.RiseSetCurves, frame)
|
||
writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options, labels)
|
||
// 点标注比等值线标注重要:先把食甚、接触、直射点与中心线时刻占到位置,等值线标注再让开。
|
||
pointLabels := solarEclipseMapPlacePointLabels(partial, central, hasCentral, frame, options, labels)
|
||
writeSolarEclipseGreatestTimeContours(&builder, partial.GreatestTimeContours, frame, options, labels)
|
||
magnitudeAxis, magnitudeNormal, hasMagnitudeAxis := solarEclipseMagnitudeLabelAxis(central)
|
||
if !hasMagnitudeAxis {
|
||
magnitudeAxis, magnitudeNormal = [3]float64{}, [3]float64{}
|
||
}
|
||
writeSolarEclipseMagnitudeContours(&builder, partial.MagnitudeContours, frame, labels,
|
||
magnitudeAxis, magnitudeNormal)
|
||
centralBandFootprints := partial.CentralBandFootprints
|
||
if len(centralBandFootprints) == 0 {
|
||
centralBandFootprints = partial.CentralShadowFootprints
|
||
}
|
||
if hasCentral {
|
||
writeSolarEclipseCentralPath(
|
||
&builder, central, centralBandFootprints, partial.CentralBandSegments, frame, partial.Eclipse.Type,
|
||
)
|
||
} else if !writeSolarEclipseCentralBandEnvelope(&builder, partial.CentralBandSegments, frame, partial.Eclipse.Type, false) {
|
||
writeSolarEclipseCentralShadowSweep(&builder, centralBandFootprints, frame, partial.Eclipse.Type)
|
||
}
|
||
// Non-central events are represented by the continuous envelope band. The
|
||
// instantaneous open shadow arcs are diagnostic samples, not an additional
|
||
// boundary; drawing them here creates interior lines that visibly diverge
|
||
// from the annular-band edge near the grazing end.
|
||
if options.CentralShadowStep > 0 && partial.Eclipse.Centrality != eclipsecore.SolarEclipseNonCentral {
|
||
writeSolarEclipseCentralShadowOutlines(&builder, partial.CentralShadowFootprints, frame)
|
||
}
|
||
if hasCentral {
|
||
writeSolarEclipseTimeMarkers(&builder, pointLabels.times)
|
||
writeSolarEclipseAxisMarkers(&builder, central, frame, options)
|
||
}
|
||
writeSolarEclipseContactMarkers(&builder, pointLabels.contacts)
|
||
writeSolarEclipseGreatestMarker(&builder, pointLabels.greatest)
|
||
writeSolarEclipseSubsolarMarker(&builder, pointLabels.subsolar)
|
||
frame.WriteFrame(&builder)
|
||
writeSolarEclipseMapLegend(&builder, layout, plan.legend)
|
||
switch {
|
||
case layout.landscape:
|
||
writeSolarEclipseLandscapePanels(&builder, plan.cells, layout, options)
|
||
case layout.nasa:
|
||
writeSolarEclipseCardinalMarkers(&builder, layout)
|
||
writeSolarEclipseScaleBar(&builder, layout, options)
|
||
writeSolarEclipseDetailedPanels(&builder, plan.cells, layout)
|
||
if hasGeocentric {
|
||
writeSolarEclipseGeocentricBlocks(&builder, geocentric, layout, options)
|
||
writeSolarEclipseEphemerisPanels(&builder, plan.cells, layout)
|
||
}
|
||
}
|
||
writeSolarEclipseMapFooter(&builder, layout, options, projection)
|
||
builder.WriteString(`</svg>`)
|
||
return builder.String()
|
||
}
|
||
|
||
// solarEclipsePartialSweepPath 返回偏食可见域填充路径及其几何来源。
|
||
func solarEclipsePartialSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) (string, string) {
|
||
if polygons, ok := solarEclipsePartialBandPolygons(info); ok {
|
||
var builder strings.Builder
|
||
for _, polygon := range polygons {
|
||
points := make([]svgmap.GeoPoint, len(polygon))
|
||
for index, point := range polygon {
|
||
points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
for _, fragment := range svgmap.PolygonFragments(points, frame.Clip()) {
|
||
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
|
||
}
|
||
}
|
||
return builder.String(), eclipseMapSourcePartialBandUnion
|
||
}
|
||
return solarEclipsePartialFootprintSweepPath(info, frame), eclipseMapSourceSampledFootprintSweep
|
||
}
|
||
|
||
func solarEclipsePartialBandPolygons(
|
||
info eclipsecore.SolarEclipsePartialFootprintsInfo,
|
||
) ([][]geodata.GeoPoint, bool) {
|
||
// 缺少精确擦地点时并集失去权威性:退回逐足迹扫掠,data-source 落到 sampled-footprint-sweep。
|
||
if len(info.PartialBandContours) == 0 || len(info.RiseSetCurves) == 0 ||
|
||
!solarEclipsePartialClosureExact(info.Footprints) {
|
||
return nil, false
|
||
}
|
||
contours := make([][]geodata.GeoPoint, 0, len(info.PartialBandContours))
|
||
for _, contour := range info.PartialBandContours {
|
||
contours = append(contours, solarClosureGeoPoints(contour))
|
||
}
|
||
phaseLines := make([][]geodata.GeoPoint, 0, len(info.RiseSetCurves)*2)
|
||
for _, curve := range info.RiseSetCurves {
|
||
for _, segment := range curve.Segments {
|
||
phaseLines = append(phaseLines, solarClosureGeoPoints(segment))
|
||
}
|
||
}
|
||
footprints := make([]solarclosure.Footprint, 0, len(info.Footprints))
|
||
for _, footprint := range info.Footprints {
|
||
footprints = append(footprints, solarClosureFootprint(footprint))
|
||
}
|
||
return solarclosure.BandPolygons(
|
||
contours, phaseLines, footprints, true, solarclosure.SnapDistanceKM,
|
||
)
|
||
}
|
||
|
||
// solarEclipsePartialClosureExact 报告每个开放足迹是否都有两个精确擦地点。
|
||
func solarEclipsePartialClosureExact(footprints []eclipsecore.SolarEclipsePartialFootprint) bool {
|
||
for _, footprint := range footprints {
|
||
// 自身闭合的足迹不需要擦地点。
|
||
if footprint.Closed {
|
||
continue
|
||
}
|
||
if len(footprint.HorizonEnds) != 2 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func solarClosureGeoPoints(points []eclipsecore.SolarEclipsePathPoint) []geodata.GeoPoint {
|
||
result := make([]geodata.GeoPoint, len(points))
|
||
for index, point := range points {
|
||
result[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// solarClosureFootprint 转换核心层瞬时足迹;擦地点点数不是 2 时按缺失处理。
|
||
func solarClosureFootprint(footprint eclipsecore.SolarEclipsePartialFootprint) solarclosure.Footprint {
|
||
segments := make([][]geodata.GeoPoint, len(footprint.Boundaries))
|
||
for index, source := range footprint.Boundaries {
|
||
segments[index] = solarClosureGeoPoints(source)
|
||
}
|
||
ends := make([]geodata.GeoPoint, 0, 2)
|
||
if len(footprint.HorizonEnds) == 2 {
|
||
for _, end := range footprint.HorizonEnds {
|
||
ends = append(ends, geodata.GeoPoint{Longitude: end.Longitude, Latitude: end.Latitude})
|
||
}
|
||
}
|
||
subsolar := solarEclipseSubsolarPoint(footprint.Time)
|
||
return solarclosure.Footprint{
|
||
Boundaries: segments,
|
||
HorizonEnds: ends,
|
||
Subsolar: geodata.GeoPoint{Longitude: subsolar.Longitude, Latitude: subsolar.Latitude},
|
||
Closed: footprint.Closed,
|
||
}
|
||
}
|
||
|
||
func solarEclipsePartialFootprintSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) string {
|
||
var builder strings.Builder
|
||
for _, footprint := range info.Footprints {
|
||
polygon := solarEclipsePartialFootprintPolygon(footprint, frame.Projection)
|
||
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Clip()) {
|
||
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
// solarEclipsePartialFootprintPolygon 返回瞬时足迹的填充多边形:开放边界有精确擦地点时用它闭合,
|
||
// 否则退回 subsolar 地平圈的近似弧。
|
||
func solarEclipsePartialFootprintPolygon(
|
||
footprint eclipsecore.SolarEclipsePartialFootprint,
|
||
projection svgmap.Projection,
|
||
) []svgmap.GeoPoint {
|
||
polygon, _, ok := solarclosure.Ring(solarClosureFootprint(footprint), true)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
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,
|
||
bandFootprints []eclipsecore.SolarEclipsePartialFootprint,
|
||
bandSegments [][]eclipsecore.SolarEclipsePathPoint,
|
||
frame svgmap.Frame,
|
||
eclipseType eclipsecore.SolarEclipseType,
|
||
) {
|
||
northern := path.NorthernLimit
|
||
southern := path.SouthernLimit
|
||
physicalEndpointSweeps := false
|
||
if path.Eclipse.Centrality == eclipsecore.SolarEclipseCentralTwoLimits {
|
||
if north, south, ok := solarEclipseTwoLimitPresentationLimits(path); ok {
|
||
northern, southern = north, south
|
||
physicalEndpointSweeps = true
|
||
}
|
||
}
|
||
count := len(northern)
|
||
paired := count == len(southern) && count >= 2
|
||
for index := 0; paired && index < count; index++ {
|
||
paired = !northern[index].Time.IsZero() && northern[index].Time.Equal(southern[index].Time)
|
||
}
|
||
coveragePath := path
|
||
coveragePath.NorthernLimit = northern
|
||
coveragePath.SouthernLimit = southern
|
||
if len(bandSegments) > 0 && !solarEclipseCentralEnvelopeCoversPath(bandSegments, coveragePath) {
|
||
bandSegments = nil
|
||
}
|
||
if !writeSolarEclipseCentralBandEnvelope(builder, bandSegments, frame, eclipseType, true) && paired {
|
||
capacity := 2 * count
|
||
if physicalEndpointSweeps {
|
||
capacity += 2
|
||
}
|
||
polygon := make([]svgmap.GeoPoint, 0, capacity)
|
||
if physicalEndpointSweeps {
|
||
point := path.CenterLine[0]
|
||
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||
}
|
||
for _, point := range northern[:count] {
|
||
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||
}
|
||
if physicalEndpointSweeps {
|
||
point := path.CenterLine[len(path.CenterLine)-1]
|
||
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||
}
|
||
for index := count - 1; index >= 0; index-- {
|
||
point := southern[index]
|
||
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||
}
|
||
polygons := [][]svgmap.GeoPoint{polygon}
|
||
if physicalEndpointSweeps {
|
||
if merged, ok := solarEclipseTwoLimitBandPolygons(
|
||
path, northern, southern, bandFootprints,
|
||
); ok {
|
||
polygons = merged
|
||
}
|
||
}
|
||
color := solarEclipseCentralPathColor(eclipseType)
|
||
fmt.Fprintf(builder, `<g class="central-eclipse-band" data-source="%s" clip-path="url(#solar-map-clip)" fill="%s" fill-opacity="0.30">`, eclipseMapSourcePairedLimitChords, color)
|
||
for _, merged := range polygons {
|
||
for _, fragment := range svgmap.PolygonFragments(merged, frame.Clip()) {
|
||
builder.WriteString(`<path d="`)
|
||
appendEclipseMapPolygonPathConsistent(builder, frame, fragment)
|
||
builder.WriteString(`"/>`)
|
||
}
|
||
}
|
||
builder.WriteString(`</g>`)
|
||
}
|
||
writeSolarPathLine(builder, frame, northern, "northern-central-limit", "#7c2f28", 1.2, "")
|
||
writeSolarPathLine(builder, frame, southern, "southern-central-limit", "#7c2f28", 1.2, "")
|
||
writeSolarPathLine(builder, frame, path.CenterLine, "solar-center-line", "#263f58", 1.8, "5 3")
|
||
}
|
||
|
||
func solarEclipseCentralEnvelopeCoversPath(
|
||
segments [][]eclipsecore.SolarEclipsePathPoint,
|
||
path eclipsecore.SolarEclipsePath,
|
||
) bool {
|
||
if len(segments) == 0 {
|
||
return false
|
||
}
|
||
polygons := make([][]geodata.GeoPoint, 0, len(segments))
|
||
for _, segment := range segments {
|
||
if len(segment) < 3 {
|
||
return false
|
||
}
|
||
polygon := make([]geodata.GeoPoint, len(segment))
|
||
for index, point := range segment {
|
||
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
polygons = append(polygons, polygon)
|
||
}
|
||
// 限制线与中心线都要落在包络内:真正绘制的就是这三条线。限界两端含掠地端帽采样,
|
||
// 允许端部偏差,但整条限界远离包络说明包络并未覆盖将要绘制的中心带。
|
||
if len(path.CenterLine) < 2 {
|
||
return false
|
||
}
|
||
if solarEclipseCentralEnvelopeMissDistanceKM(polygons, path.CenterLine) >
|
||
solarEclipseCentralEnvelopeAxisMissToleranceKM {
|
||
return false
|
||
}
|
||
limitToleranceKM := solarEclipseCentralEnvelopeAxisMissToleranceKM
|
||
if width := path.Eclipse.PathWidthKM; width > 0 && !math.IsInf(width, 1) {
|
||
limitToleranceKM = math.Max(limitToleranceKM, solarEclipseCentralEnvelopeLimitMissFraction*width)
|
||
}
|
||
for _, series := range [][]eclipsecore.SolarEclipsePathPoint{path.NorthernLimit, path.SouthernLimit} {
|
||
if solarEclipseCentralEnvelopeMissDistanceKM(polygons, series) > limitToleranceKM {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func solarEclipseCentralEnvelopeMissDistanceKM(
|
||
polygons [][]geodata.GeoPoint,
|
||
points []eclipsecore.SolarEclipsePathPoint,
|
||
) float64 {
|
||
if len(points) == 0 {
|
||
return 0
|
||
}
|
||
line := make([]geodata.GeoPoint, len(points))
|
||
for index, point := range points {
|
||
line[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
return geodata.SphericalPolygonsPathMissDistanceKM(polygons, [][]geodata.GeoPoint{line}, false)
|
||
}
|
||
|
||
func solarEclipseTwoLimitBandPolygons(
|
||
path eclipsecore.SolarEclipsePath,
|
||
northern, southern []eclipsecore.SolarEclipsePathPoint,
|
||
footprints []eclipsecore.SolarEclipsePartialFootprint,
|
||
) ([][]svgmap.GeoPoint, bool) {
|
||
if len(northern) < 2 || len(northern) != len(southern) || len(footprints) == 0 {
|
||
return nil, false
|
||
}
|
||
middle := make([]geodata.GeoPoint, 0, len(northern)+len(southern))
|
||
for _, point := range northern {
|
||
middle = append(middle, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||
}
|
||
for index := len(southern) - 1; index >= 0; index-- {
|
||
point := southern[index]
|
||
middle = append(middle, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
||
}
|
||
endSweeps, err := solarEclipseMonotoneCentralShadowSweepPolygons(footprints)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
inputs := make([][]geodata.GeoPoint, 0, 1+len(endSweeps)+2)
|
||
inputs = append(inputs, middle)
|
||
inputs = append(inputs, endSweeps...)
|
||
inputs = append(inputs, solarEclipseCentralBandInnerTransitionCaps(footprints)...)
|
||
inputs = append(inputs, solarEclipseCentralBandContactCaps(
|
||
footprints, path.NorthernLimit[0], path.NorthernLimit[len(path.NorthernLimit)-1],
|
||
)...)
|
||
merged, err := geodata.UnionPolygons(inputs)
|
||
if err != nil || len(merged) != 1 {
|
||
return nil, false
|
||
}
|
||
polygons := make([][]svgmap.GeoPoint, len(merged))
|
||
for polygonIndex, source := range merged {
|
||
polygon := make([]svgmap.GeoPoint, len(source))
|
||
for pointIndex, point := range source {
|
||
polygon[pointIndex] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
polygons[polygonIndex] = polygon
|
||
}
|
||
return polygons, true
|
||
}
|
||
|
||
func solarEclipseCentralBandContactCaps(
|
||
footprints []eclipsecore.SolarEclipsePartialFootprint,
|
||
startContact, endContact eclipsecore.SolarEclipsePathPoint,
|
||
) [][]geodata.GeoPoint {
|
||
if len(footprints) == 0 {
|
||
return nil
|
||
}
|
||
caps := make([][]geodata.GeoPoint, 0, 2)
|
||
appendCap := func(contact eclipsecore.SolarEclipsePathPoint, footprint eclipsecore.SolarEclipsePartialFootprint) {
|
||
segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
|
||
for _, source := range footprint.Boundaries {
|
||
segment := make([]geodata.GeoPoint, len(source))
|
||
for index, point := range source {
|
||
segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
segments = append(segments, segment)
|
||
}
|
||
boundary := geodata.JoinPolylineSegments(segments)
|
||
if len(boundary) > 1 && geodata.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) {
|
||
boundary = boundary[:len(boundary)-1]
|
||
}
|
||
if len(boundary) < 2 {
|
||
return
|
||
}
|
||
caps = append(caps, []geodata.GeoPoint{
|
||
{Longitude: contact.Longitude, Latitude: contact.Latitude},
|
||
boundary[0], boundary[len(boundary)-1],
|
||
})
|
||
}
|
||
appendCap(startContact, footprints[0])
|
||
appendCap(endContact, footprints[len(footprints)-1])
|
||
return caps
|
||
}
|
||
|
||
// The path API retains U1/U4 compatibility samples. Rendered side lines use
|
||
// only the paired interval; the band fill adds the sampled physical endpoint
|
||
// sweeps separately so the duplicate outer samples cannot form long wedges.
|
||
func solarEclipseTwoLimitPresentationLimits(
|
||
path eclipsecore.SolarEclipsePath,
|
||
) ([]eclipsecore.SolarEclipsePathPoint, []eclipsecore.SolarEclipsePathPoint, bool) {
|
||
northern, southern, centerLine := path.NorthernLimit, path.SouthernLimit, path.CenterLine
|
||
if len(northern) != len(southern) || len(northern) < 4 || len(centerLine) < 2 {
|
||
return nil, nil, false
|
||
}
|
||
start := centerLine[0].Time
|
||
end := centerLine[len(centerLine)-1].Time
|
||
if start.IsZero() || end.IsZero() || !start.Before(end) ||
|
||
!northern[0].Time.Before(start) || !northern[len(northern)-1].Time.After(end) ||
|
||
!solarEclipseSamePathPoint(northern[0], southern[0]) ||
|
||
!solarEclipseSamePathPoint(northern[len(northern)-1], southern[len(southern)-1]) {
|
||
return nil, nil, false
|
||
}
|
||
first := 0
|
||
for first < len(northern) && !northern[first].Time.After(start) {
|
||
first++
|
||
}
|
||
last := first
|
||
for last < len(northern) && northern[last].Time.Before(end) {
|
||
last++
|
||
}
|
||
if first == 0 || last >= len(northern) || last-first < 2 {
|
||
return nil, nil, false
|
||
}
|
||
for index := first; index < last; index++ {
|
||
if northern[index].Time.IsZero() || !northern[index].Time.Equal(southern[index].Time) {
|
||
return nil, nil, false
|
||
}
|
||
}
|
||
return northern[first:last], southern[first:last], true
|
||
}
|
||
|
||
func solarEclipseSamePathPoint(first, second eclipsecore.SolarEclipsePathPoint) bool {
|
||
return math.Abs(first.Longitude-second.Longitude) <= 1e-9 &&
|
||
math.Abs(first.Latitude-second.Latitude) <= 1e-9
|
||
}
|
||
|
||
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", eclipseMapSourceCentralPathLimits)
|
||
}
|
||
|
||
func writeSolarEclipseGreatestMarker(builder *strings.Builder, label solarEclipseMapPointLabel) {
|
||
if label.text == "" {
|
||
return
|
||
}
|
||
fmt.Fprintf(builder, `<g class="solar-greatest-marker"><circle cx="%.3f" cy="%.3f" r="5" fill="#c44336" stroke="#fff" stroke-width="1.3"/>`,
|
||
label.x, label.y)
|
||
if label.ok {
|
||
fmt.Fprintf(builder, `<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="%s">%s</text>`,
|
||
label.placed.X, label.placed.Y, label.placed.Anchor, html.EscapeString(label.text))
|
||
}
|
||
builder.WriteString(`</g>`)
|
||
}
|
||
|
||
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,
|
||
labels *svgchart.LabelTable,
|
||
info eclipsecore.SolarEclipseInfo,
|
||
local eclipsecore.LocalSolarEclipseInfo,
|
||
hasLocal bool,
|
||
geocentric eclipsecore.SolarEclipseGeocentricPanel,
|
||
hasGeocentric 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)
|
||
}
|
||
summaryX := float64(options.Width) / 2
|
||
labels.ReserveText(summaryX, 82, 13, text, "middle")
|
||
fmt.Fprintf(builder, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
|
||
summaryX, html.EscapeString(text))
|
||
|
||
details := make([]string, 0, 4)
|
||
if info.HasCentral {
|
||
if options.Language == "en" {
|
||
details = append(details, fmt.Sprintf("central path width %.1f km", info.PathWidthKM))
|
||
} else {
|
||
details = append(details, fmt.Sprintf("中心食带宽 %.1f km", info.PathWidthKM))
|
||
}
|
||
}
|
||
if info.HasSaros {
|
||
if options.Language == "en" {
|
||
label := fmt.Sprintf("Saros series %d, member %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)
|
||
if !info.Saros.Verified {
|
||
label += " (provisional)"
|
||
}
|
||
details = append(details, label)
|
||
} else {
|
||
label := fmt.Sprintf("沙罗序列 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)
|
||
if !info.Saros.Verified {
|
||
label += "(推算)"
|
||
}
|
||
details = append(details, label)
|
||
}
|
||
}
|
||
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 {
|
||
detailText := strings.Join(details, " | ")
|
||
labels.ReserveText(summaryX, 106, 11, detailText, "middle")
|
||
fmt.Fprintf(builder, `<text x="%.3f" y="106" fill="#596164" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
|
||
summaryX, html.EscapeString(detailText))
|
||
}
|
||
conjunctionY := 0.0
|
||
if hasGeocentric {
|
||
conjunctionY = 128
|
||
conjunction := geocentric.RightAscensionConjunction
|
||
label := "地心合(视赤经相等)"
|
||
if options.Language == "en" {
|
||
label = "Geocentric conjunction (equal apparent right ascension)"
|
||
}
|
||
conjunctionText := fmt.Sprintf("%s = %s UT | J.D. = %.6f",
|
||
label, conjunction.UTC().Format("15:04:05.0"), geocentric.RightAscensionConjunctionJD)
|
||
labels.ReserveText(summaryX, conjunctionY, 11, conjunctionText, "middle")
|
||
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
|
||
summaryX, conjunctionY, html.EscapeString(conjunctionText))
|
||
}
|
||
// 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。
|
||
zoneNote := solarEclipseTimeZoneNote(maximum, options.Language)
|
||
labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle")
|
||
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
|
||
summaryX, conjunctionY+20, html.EscapeString(zoneNote))
|
||
}
|
||
|
||
func solarEclipseTimeZoneNote(maximum time.Time, language string) string {
|
||
name, offsetSeconds := maximum.Zone()
|
||
if offsetSeconds == 0 {
|
||
if language == "en" {
|
||
return "All times are UT"
|
||
}
|
||
return "图中时刻为 UT"
|
||
}
|
||
sign := "+"
|
||
if offsetSeconds < 0 {
|
||
sign = "-"
|
||
offsetSeconds = -offsetSeconds
|
||
}
|
||
hours := offsetSeconds / 3600
|
||
minutes := (offsetSeconds % 3600) / 60
|
||
if language == "en" {
|
||
return fmt.Sprintf("All times are %s (UT%s%02d:%02d)", name, sign, hours, minutes)
|
||
}
|
||
return fmt.Sprintf("图中时刻为 %s(UT%s%02d:%02d)", name, sign, hours, minutes)
|
||
}
|
||
|
||
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, rows [][]solarEclipseMapLegendItem) {
|
||
builder.WriteString(`<g class="solar-map-legend">`)
|
||
for row, items := range rows {
|
||
y := layout.legendY + float64(row)*solarEclipseLegendLineStep
|
||
x := layout.frame.X
|
||
if layout.nasa {
|
||
x = layout.panelX
|
||
}
|
||
for _, item := range items {
|
||
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.62"/>`,
|
||
x, y-8, item.color)
|
||
}
|
||
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
|
||
x+solarEclipseLegendIconWidth, y, solarEclipseLegendFontSize, html.EscapeString(item.label))
|
||
x += solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize) +
|
||
solarEclipseLegendColumnGap
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
const (
|
||
solarEclipseMapFooterFontSize = 11.0
|
||
solarEclipseMapFooterBaselineInset = 38.0
|
||
)
|
||
|
||
func solarEclipseMapFooterText(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string {
|
||
if options.FooterNote != "" {
|
||
return options.FooterNote
|
||
}
|
||
if options.Language == "en" {
|
||
return eclipseMapProjectionLabel(projection, "en") + "; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries."
|
||
}
|
||
return eclipseMapProjectionLabel(projection, "zh") + ";偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
|
||
}
|
||
|
||
func writeSolarEclipseMapFooter(
|
||
builder *strings.Builder,
|
||
layout solarEclipseMapLayout,
|
||
options SolarEclipseMapSVGOptions,
|
||
projection svgmap.Projection,
|
||
) {
|
||
// 详细版式的球面居中,页脚要从左边距起排,否则长句子会被图框裁掉。
|
||
footerX := layout.frame.X
|
||
if layout.nasa {
|
||
footerX = layout.margin
|
||
}
|
||
baseline := float64(options.Height) - solarEclipseMapFooterBaselineInset
|
||
// 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。
|
||
lines := []string{solarEclipseMapFooterText(options, projection)}
|
||
if options.FooterNote != "" {
|
||
maxWidth := float64(options.Width) - footerX - layout.margin
|
||
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, solarEclipseMapFooterFontSize),
|
||
maxWidth, solarEclipseMapFooterFontSize,
|
||
svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, 15, baseline, float64(options.Height)-4))
|
||
}
|
||
for index, line := range lines {
|
||
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="%.0f">%s</text>`,
|
||
footerX, baseline+float64(index)*15, solarEclipseMapFooterFontSize, html.EscapeString(line))
|
||
}
|
||
}
|
||
|
||
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]
|
||
}
|
||
}
|