Files
astro/eclipse/svg/solar_map_detailed.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

265 lines
9.5 KiB
Go

package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
)
// solarEclipseDetailedMapLayout 给出 NASA 摆法:球面居中放大,阶段面板在球面下方分三栏。
// solarEclipseMapAspect 返回各投影下地图框的长宽比:圆盘类为 1,等经纬为 2。
func solarEclipseMapAspect(projection svgmap.Projection) float64 {
if projection == svgmap.ProjectionEquirectangular {
return 2
}
return 1
}
// solarEclipsePortraitMapLayout 是竖版详细版式:地图居中在上、数据块两行在下。
func solarEclipsePortraitMapLayout(
width, height float64,
projection svgmap.Projection,
center svgmap.GeoPoint,
legendRows int,
) solarEclipseMapLayout {
aspect := solarEclipseMapAspect(projection)
margin := math.Max(30, math.Min(52, width*0.044))
headerHeight := math.Max(190, height*0.175)
blockHeight := math.Max(76, height*0.058)
footerHeight := math.Max(64, height*0.05)
legendHeight := math.Max(46, solarEclipseMapLegendBlockHeight(legendRows))
// 数据块最多六行,行高必须容下六行文字。
panelHeight := math.Max(solarEclipseMinPanelHeight(6), height*0.10)
gap := math.Max(16, height*0.014)
panelGap := math.Max(18, width*0.022)
reserved := headerHeight + blockHeight + footerHeight + legendHeight + 2*panelHeight + 3*gap
mapWidth := math.Min(width-2*margin, math.Max(140, height-reserved)*aspect)
if mapWidth < 140 {
mapWidth = math.Max(140, width-2*margin)
}
mapHeight := mapWidth / aspect
frame := svgmap.Frame{
X: (width - mapWidth) / 2,
Y: headerHeight + blockHeight,
Width: mapWidth, Height: mapHeight, Projection: projection,
CenterLongitude: center.Longitude, CenterLatitude: center.Latitude,
}
// 第一行三栏放阶段信息,第二行中间留给比例尺,两侧放历表常数与天平动。
panelY := frame.Y + mapHeight + gap + 14
panel2Y := panelY + panelHeight + gap
columnWidth := (width - 2*margin - 2*panelGap) / 3
scaleSlot := svgchart.LabelBox{
X: margin + columnWidth + panelGap, Y: panel2Y, Width: columnWidth, Height: panelHeight,
}
return solarEclipseMapLayout{
frame: frame,
panelX: margin,
panelY: panelY,
panelWidth: width - 2*margin,
panelHeight: panelHeight,
nasa: true,
legendY: panel2Y + panelHeight + gap,
scaleY: scaleSlot.Y + scaleSlot.Height*0.45,
margin: margin,
panelGap: panelGap,
blockY: headerHeight,
blockHeight: blockHeight,
secondPanelY: panel2Y,
scaleSlot: scaleSlot,
}
}
// writeSolarEclipseScaleBar 在球面图下方画比例尺;球面半径对应 90° 大圆弧长。
// 刻度长度由槽位宽度反推,标注不会越过槽位压到旁边的数据块上。
func writeSolarEclipseScaleBar(
builder *strings.Builder,
layout solarEclipseMapLayout,
options SolarEclipseMapSVGOptions,
) {
if !layout.nasa {
return
}
slot := layout.scaleSlot
radius := layout.frame.Width / 2
if radius <= 0 || slot.Width <= 0 || slot.Height <= 0 {
return
}
kilometresPerPixel := (math.Pi / 2 * 6371.0088) / radius
target := 130.0 * kilometresPerPixel
step := solarEclipseScaleStep(target, slot.Width, func(candidate float64) float64 {
barWidth := candidate / kilometresPerPixel
return barWidth + svgchart.EstimatedTextWidth("0", 10) +
svgchart.EstimatedTextWidth(formatSolarEclipseScale(candidate)+" km", 10) + 24
})
if step <= 0 {
return
}
barWidth := step / kilometresPerPixel
centreX := slot.X + slot.Width/2
y := layout.scaleY
left := centreX - barWidth/2
fmt.Fprintf(builder, `<g class="solar-scale-bar"><line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#334155" stroke-width="1"/>`,
left, y, left+barWidth, y)
for _, tick := range []float64{0, 0.25, 0.5, 0.75, 1} {
x := left + tick*barWidth
length := 5.0
if tick == 0 || tick == 1 {
length = 9
}
fmt.Fprintf(builder, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#334155" stroke-width="1"/>`,
x, y-length/2, x, y+length/2)
}
label := "比例尺"
if options.Language == "en" {
label = "Scale"
}
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#334155" font-family="Arial, sans-serif" font-size="10" text-anchor="end">0</text>`,
left-8, y+4)
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#334155" font-family="Arial, sans-serif" font-size="10">%s km</text>`,
left+barWidth+8, y+4, html.EscapeString(formatSolarEclipseScale(step)))
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="10" text-anchor="middle">%s</text>`,
centreX, y+20, html.EscapeString(label))
builder.WriteString(`</g>`)
}
func formatSolarEclipseScale(kilometres float64) string {
if kilometres >= 1000 {
return fmt.Sprintf("%.0f", kilometres)
}
return fmt.Sprintf("%.0f", kilometres)
}
func writeSolarEclipseDetailedPanels(builder *strings.Builder, cells []solarEclipsePanelCell, layout solarEclipseMapLayout) {
for _, cell := range cells {
if cell.box.Y < layout.secondPanelY {
svgchart.WritePanelBox(builder, "solar-detailed-panel", cell.box.X, cell.box.Y,
cell.box.Width, cell.box.Height, cell.title, cell.rows)
}
}
}
var _ = time.Second
// formatSolarEclipseRA 把赤经度数写成 HHhMMmSS.Ss。
func formatSolarEclipseRA(degrees float64) string {
total := math.Mod(degrees, 360) / 15
hours := math.Floor(total)
minutes := math.Floor((total - hours) * 60)
seconds := ((total-hours)*60 - minutes) * 60
return fmt.Sprintf("%02.0fh%02.0fm%04.1fs", hours, minutes, seconds)
}
// formatSolarEclipseDec 把赤纬度数写成 ±DD°MM'SS.S"。
func formatSolarEclipseDec(degrees float64) string {
sign := "+"
if degrees < 0 {
sign = "-"
degrees = -degrees
}
whole := math.Floor(degrees)
minutes := math.Floor((degrees - whole) * 60)
seconds := ((degrees-whole)*60 - minutes) * 60
return fmt.Sprintf("%s%02.0f°%02.0f'%04.1f\"", sign, whole, minutes, seconds)
}
// formatSolarEclipseArcsec 把角秒写成 DD°MM'SS.S"。
func formatSolarEclipseArcsec(arcsec float64) string {
whole := math.Floor(arcsec / 3600)
minutes := math.Floor((arcsec/3600 - whole) * 60)
seconds := ((arcsec/3600-whole)*60 - minutes) * 60
return fmt.Sprintf("%02.0f°%02.0f'%04.1f\"", whole, minutes, seconds)
}
func writeSolarEclipseGeocentricBlocks(
builder *strings.Builder,
panel eclipsecore.SolarEclipseGeocentricPanel,
layout solarEclipseMapLayout,
options SolarEclipseMapSVGOptions,
) {
if !layout.nasa {
return
}
sunTitle, moonTitle := solarEclipseGeocentricTitles(options)
sunRows, moonRows := solarEclipseGeocentricRows(panel)
if options.Language == "en" {
sunRows[3].Label, moonRows[3].Label = "H.P.", "H.P."
}
blocks := [2]solarEclipsePanelBlock{
{title: sunTitle, rows: sunRows},
{title: moonTitle, rows: moonRows},
}
// 两块都按固定宽度排版,数值一律右对齐到块右缘,右侧块才不会顶出图框。
blockWidth := math.Min(212, (layout.panelWidth-40)/2)
for index, block := range blocks {
x := layout.margin
if index == 1 {
x = layout.margin + layout.panelWidth - blockWidth
}
y := layout.blockY
fmt.Fprintf(builder, `<g class="solar-geocentric-block">`)
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#111827" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700">%s</text>`,
x, y+11, html.EscapeString(block.title))
for rowIndex, row := range block.rows {
rowY := y + 30 + float64(rowIndex)*17
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#475569" font-family="Arial, sans-serif" font-size="10.5">%s</text>`,
x, rowY, html.EscapeString(row.Label))
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#0f172a" font-family="Arial, sans-serif" font-size="10.5" font-weight="700" text-anchor="end">%s</text>`,
x+blockWidth, rowY, html.EscapeString(row.Value))
}
builder.WriteString(`</g>`)
}
}
func writeSolarEclipseEphemerisPanels(builder *strings.Builder, cells []solarEclipsePanelCell, layout solarEclipseMapLayout) {
if !layout.nasa {
return
}
for _, cell := range cells {
if cell.box.Y < layout.secondPanelY {
continue
}
svgchart.WritePanelBox(builder, "solar-detailed-panel", cell.box.X, cell.box.Y,
cell.box.Width, cell.box.Height, cell.title, cell.rows)
}
}
// solarEclipseCardinalLabel 是圆盘四边的方向标记。
type solarEclipseCardinalLabel struct {
text string
x, y float64
}
// solarEclipseCardinalLabels 给出圆盘四边的方向标记位次;平面图没有圆盘边界,返回空。
func solarEclipseCardinalLabels(layout solarEclipseMapLayout) []solarEclipseCardinalLabel {
if !layout.nasa || !layout.frame.IsDisk() {
return nil
}
centreX := layout.frame.X + layout.frame.Width/2
centreY := layout.frame.Y + layout.frame.Height/2
radius := math.Min(layout.frame.Width, layout.frame.Height)/2 + 13
result := make([]solarEclipseCardinalLabel, 0, 4)
for index, text := range []string{"N", "E", "S", "W"} {
angle := float64(index) * math.Pi / 2
result = append(result, solarEclipseCardinalLabel{
text: text,
x: centreX + radius*math.Sin(angle),
y: centreY - radius*math.Cos(angle) + 4,
})
}
return result
}
// writeSolarEclipseCardinalMarkers 在圆盘四边标出 N/E/S/W。
func writeSolarEclipseCardinalMarkers(builder *strings.Builder, layout solarEclipseMapLayout) {
for _, cardinal := range solarEclipseCardinalLabels(layout) {
fmt.Fprintf(builder, `<text class="solar-cardinal" x="%.3f" y="%.3f" fill="#334155" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="700" text-anchor="middle">%s</text>`,
cardinal.x, cardinal.y, cardinal.text)
}
}