package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
const (
solarEclipseMapDefaultWidth = 960
solarEclipseMapDefaultHeight = 640
)
// SolarEclipseMapSVGOptions 控制无国界全球日食地图。
// SolarEclipseMapSVGOptions controls a border-free global solar-eclipse map.
type SolarEclipseMapSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。
// Width and Height are SVG canvas dimensions in user units. Width values below 640 and height values below 420 use the 960x640 defaults.
Width int
Height int
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
Language string
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false.
Projection EclipseMapProjection
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
MapTitle string
EventsTitle string
FooterNote string
// PartialStep 是半影足迹请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持时间序列不超过 30000 个采样点。
// PartialStep is the requested partial-footprint time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the time series within 30000 samples.
PartialStep time.Duration
// BoundaryPoints 是每个瞬时偏食足迹的角向采样数;非正值使用 180,正值限制在 12..1440。
// BoundaryPoints is the angular sample count for each instantaneous partial footprint. Values <= 0 use 180; positive values are clamped to 12..1440.
BoundaryPoints int
// PenumbralOutlineStep 控制半影边界轮廓采样;零值使用 60 分钟,负值禁用,正值小于一分钟时使用一分钟。
// PenumbralOutlineStep controls sampled penumbral boundary outlines. Zero uses 60 minutes, negative values disable them, and positive values below one minute use one minute.
PenumbralOutlineStep time.Duration
// CentralShadowStep 控制本影/反本影轮廓采样;零值使用 10 分钟,负值禁用,正值小于一分钟时使用一分钟。
// CentralShadowStep controls sampled umbral/antumbral outlines. Zero uses 10 minutes, negative values disable them, and positive values below one minute use one minute.
CentralShadowStep time.Duration
// CentralStep 是中心路径请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持基础路径不超过 30000 个采样点。
// CentralStep is the requested central-path time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the base path within 30000 samples.
CentralStep time.Duration
// TargetSpacingKM 是中心线地面间距上限,单位为千米;非正值使用 150 km,非有限值禁用加密。
// TargetSpacingKM is the requested maximum center-line ground spacing in kilometers. Values <= 0 use 150 km; non-finite values disable refinement.
TargetSpacingKM float64
// TimeLabelStep 控制中心线上的 HH:MM 标签;零值使用 30 分钟,负值禁用标签,正值小于一分钟时使用一分钟。
// TimeLabelStep controls HH:MM labels along the central line. Zero uses 30 minutes, negative values disable labels, and positive values below one minute use one minute.
TimeLabelStep time.Duration
}
type solarEclipseMapCalculators struct {
partial func(time.Time, eclipsecore.SolarEclipsePartialFootprintOptions) (eclipsecore.SolarEclipsePartialFootprintsInfo, bool)
central func(time.Time, eclipsecore.SolarEclipsePathOptions) (eclipsecore.SolarEclipsePath, bool)
local func(time.Time, float64, float64, float64) (eclipsecore.LocalSolarEclipseInfo, bool)
}
// SolarEclipseMapSVG 使用 NASA bulletin Split-K 绘制完整偏食可见范围,并在存在时绘制全食或环食中心线。
// SolarEclipseMapSVG renders the full partial-visibility sweep and, when present, the total or annular central path using NASA bulletin Split-K.
func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
return SolarEclipseMapSVGNASABulletinSplitK(date, options)
}
// SolarEclipseMapSVGNASABulletinSplitK 使用 NASA bulletin Split-K 绘制地图。
// SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map.
func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK,
central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK,
})
}
// SolarEclipseMapSVGIAUSingleK 使用 IAU Single-K 模型绘制地图。
// SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map.
func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) {
return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{
partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK,
central: eclipsecore.SolarEclipseCentralPathIAUSingleK,
local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK,
})
}
func solarEclipseMapSVG(
date time.Time,
options SolarEclipseMapSVGOptions,
calculators solarEclipseMapCalculators,
) (string, bool) {
if !validEclipseMapProjection(options.Projection) {
return "", false
}
options = normalizeSolarEclipseMapSVGOptions(date, options)
partial, ok := calculators.partial(date, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: options.PartialStep,
BoundaryPoints: options.BoundaryPoints,
CentralShadowStep: options.CentralShadowStep,
})
if !ok {
return "", false
}
central, hasCentral := calculators.central(date, eclipsecore.SolarEclipsePathOptions{
Step: options.CentralStep,
TargetSpacingKM: options.TargetSpacingKM,
})
local, hasLocal := calculators.local(
partial.Eclipse.GreatestEclipse,
partial.Eclipse.GreatestLongitude,
partial.Eclipse.GreatestLatitude,
0,
)
projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, options.Projection)
return renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal, options, projection), true
}
func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapSVGOptions) SolarEclipseMapSVGOptions {
if options.Width < 640 {
options.Width = solarEclipseMapDefaultWidth
}
if options.Height < 420 {
options.Height = solarEclipseMapDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
if options.PartialStep <= 0 {
options.PartialStep = 2 * time.Minute
}
if options.BoundaryPoints <= 0 {
options.BoundaryPoints = 180
}
if options.PenumbralOutlineStep < 0 {
options.PenumbralOutlineStep = 0
} else if options.PenumbralOutlineStep == 0 {
options.PenumbralOutlineStep = time.Hour
} else if options.PenumbralOutlineStep < time.Minute {
options.PenumbralOutlineStep = time.Minute
}
if options.CentralShadowStep < 0 {
options.CentralShadowStep = 0
} else if options.CentralShadowStep == 0 {
options.CentralShadowStep = 10 * time.Minute
} else if options.CentralShadowStep < time.Minute {
options.CentralShadowStep = time.Minute
}
if options.CentralStep <= 0 {
options.CentralStep = 2 * time.Minute
}
if options.TargetSpacingKM <= 0 {
options.TargetSpacingKM = 150
}
if options.TimeLabelStep < 0 {
options.TimeLabelStep = 0
} else if options.TimeLabelStep == 0 {
options.TimeLabelStep = 30 * time.Minute
} else if options.TimeLabelStep < time.Minute {
options.TimeLabelStep = time.Minute
}
return options
}
func resolveSolarEclipseMapProjection(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
requested EclipseMapProjection,
) svgmap.Projection {
if requested != EclipseMapProjectionAuto {
return internalEclipseMapProjection(requested)
}
focus := partial.Eclipse.GreatestLatitude
minimum, maximum := focus, focus
for _, footprint := range partial.Footprints {
for _, boundary := range footprint.Boundaries {
for _, point := range boundary {
minimum = math.Min(minimum, point.Latitude)
maximum = math.Max(maximum, point.Latitude)
}
}
}
if hasCentral {
for _, series := range [][]eclipsecore.SolarEclipsePathPoint{central.CenterLine, central.NorthernLimit, central.SouthernLimit} {
for _, point := range series {
minimum = math.Min(minimum, point.Latitude)
maximum = math.Max(maximum, point.Latitude)
}
}
}
resolved := svgmap.ResolveProjection("", focus, minimum, maximum)
if resolved == svgmap.ProjectionEquirectangular && focus >= 65 {
return svgmap.ProjectionNorthPolar
}
if resolved == svgmap.ProjectionEquirectangular && focus <= -65 {
return svgmap.ProjectionSouthPolar
}
return resolved
}
func renderSolarEclipseMapSVG(
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
local eclipsecore.LocalSolarEclipseInfo,
hasLocal bool,
options SolarEclipseMapSVGOptions,
projection svgmap.Projection,
) string {
layout := solarEclipseMapLayoutFor(options, projection)
frame := layout.frame
title := solarEclipseMapTitle(partial.Eclipse, options)
partialPath := solarEclipsePartialSweepPath(partial, frame)
var builder strings.Builder
fmt.Fprintf(&builder, ``)
return builder.String()
}
func solarEclipsePartialSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) string {
var builder strings.Builder
for _, footprint := range info.Footprints {
segments := make([][]svgmap.GeoPoint, 0, len(footprint.Boundaries))
for _, source := range footprint.Boundaries {
segment := make([]svgmap.GeoPoint, len(source))
for index, point := range source {
segment[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, segment)
}
boundary := svgmap.JoinPolylineSegments(segments)
if len(boundary) < 3 {
continue
}
if len(boundary) > 1 && svgmap.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) {
boundary = boundary[:len(boundary)-1]
}
polygon := solarEclipsePartialFootprintPolygon(boundary, footprint.Time, footprint.Closed, frame.Projection)
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) {
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
}
}
return builder.String()
}
func solarEclipsePartialFootprintPolygon(
boundary []svgmap.GeoPoint,
value time.Time,
closed bool,
projection svgmap.Projection,
) []svgmap.GeoPoint {
polygon := append([]svgmap.GeoPoint(nil), boundary...)
if len(boundary) < 2 {
return polygon
}
if !closed {
terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(value), 90, 360)
polygon = append(polygon, svgmap.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])...)
}
if interior, ok := solarEclipseSphericalBoundaryCentroid(polygon); ok && projection == svgmap.ProjectionEquirectangular {
polygon = solarEclipseAppendEquirectangularPoleRim(polygon, interior)
}
return polygon
}
func solarEclipseSphericalBoundaryCentroid(points []svgmap.GeoPoint) (svgmap.GeoPoint, bool) {
var sum solarEclipseMapVector
for _, point := range points {
value := solarEclipseMapUnitVector(point)
sum[0] += value[0]
sum[1] += value[1]
sum[2] += value[2]
}
length := math.Sqrt(solarEclipseMapDot(sum, sum))
if length < 1e-12 {
return svgmap.GeoPoint{}, false
}
for index := range sum {
sum[index] /= length
}
return svgmap.GeoPoint{
Longitude: math.Atan2(sum[1], sum[0]) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, sum[2]))) * 180 / math.Pi,
}, true
}
func solarEclipseAppendEquirectangularPoleRim(
polygon []svgmap.GeoPoint,
interior svgmap.GeoPoint,
) []svgmap.GeoPoint {
interiorAngle := solarEclipseSphericalPolygonAngle(polygon, interior)
if math.Abs(interiorAngle) < math.Pi {
return polygon
}
poleLatitude := 0.0
for _, candidate := range []float64{90, -90} {
angle := solarEclipseSphericalPolygonAngle(polygon, svgmap.GeoPoint{Latitude: candidate})
if math.Abs(angle) >= math.Pi && math.Signbit(angle) == math.Signbit(interiorAngle) {
poleLatitude = candidate
break
}
}
if poleLatitude == 0 {
return polygon
}
firstLongitude, lastLongitude := solarEclipseUnwrappedLongitudeEndpoints(polygon)
delta := firstLongitude - lastLongitude
if math.Abs(delta) < 180 {
return polygon
}
steps := int(math.Ceil(math.Abs(delta) / 90))
result := append([]svgmap.GeoPoint(nil), polygon...)
for step := 1; step <= steps; step++ {
longitude := lastLongitude + delta*float64(step)/float64(steps)
result = append(result, svgmap.GeoPoint{
Longitude: normalizeDegree180(longitude),
Latitude: poleLatitude,
})
}
return result
}
func solarEclipseUnwrappedLongitudeEndpoints(points []svgmap.GeoPoint) (float64, float64) {
first := points[0].Longitude
previous := first
for _, point := range points[1:] {
longitude := point.Longitude
for longitude-previous > 180 {
longitude -= 360
}
for longitude-previous < -180 {
longitude += 360
}
previous = longitude
}
return first, previous
}
type solarEclipseMapVector [3]float64
func solarEclipseSphericalPolygonAngle(points []svgmap.GeoPoint, target svgmap.GeoPoint) float64 {
if len(points) < 3 {
return 0
}
reference := solarEclipseMapUnitVector(target)
angle := 0.0
for index, point := range points {
current, currentOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(point))
next, nextOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(points[(index+1)%len(points)]))
if !currentOK || !nextOK {
return math.Copysign(2*math.Pi, angle)
}
angle += math.Atan2(
solarEclipseMapDot(reference, solarEclipseMapCross(current, next)),
solarEclipseMapDot(current, next),
)
}
return angle
}
func solarEclipseMapUnitVector(point svgmap.GeoPoint) solarEclipseMapVector {
longitude := point.Longitude * math.Pi / 180
latitude := point.Latitude * math.Pi / 180
return solarEclipseMapVector{
math.Cos(latitude) * math.Cos(longitude),
math.Cos(latitude) * math.Sin(longitude),
math.Sin(latitude),
}
}
func solarEclipseMapTangentDirection(
reference, value solarEclipseMapVector,
) (solarEclipseMapVector, bool) {
projection := solarEclipseMapDot(reference, value)
result := solarEclipseMapVector{
value[0] - projection*reference[0],
value[1] - projection*reference[1],
value[2] - projection*reference[2],
}
length := math.Sqrt(solarEclipseMapDot(result, result))
if length < 1e-12 {
return solarEclipseMapVector{}, false
}
return solarEclipseMapVector{result[0] / length, result[1] / length, result[2] / length}, true
}
func solarEclipseMapDot(a, b solarEclipseMapVector) float64 {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
}
func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector {
return solarEclipseMapVector{
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0],
}
}
func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
ttJDE := solarEclipseTimeToTTJDE(value)
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
func appendEclipseMapPolygonPathConsistent(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) {
if projectedPolygonArea(frame, points) < 0 {
points = append([]svgmap.GeoPoint(nil), points...)
reverseGeoPoints(points)
}
appendEclipseMapPolygonPath(builder, frame, points)
}
func projectedPolygonArea(frame svgmap.Frame, points []svgmap.GeoPoint) float64 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
x1, y1, ok1 := frame.Project(point.Longitude, point.Latitude)
x2, y2, ok2 := frame.Project(next.Longitude, next.Latitude)
if ok1 && ok2 {
area += x1*y2 - x2*y1
}
}
return area / 2
}
func writeSolarEclipseCentralPath(
builder *strings.Builder,
path eclipsecore.SolarEclipsePath,
frame svgmap.Frame,
eclipseType eclipsecore.SolarEclipseType,
) {
count := len(path.NorthernLimit)
paired := count == len(path.SouthernLimit) && count >= 2
for index := 0; paired && index < count; index++ {
paired = !path.NorthernLimit[index].Time.IsZero() &&
path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time)
}
if paired {
polygon := make([]svgmap.GeoPoint, 0, 2*count)
for _, point := range path.NorthernLimit[:count] {
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
for index := count - 1; index >= 0; index-- {
point := path.SouthernLimit[index]
polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
}
color := solarEclipseCentralPathColor(eclipseType)
fmt.Fprintf(builder, ``, color)
for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) {
builder.WriteString(``)
}
builder.WriteString(``)
}
writeSolarPathLine(builder, frame, path.NorthernLimit, "northern-central-limit", "#7c2f28", 1.2, "")
writeSolarPathLine(builder, frame, path.SouthernLimit, "southern-central-limit", "#7c2f28", 1.2, "")
writeSolarPathLine(builder, frame, path.CenterLine, "solar-center-line", "#263f58", 1.8, "5 3")
}
func writeSolarPathLine(
builder *strings.Builder,
frame svgmap.Frame,
points []eclipsecore.SolarEclipsePathPoint,
className, color string,
width float64,
dash string,
) {
geographic := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
writeEclipseMapGeoLine(builder, frame, geographic, className, color, width, dash, "solar-map-clip")
}
func writeSolarEclipseGreatestMarker(
builder *strings.Builder,
info eclipsecore.SolarEclipseInfo,
frame svgmap.Frame,
language string,
) {
x, y, ok := frame.Project(info.GreatestLongitude, info.GreatestLatitude)
if !ok {
return
}
label := "食甚"
if language == "en" {
label = "Greatest"
}
fmt.Fprintf(builder, `%s`,
x, y, x, y-10, html.EscapeString(label))
}
func solarEclipseMapTitle(info eclipsecore.SolarEclipseInfo, options SolarEclipseMapSVGOptions) string {
if options.Title != "" {
return options.Title
}
date := info.GreatestEclipse.In(options.Location).Format("2006-01-02")
if options.Language == "en" {
return fmt.Sprintf("%s %s Global Visibility", date, solarEclipseMapTypeName(info.Type, "en"))
}
return fmt.Sprintf("%s %s全球见食图", date, solarEclipseMapTypeName(info.Type, "zh"))
}
func writeSolarEclipseMapSummary(
builder *strings.Builder,
info eclipsecore.SolarEclipseInfo,
local eclipsecore.LocalSolarEclipseInfo,
hasLocal bool,
options SolarEclipseMapSVGOptions,
) {
start := info.PartialBeginOnEarth.In(options.Location)
maximum := info.GreatestEclipse.In(options.Location)
end := info.PartialEndOnEarth.In(options.Location)
zone, _ := maximum.Zone()
if zone == "" {
zone = "UTC"
}
text := fmt.Sprintf("偏食始 %s | 食甚 %s | 偏食终 %s (%s) | 食分 %.3f | Gamma %.4f",
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma)
if options.Language == "en" {
text = fmt.Sprintf("Partial begins %s | Greatest %s | Partial ends %s (%s) | magnitude %.3f | Gamma %.4f",
start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma)
}
fmt.Fprintf(builder, `%s`,
float64(options.Width)/2, html.EscapeString(text))
details := make([]string, 0, 4)
if info.HasCentral {
if options.Language == "en" {
details = append(details, fmt.Sprintf("path width %.1f km", info.PathWidthKM))
} else {
details = append(details, fmt.Sprintf("食带宽 %.1f km", info.PathWidthKM))
}
}
if info.HasSaros {
if options.Language == "en" {
details = append(details, fmt.Sprintf("Solar Saros %d, member %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count))
} else {
details = append(details, fmt.Sprintf("太阳沙罗 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count))
}
}
if hasLocal {
if options.Language == "en" {
details = append(details, fmt.Sprintf("Sun alt %.1f° az %.1f°", local.SunAltitude, local.SunAzimuth))
} else {
details = append(details, fmt.Sprintf("食甚点太阳高度 %.1f° 方位 %.1f°", local.SunAltitude, local.SunAzimuth))
}
if local.HasCentral && !local.CentralStart.IsZero() && !local.CentralEnd.IsZero() {
duration := local.CentralEnd.Sub(local.CentralStart)
if duration > 0 {
if options.Language == "en" {
details = append(details, "central duration "+formatSolarEclipseMapDuration(duration))
} else {
details = append(details, "中心食持续 "+formatSolarEclipseMapDuration(duration))
}
}
}
}
if len(details) > 0 {
fmt.Fprintf(builder, `%s`,
float64(options.Width)/2, html.EscapeString(strings.Join(details, " | ")))
}
}
func formatSolarEclipseMapDuration(value time.Duration) string {
seconds := int(math.Round(value.Seconds()))
if seconds < 0 {
seconds = -seconds
}
return fmt.Sprintf("%02d:%02d", seconds/60, seconds%60)
}
func writeSolarEclipseMapLegend(
builder *strings.Builder,
layout solarEclipseMapLayout,
info eclipsecore.SolarEclipseInfo,
hasCentral bool,
options SolarEclipseMapSVGOptions,
) {
type legendItem struct {
label string
kind string
color string
dash string
}
items := []legendItem{{label: "偏食可见区", kind: "fill", color: "#dfb84d"}}
if hasCentral {
items = append(items,
legendItem{label: solarEclipseCentralPathLabel(info.Type, options.Language), kind: "fill", color: solarEclipseCentralPathColor(info.Type)},
legendItem{label: "中心线", kind: "line", color: "#263f58", dash: "5 3"},
)
}
if options.PenumbralOutlineStep > 0 {
items = append(items, legendItem{
label: solarEclipseOutlineLegendLabel("penumbra", info.Type, options.PenumbralOutlineStep, options.Language),
kind: "line", color: "#b07a18", dash: "3 3",
})
}
items = append(items, legendItem{label: "食甚晨昏圈", kind: "line", color: "#6f7778", dash: "4 3"})
if hasCentral && options.CentralShadowStep > 0 {
items = append(items, legendItem{
label: solarEclipseOutlineLegendLabel("central", info.Type, options.CentralShadowStep, options.Language),
kind: "line", color: "#7b5a42",
})
}
contactLabel := "P/U 影锥接触"
if !hasCentral {
contactLabel = "P 半影接触"
}
if options.Language == "en" {
items[0].label = "Partial-eclipse visibility"
if hasCentral {
items[2].label = "Center line"
}
for index := range items {
if items[index].label == "食甚晨昏圈" {
items[index].label = "Terminator at greatest"
}
}
contactLabel = "P/U shadow contacts"
if !hasCentral {
contactLabel = "P penumbral contacts"
}
}
items = append(items, legendItem{label: contactLabel, kind: "contact", color: "#a52d70"})
columns := len(items)
if columns > 4 {
columns = 4
}
legendWidth := layout.panelX + layout.panelWidth - layout.frame.X
itemWidth := legendWidth / float64(columns)
baseY := layout.frame.Y + layout.frame.Height + 29
builder.WriteString(``)
for index, item := range items {
row, column := index/columns, index%columns
x := layout.frame.X + float64(column)*itemWidth
y := baseY + float64(row)*22
switch item.kind {
case "line":
fmt.Fprintf(builder, ``,
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+25, y, html.EscapeString(item.label))
}
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)
}
func writeSolarEclipseMapFooter(
builder *strings.Builder,
frame svgmap.Frame,
options SolarEclipseMapSVGOptions,
projection svgmap.Projection,
) {
text := options.FooterNote
if text == "" {
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
fmt.Fprintf(builder, `%s`,
frame.X, float64(options.Height)-38, html.EscapeString(text))
}
func solarEclipseMapTypeName(value eclipsecore.SolarEclipseType, language string) string {
if language == "en" {
switch value {
case eclipsecore.SolarEclipseTotal:
return "Total Solar Eclipse"
case eclipsecore.SolarEclipseAnnular:
return "Annular Solar Eclipse"
case eclipsecore.SolarEclipseHybrid:
return "Hybrid Solar Eclipse"
default:
return "Partial Solar Eclipse"
}
}
switch value {
case eclipsecore.SolarEclipseTotal:
return "日全食"
case eclipsecore.SolarEclipseAnnular:
return "日环食"
case eclipsecore.SolarEclipseHybrid:
return "全环食"
default:
return "日偏食"
}
}
func solarEclipseCentralPathLabel(value eclipsecore.SolarEclipseType, language string) string {
if language == "en" {
switch value {
case eclipsecore.SolarEclipseAnnular:
return "Annular path"
case eclipsecore.SolarEclipseHybrid:
return "Hybrid central path"
default:
return "Path of totality"
}
}
switch value {
case eclipsecore.SolarEclipseAnnular:
return "环食带"
case eclipsecore.SolarEclipseHybrid:
return "全环食中心带"
default:
return "全食带"
}
}
func solarEclipseCentralPathColor(value eclipsecore.SolarEclipseType) string {
if value == eclipsecore.SolarEclipseAnnular {
return "#a94f3f"
}
if value == eclipsecore.SolarEclipseHybrid {
return "#76506f"
}
return "#38516d"
}
func reverseGeoPoints(points []svgmap.GeoPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}