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, ``)
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, ``, eclipseMapSourcePairedLimitChords, color)
for _, merged := range polygons {
for _, fragment := range svgmap.PolygonFragments(merged, frame.Clip()) {
builder.WriteString(``)
}
}
builder.WriteString(``)
}
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, ``,
label.x, label.y)
if label.ok {
fmt.Fprintf(builder, `%s`,
label.placed.X, label.placed.Y, label.placed.Anchor, html.EscapeString(label.text))
}
builder.WriteString(``)
}
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, `%s`,
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, `%s`,
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, `%s`,
summaryX, conjunctionY, html.EscapeString(conjunctionText))
}
// 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。
zoneNote := solarEclipseTimeZoneNote(maximum, options.Language)
labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle")
fmt.Fprintf(builder, `%s`,
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(``)
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, ``,
x, y-4, x+20, y-4, item.color, item.dash)
case "contact":
fmt.Fprintf(builder, ``,
x+8, y-4, item.color)
default:
fmt.Fprintf(builder, ``,
x, y-8, item.color)
}
fmt.Fprintf(builder, `%s`,
x+solarEclipseLegendIconWidth, y, solarEclipseLegendFontSize, html.EscapeString(item.label))
x += solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize) +
solarEclipseLegendColumnGap
}
}
builder.WriteString(``)
}
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, `%s`,
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]
}
}