Files
astro/eclipse/svg/transit_map.go
T
b612 044f2f4381 feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
2026-10-15 23:54:15 +08:00

537 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/internal/timenote"
)
const (
transitMapDefaultWidth = 960
transitMapDefaultHeight = 640
)
// TransitPlanet 选择凌日行星 / selects the transiting planet.
type TransitPlanet int
const (
// TransitMercury 是水星凌日 / Mercury transit.
TransitMercury TransitPlanet = 1
// TransitVenus 是金星凌日 / Venus transit.
TransitVenus TransitPlanet = 2
)
// TransitIsochrones 是等时线开关的位组合 / bit set of contact isochrones.
type TransitIsochrones uint8
const (
// TransitIsochroneC1 画一触等时线 / draw C1 isochrones.
TransitIsochroneC1 TransitIsochrones = 1 << iota
// TransitIsochroneC2 画二触等时线;掠凌没有内切接触时忽略 / draw C2 isochrones, ignored for a grazing transit.
TransitIsochroneC2
// TransitIsochroneC3 画三触等时线;掠凌没有内切接触时忽略 / draw C3 isochrones, ignored for a grazing transit.
TransitIsochroneC3
// TransitIsochroneC4 画四触等时线 / draw C4 isochrones.
TransitIsochroneC4
// TransitIsochroneExternal 是一触与四触 / C1 and C4.
TransitIsochroneExternal = TransitIsochroneC1 | TransitIsochroneC4
// TransitIsochroneAll 是全部四个接触 / all four contacts.
TransitIsochroneAll = TransitIsochroneC1 | TransitIsochroneC2 | TransitIsochroneC3 | TransitIsochroneC4
)
// TransitMapSVGOptions 控制全球凌日可见图。
// TransitMapSVGOptions controls a global planetary-transit visibility map.
type TransitMapSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。
// Width and Height are SVG canvas dimensions. 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
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值使用等经纬投影,正射投影视点取凌甚时的太阳直射点。
// Projection selects the map projection. The zero value uses equirectangular; orthographic centres on the subsolar point at greatest.
Projection EclipseMapProjection
// Isochrones 选择要画的站心接触等时线,可按位组合;零值不画(默认关闭,需显式开启)。
// Isochrones selects the topocentric contact isochrones to draw and may combine flags. The zero value draws none (off by default).
Isochrones TransitIsochrones
// IsochroneStep 是等时线的时间间隔;非正值按行星自动取(水星 20 秒、金星 1 分钟)。
// IsochroneStep is the isochrone spacing. Non-positive values select 20 s for Mercury and 1 min for Venus.
IsochroneStep time.Duration
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:按一触、凌甚、四触太阳中心在视地平上
// (计入标准大气折射)的区域分区;Options.Isochrones 显式开启时叠加所选接触的站心等时线。找不到凌日或选项无效时返回 false。
// TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: regions follow where the
// Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4. Topocentric isochrones of the
// selected contacts are overlaid only when Options.Isochrones enables them. It returns false when no transit is found
// or the options are invalid.
func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok || !validEclipseMapProjection(options.Projection) {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeTransitMapSVGOptions(date, options)
if !transit.HasInternal {
options.Isochrones &^= TransitIsochroneC2 | TransitIsochroneC3
}
options.Isochrones &= TransitIsochroneAll
if options.IsochroneStep <= 0 {
options.IsochroneStep = time.Minute
if planet == TransitMercury {
options.IsochroneStep = 20 * time.Second
}
}
projection := internalEclipseMapProjection(options.Projection)
if projection == "" {
projection = svgmap.ProjectionEquirectangular
}
return renderTransitMapSVG(planet, transit, options, projection), true
}
// closestPlanetTransit 返回距 date 最近的一次地心凌日;行星无效或找不到时第二个返回值为 false。
func closestPlanetTransit(planet TransitPlanet, date time.Time) (basic.PlanetTransitResult, bool) {
jd := basic.Date2JD(date.UTC())
var transit basic.PlanetTransitResult
switch planet {
case TransitMercury:
transit = basic.ClosestMercuryTransit(jd)
case TransitVenus:
transit = basic.ClosestVenusTransit(jd)
default:
return transit, false
}
return transit, transit.Valid
}
func normalizeTransitMapSVGOptions(date time.Time, options TransitMapSVGOptions) TransitMapSVGOptions {
if options.Width < 640 {
options.Width = transitMapDefaultWidth
}
if options.Height < 420 {
options.Height = transitMapDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
// transitMapContacts 是地心一触、凌甚、四触的 UTC 时刻。
type transitMapContacts struct {
start, greatest, end time.Time
internalStart, internalEnd time.Time
hasInternal bool
}
func transitMapContactsOf(transit basic.PlanetTransitResult) transitMapContacts {
at := func(jd float64) time.Time { return basic.JD2DateByZone(jd, time.UTC, false) }
contacts := transitMapContacts{
start: at(transit.ExternalIngress),
greatest: at(transit.Greatest),
end: at(transit.ExternalEgress),
hasInternal: transit.HasInternal,
}
if transit.HasInternal {
contacts.internalStart, contacts.internalEnd = at(transit.InternalIngress), at(transit.InternalEgress)
}
return contacts
}
func renderTransitMapSVG(
planet TransitPlanet,
transit basic.PlanetTransitResult,
options TransitMapSVGOptions,
projection svgmap.Projection,
) string {
contacts := transitMapContactsOf(transit)
// 底部比月食图多留两行:图例下要放可折行的说明与时标声明。
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 122)
if projection == svgmap.ProjectionOrthographic {
center := solarEclipseSubsolarPoint(contacts.greatest)
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
}
title := options.Title
if title == "" {
date := transitMapDisplayTime(contacts.greatest, options).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s Transit of %s Global Visibility", date, transitMapPlanetName(planet, "en"))
} else {
title = fmt.Sprintf("%s %s凌日全球可见图", date, transitMapPlanetName(planet, "zh"))
}
}
var b strings.Builder
fmt.Fprintf(&b, `<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))
b.WriteString(`<defs>`)
writeTransitMapDefinitions(&b, frame, contacts)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
options.Width-44, options.Height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
}
fmt.Fprintf(&b, `<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))
writeTransitMapSummary(&b, transit, contacts, options)
frame.WriteOcean(&b)
frame.WriteGraticule(&b, "transit-map-clip")
frame.WriteLand(&b, "transit-map-clip")
writeTransitMapRegions(&b, frame, contacts)
if options.Isochrones != 0 {
writeTransitMapIsochrones(&b, frame, transit, options)
}
frame.WriteFrame(&b)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows)
b.WriteString(`</svg>`)
return b.String()
}
func transitMapPlanetName(planet TransitPlanet, language string) string {
if language == "en" {
if planet == TransitMercury {
return "Mercury"
}
return "Venus"
}
if planet == TransitMercury {
return "水星"
}
return "金星"
}
// transitMapDisplayTime 把 UTC 时刻换成图上显示的时刻:UT1 时标给 UT1 读数,否则换到 Location。
func transitMapDisplayTime(value time.Time, options TransitMapSVGOptions) time.Time {
return transitDisplayTime(value, options.TimeScale, options.Location)
}
// transitMapSunUpPath 返回 value 时刻太阳中心在视地平上的区域面路径(evenodd)与地平边界。
// 视地平计入标准大气折射:太阳中心距直射点小于 90°+34′ 即可见,太阳视差 8.8″ 忽略。
// 半径超过 90° 的球冠环在拆分日界线时分不清内外,所以改画对日点为心、半径不足 90° 的不可见冠,
// 再用图框减去它得到可见区。
func transitMapSunUpPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
subsolar := solarEclipseSubsolarPoint(value)
antisolar := svgmap.GeoPoint{Longitude: geodata.NormalizeLongitude(subsolar.Longitude + 180), Latitude: -subsolar.Latitude}
radius := 90 - basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
boundary := geodata.SphericalCircle(antisolar, radius, 360)
closed := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
path := fmt.Sprintf("M %.3f %.3f H %.3f V %.3f H %.3f Z %s",
frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height, frame.X, lunarEclipseGeoPolygonPath(boundary, frame))
return path, closed
}
// 标准大气,与 basic 站心凌日的视地平判定一致。
const (
transitMapRefractionPressureHPa = 1010.0
transitMapRefractionTemperatureC = 10.0
)
// writeTransitMapDefinitions 输出一触、凌甚、四触三个太阳在地平上半球的形状与排除掩膜,分区口径同月食可见图。
func writeTransitMapDefinitions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
b.WriteString(frame.ClipDefinition("transit-map-clip"))
startPath, _ := transitMapSunUpPath(contacts.start, frame)
endPath, _ := transitMapSunUpPath(contacts.end, frame)
greatestPath, _ := transitMapSunUpPath(contacts.greatest, frame)
fmt.Fprintf(b, `<path id="transit-sun-c1-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, startPath)
fmt.Fprintf(b, `<path id="transit-sun-c4-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, endPath)
fmt.Fprintf(b, `<path id="transit-sun-greatest-shape" fill-rule="evenodd" clip-rule="evenodd" d="%s"/>`, greatestPath)
b.WriteString(`<clipPath id="transit-sun-c4"><use href="#transit-sun-c4-shape"/></clipPath>`)
b.WriteString(`<clipPath id="transit-sun-greatest"><use href="#transit-sun-greatest-shape"/></clipPath>`)
writeLunarEclipseVisibilityMask(b, "transit-not-visible-mask", frame,
"transit-sun-c1-shape", "transit-sun-c4-shape", "transit-sun-greatest-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c1-c4-mask", frame, "transit-sun-c1-shape", "transit-sun-c4-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-greatest-mask", frame, "transit-sun-greatest-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c4-mask", frame, "transit-sun-c4-shape")
writeLunarEclipseVisibilityMask(b, "transit-not-c1-mask", frame, "transit-sun-c1-shape")
}
const (
transitMapColorEntire = "#5e846d"
transitMapColorSunrise = "#4e9da0"
transitMapColorSunset = "#e2aa4b"
transitMapColorNone = "#747b7d"
transitMapColorC1 = "#a3392f"
transitMapColorC2 = "#c0702a"
transitMapColorC3 = "#7a4ca0"
transitMapColorC4 = "#2f5ea3"
)
// writeTransitMapRegions 着色四类可见区:全程可见、日出时凌日已开始、日落时凌日未结束、不可见。
// 只有凌甚前后太阳在地平上的极区透镜与两端可见、凌甚不可见的高纬带都归入日落带,同月食可见图。
func writeTransitMapRegions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
_, startBoundary := transitMapSunUpPath(contacts.start, frame)
_, endBoundary := transitMapSunUpPath(contacts.end, frame)
fmt.Fprintf(b, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitVisibilityRegions)
if frame.IsPolar() {
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone)
} else {
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone)
}
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunrise)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorEntire)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="0.34"/></g>`, transitMapColorSunset)
b.WriteString(`</g>`)
writeEclipseMapGeoLine(b, frame, startBoundary, "c1-horizon", "#a56c16", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
writeEclipseMapGeoLine(b, frame, endBoundary, "c4-horizon", "#197a82", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
}
// writeTransitMapIsochrones 画选中接触的站心等时线:每条线是同一时刻看到该接触的地点,只保留该时刻太阳在视地平上的部分。
// 每隔若干条标一次时刻,标签按 LabelTable 避让。
func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) {
table := &svgchart.LabelTable{}
format := "15:04"
if options.IsochroneStep%time.Minute != 0 {
format = "15:04:05"
}
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
class string
color string
flag TransitIsochrones
}{
{basic.PlanetTransitC1, "c1-isochrone", transitMapColorC1, TransitIsochroneC1},
{basic.PlanetTransitC2, "c2-isochrone", transitMapColorC2, TransitIsochroneC2},
{basic.PlanetTransitC3, "c3-isochrone", transitMapColorC3, TransitIsochroneC3},
{basic.PlanetTransitC4, "c4-isochrone", transitMapColorC4, TransitIsochroneC4},
} {
if options.Isochrones&contact.flag == 0 {
continue
}
lines := basic.PlanetTransitContactIsochrones(transit, contact.kind, options.IsochroneStep)
labelEvery := transitMapIsochroneLabelEvery(len(lines))
for index, line := range lines {
segments := make([][]svgmap.GeoPoint, 0, len(line.Segments))
for _, segment := range line.Segments {
points := make([]svgmap.GeoPoint, len(segment))
for position, point := range segment {
points[position] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
segments = append(segments, points)
writeEclipseMapGeoLine(b, frame, points, contact.class, contact.color, 0.9, "none", "transit-map-clip", eclipseMapSourceTransitContactIsochrones)
}
if index%labelEvery != 0 {
continue
}
text := transitMapDisplayTime(basic.JD2DateByZone(line.JD, time.UTC, false), options).Format(format)
placement, ok := table.Place(text, 10, transitMapIsochroneLabelCandidates(segments, frame))
if !ok {
continue
}
fmt.Fprintf(b, `<text class="%s-label" x="%.3f" y="%.3f" fill="%s" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="10" text-anchor="%s">%s</text>`,
contact.class, placement.X, placement.Y, contact.color, placement.Anchor, html.EscapeString(text))
}
}
}
// transitMapIsochroneLabelEvery 让每个接触最多标八条线。
func transitMapIsochroneLabelEvery(count int) int {
every := int(math.Ceil(float64(count) / 8))
if every < 1 {
every = 1
}
return every
}
// transitMapIsochroneLabelCandidates 从最长支路的中点向两端取画布内的候选位置。
func transitMapIsochroneLabelCandidates(segments [][]svgmap.GeoPoint, frame svgmap.Frame) []svgchart.LabelPlacement {
longest := -1
for index, segment := range segments {
if longest < 0 || len(segment) > len(segments[longest]) {
longest = index
}
}
if longest < 0 {
return nil
}
segment := segments[longest]
middle := len(segment) / 2
candidates := make([]svgchart.LabelPlacement, 0, 32)
for offset := 0; offset <= middle && len(candidates) < 32; offset++ {
for _, index := range []int{middle + offset, middle - offset} {
if index < 0 || index >= len(segment) || (offset == 0 && index != middle) {
continue
}
x, y, visible := frame.Project(segment[index].Longitude, segment[index].Latitude)
if !visible || x < frame.X+24 || x > frame.X+frame.Width-24 || y < frame.Y+14 || y > frame.Y+frame.Height-6 {
continue
}
candidates = append(candidates, svgchart.LabelPlacement{X: x, Y: y - 4, Anchor: "middle"})
}
}
return candidates
}
func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResult, contacts transitMapContacts, options TransitMapSVGOptions) {
clock := func(value time.Time) string { return transitMapDisplayTime(value, options).Format("15:04:05") }
zone, _ := transitMapDisplayTime(contacts.greatest, options).Zone()
if zone == "" {
zone = "UTC"
}
if options.TimeScale == astro.TimeScaleUT1 {
zone = "UT1"
}
greatest, separation := "凌甚", "最小角距"
if options.Language == "en" {
greatest, separation = "Greatest", "least separation"
}
parts := []string{"C1 " + clock(contacts.start)}
if contacts.hasInternal {
parts = append(parts, "C2 "+clock(contacts.internalStart))
}
parts = append(parts, greatest+" "+clock(contacts.greatest))
if contacts.hasInternal {
parts = append(parts, "C3 "+clock(contacts.internalEnd))
}
parts = append(parts, "C4 "+clock(contacts.end))
line := fmt.Sprintf("%s (%s)", strings.Join(parts, " | "), zone)
fmt.Fprintf(b, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(line, float64(options.Width)-60, 13)))
note := fmt.Sprintf("%s %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″(地心)", separation,
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
if options.Language == "en" {
note = fmt.Sprintf("%s %.1f″ | solar semidiameter %.1f″ | planet semidiameter %.2f″ (geocentric)", separation,
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
}
fmt.Fprintf(b, `<text x="%.3f" y="100" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(note))
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions) int {
type entry struct {
text, color string
line bool
}
labels := []string{"全程可见", "日出时已在凌日", "日落时仍在凌日", "不可见", "一触等时线", "二触等时线", "三触等时线", "四触等时线"}
if options.Language == "en" {
labels = []string{"Entire transit", "Transit at sunrise", "Transit at sunset", "Not visible",
"C1 isochrones", "C2 isochrones", "C3 isochrones", "C4 isochrones"}
}
entries := []entry{
{labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false},
{labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false},
}
for index, item := range []struct {
flag TransitIsochrones
color string
}{
{TransitIsochroneC1, transitMapColorC1}, {TransitIsochroneC2, transitMapColorC2},
{TransitIsochroneC3, transitMapColorC3}, {TransitIsochroneC4, transitMapColorC4},
} {
if options.Isochrones&item.flag != 0 {
entries = append(entries, entry{labels[4+index], item.color, true})
}
}
width := func(item entry) float64 {
return lunarEclipseDetailedMapLegendIconWidth + svgchart.EstimatedTextWidth(item.text, lunarEclipseDetailedMapLegendFontSize)
}
available := canvasWidth - 2*lunarEclipseDetailedPageInset
var rows [][]entry
used := 0.0
for _, item := range entries {
if len(rows) == 0 || used+lunarEclipseDetailedMapLegendGap+width(item) > available {
rows = append(rows, nil)
used = -lunarEclipseDetailedMapLegendGap
}
rows[len(rows)-1] = append(rows[len(rows)-1], item)
used += lunarEclipseDetailedMapLegendGap + width(item)
}
baseY := frame.Y + frame.Height + 31
for rowIndex, row := range rows {
total := -lunarEclipseDetailedMapLegendGap
for _, item := range row {
total += lunarEclipseDetailedMapLegendGap + width(item)
}
x := math.Max(lunarEclipseDetailedPageInset, frame.X+frame.Width/2-total/2)
y := baseY + float64(rowIndex)*lunarEclipseDetailedMapLegendLineStep
for _, item := range row {
if item.line {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.4"/>`, x, y-4, x+18, y-4, item.color)
} else {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, item.color)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
x+lunarEclipseDetailedMapLegendIconWidth, y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(item.text))
x += width(item) + lunarEclipseDetailedMapLegendGap
}
}
return len(rows)
}
func writeTransitMapFooter(
b *strings.Builder,
frame svgmap.Frame,
options TransitMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
legendRows int,
) {
text := options.FooterNote
if text == "" {
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4"
if options.Isochrones != 0 {
text += "; isochrones are topocentric contact times"
}
text += "."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
if options.Isochrones != 0 {
text += ";等时线为站心接触时刻"
}
text += "。"
}
}
const footerBottomPadding = 26.0
// 页脚紧跟图例末行,不与图例重叠;行数按画布底边截断,时标声明独占最后一行。
baseline := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 18
maxWidth := float64(options.Width) - frame.X - 24
lines := svgchart.WrapText(text, maxWidth, 11)
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language), maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline, float64(options.Height)-footerBottomPadding))
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}