feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"b612.me/astro/internal/svgchart"
|
||||
"b612.me/astro/internal/svgmap"
|
||||
)
|
||||
|
||||
// 横版详细版式的固定量:数据块栏宽、栏数行数与地图长宽比。
|
||||
const (
|
||||
solarEclipseLandscapeCellWidth = 230.0
|
||||
solarEclipseLandscapeColumns = 2
|
||||
solarEclipseLandscapeRows = 3
|
||||
// 等经纬地图经度跨 360°,比例尺按赤道弧长换算。
|
||||
solarEclipseEquirectangularAspect = 2.0
|
||||
solarEclipseEarthCircumferenceKM = 40075.017
|
||||
// 横带上下限:低于这些值时数据块行距会压到文字高度以下,画布必须拒绝。
|
||||
// 表头下限让开摘要最后一行;横带与图例之间的空隙还要容下末行文字的伸出部分。
|
||||
solarEclipseLandscapeMinHeader = 156.0
|
||||
solarEclipseLandscapeMinFooter = 44.0
|
||||
solarEclipseLandscapeMinGap = 12.0
|
||||
solarEclipseLandscapeMinRowGap = 8.0
|
||||
solarEclipseLandscapeMinLegend = 42.0
|
||||
// 比例尺槽最小高度:刻度上下各要一行标注。
|
||||
solarEclipseScaleSlotMinHeight = 46.0
|
||||
)
|
||||
|
||||
// 横版两栏三行的行序固定为日月地心块、接触时刻块、地方情况与历表块,最大行数依次是 4、4、6。
|
||||
var solarEclipseLandscapeDefaultRowFields = [solarEclipseLandscapeRows]float64{4, 4, 6}
|
||||
|
||||
// solarEclipseLandscapeRowFields 数出该事件下每行最多几行文字:行高必须按它分配。
|
||||
func solarEclipseLandscapeRowFields(blocks solarEclipsePanelBlocks) [solarEclipseLandscapeRows]float64 {
|
||||
fields := [solarEclipseLandscapeRows]float64{}
|
||||
for index, block := range solarEclipseLandscapeBlockOrder(blocks) {
|
||||
row := index / solarEclipseLandscapeColumns
|
||||
if row >= solarEclipseLandscapeRows {
|
||||
continue
|
||||
}
|
||||
if count := float64(len(block.rows)); count > fields[row] {
|
||||
fields[row] = count
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// solarEclipseLandscapeRowHeights 把横带高度分给三行:行距一致,行高为表头加上行数乘行距;空行高度为 0 也不占行距。
|
||||
func solarEclipseLandscapeRowHeights(
|
||||
bandHeight, rowGap float64,
|
||||
fields [solarEclipseLandscapeRows]float64,
|
||||
) ([solarEclipseLandscapeRows]float64, float64) {
|
||||
heights := [solarEclipseLandscapeRows]float64{}
|
||||
totalFields, filled := 0.0, 0
|
||||
for _, count := range fields {
|
||||
totalFields += count
|
||||
if count > 0 {
|
||||
filled++
|
||||
}
|
||||
}
|
||||
if totalFields <= 0 || filled == 0 {
|
||||
return heights, 0
|
||||
}
|
||||
spacing := (bandHeight - float64(filled-1)*rowGap -
|
||||
float64(filled)*solarEclipsePanelHeaderHeight) / totalFields
|
||||
for index, count := range fields {
|
||||
if count <= 0 {
|
||||
continue
|
||||
}
|
||||
heights[index] = solarEclipsePanelHeaderHeight + count*spacing
|
||||
}
|
||||
return heights, spacing
|
||||
}
|
||||
|
||||
// solarEclipseStripDivision 把地图下方那条分成天平动盒与比例尺槽,留不出比例尺高度时天平动盒吃掉整条。
|
||||
func solarEclipseStripDivision(
|
||||
bottomY, bottomHeight, width, margin, gap, rowSpacing float64,
|
||||
) (float64, svgchart.LabelBox) {
|
||||
natural := solarEclipsePanelHeaderHeight + 4*rowSpacing + 0.5
|
||||
if slotHeight := bottomHeight - natural - gap; slotHeight >= solarEclipseScaleSlotMinHeight {
|
||||
return natural, svgchart.LabelBox{X: margin, Y: bottomY + natural + gap, Width: width, Height: slotHeight}
|
||||
}
|
||||
shorter := bottomHeight - solarEclipseScaleSlotMinHeight - gap
|
||||
if shorter >= solarEclipseMinPanelHeight(4) {
|
||||
return shorter, svgchart.LabelBox{
|
||||
X: margin, Y: bottomY + shorter + gap,
|
||||
Width: width, Height: solarEclipseScaleSlotMinHeight,
|
||||
}
|
||||
}
|
||||
return bottomHeight, svgchart.LabelBox{}
|
||||
}
|
||||
|
||||
// solarEclipseLandscapeMapLayout 是横版详细版式:地图在左,数据块两栏三行在右,
|
||||
// 地图下方那条放天平动与比例尺。竖版是“地图在上、面板在下”,横版把排布轴换到水平方向。
|
||||
func solarEclipseLandscapeMapLayout(
|
||||
width, height float64,
|
||||
projection svgmap.Projection,
|
||||
center svgmap.GeoPoint,
|
||||
legendRows int,
|
||||
rowFields [solarEclipseLandscapeRows]float64,
|
||||
) solarEclipseMapLayout {
|
||||
aspect := solarEclipseMapAspect(projection)
|
||||
margin := math.Max(30, math.Min(52, width*0.044))
|
||||
headerHeight := math.Max(solarEclipseLandscapeMinHeader, height*0.17)
|
||||
footerHeight := math.Max(solarEclipseLandscapeMinFooter, height*0.04)
|
||||
legendHeight := math.Max(solarEclipseLandscapeMinLegend, solarEclipseMapLegendBlockHeight(legendRows))
|
||||
gap := math.Max(solarEclipseLandscapeMinGap, height*0.01)
|
||||
panelGap := math.Max(12, width*0.011)
|
||||
rowGap := math.Max(solarEclipseLandscapeMinRowGap, height*0.008)
|
||||
|
||||
legendY := height - footerHeight - legendHeight
|
||||
bandTop := headerHeight + gap
|
||||
bandBottom := math.Max(bandTop, legendY-gap)
|
||||
bandHeight := bandBottom - bandTop
|
||||
|
||||
gridWidth := solarEclipseLandscapeColumns*solarEclipseLandscapeCellWidth +
|
||||
float64(solarEclipseLandscapeColumns-1)*panelGap
|
||||
gridX := width - margin - gridWidth
|
||||
rowHeights, rowSpacing := solarEclipseLandscapeRowHeights(bandHeight, rowGap, rowFields)
|
||||
rowY := [solarEclipseLandscapeRows]float64{}
|
||||
gridCellHeight := 0.0
|
||||
cursor := bandTop
|
||||
for index := range rowHeights {
|
||||
rowY[index] = cursor
|
||||
if rowHeights[index] <= 0 {
|
||||
continue
|
||||
}
|
||||
cursor += rowHeights[index] + rowGap
|
||||
if rowHeights[index] > gridCellHeight {
|
||||
gridCellHeight = rowHeights[index]
|
||||
}
|
||||
}
|
||||
|
||||
// 地图下方那条要放天平动与比例尺,先扣掉它的高度,圆盘图才不会把整条横带吃光。
|
||||
stripHeight := math.Max(150, bandHeight*0.24)
|
||||
mapMaxHeight := math.Max(120, bandHeight-gap-stripHeight)
|
||||
leftWidth := math.Max(240, gridX-gap-margin)
|
||||
mapWidth := leftWidth
|
||||
mapHeight := mapWidth / aspect
|
||||
if mapHeight > mapMaxHeight {
|
||||
mapHeight = mapMaxHeight
|
||||
mapWidth = mapHeight * aspect
|
||||
}
|
||||
mapY := bandTop
|
||||
bottomY := mapY + mapHeight + gap
|
||||
bottomHeight := math.Max(0, bandBottom-bottomY)
|
||||
stripPanelHeight, scaleSlot := solarEclipseStripDivision(bottomY, bottomHeight, leftWidth, margin, gap, rowSpacing)
|
||||
|
||||
return solarEclipseMapLayout{
|
||||
frame: svgmap.Frame{
|
||||
X: margin + (leftWidth-mapWidth)/2, Y: mapY, Width: mapWidth, Height: mapHeight,
|
||||
Projection: projection,
|
||||
CenterLongitude: center.Longitude, CenterLatitude: center.Latitude,
|
||||
},
|
||||
nasa: true,
|
||||
landscape: true,
|
||||
panelX: gridX,
|
||||
panelY: bandTop,
|
||||
panelWidth: gridWidth,
|
||||
panelHeight: gridCellHeight,
|
||||
panelGap: panelGap,
|
||||
gridX: gridX,
|
||||
gridCellWidth: solarEclipseLandscapeCellWidth,
|
||||
gridCellHeight: gridCellHeight,
|
||||
gridGapX: panelGap,
|
||||
gridGapY: rowGap,
|
||||
gridRowY: rowY,
|
||||
gridRowHeights: rowHeights,
|
||||
bottomY: bottomY,
|
||||
stripHeight: stripPanelHeight,
|
||||
scaleSlot: scaleSlot,
|
||||
margin: margin,
|
||||
legendY: legendY,
|
||||
scaleY: scaleSlot.Y + scaleSlot.Height*0.45,
|
||||
}
|
||||
}
|
||||
|
||||
// solarEclipseLandscapeBlockOrder 是横版两栏三行里的数据块顺序。
|
||||
func solarEclipseLandscapeBlockOrder(blocks solarEclipsePanelBlocks) []solarEclipsePanelBlock {
|
||||
return []solarEclipsePanelBlock{
|
||||
blocks.sun, blocks.moon,
|
||||
blocks.penumbra, blocks.umbra,
|
||||
blocks.circumstances, blocks.ephemeris,
|
||||
}
|
||||
}
|
||||
|
||||
// writeSolarEclipseLandscapePanels 画横版的数据块网格、地图下方那条与比例尺。
|
||||
func writeSolarEclipseLandscapePanels(
|
||||
builder *strings.Builder,
|
||||
cells []solarEclipsePanelCell,
|
||||
layout solarEclipseMapLayout,
|
||||
options SolarEclipseMapSVGOptions,
|
||||
) {
|
||||
for _, cell := range cells {
|
||||
svgchart.WritePanelBox(builder, "solar-detailed-panel", cell.box.X, cell.box.Y,
|
||||
cell.box.Width, cell.box.Height, cell.title, cell.rows)
|
||||
}
|
||||
if layout.scaleSlot.Height <= 0 {
|
||||
return
|
||||
}
|
||||
if layout.frame.Projection == svgmap.ProjectionEquirectangular {
|
||||
writeSolarEclipseEquirectangularScaleBar(builder, layout)
|
||||
} else {
|
||||
writeSolarEclipseScaleBar(builder, layout, options)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSolarEclipseEquirectangularScaleBar 画等经纬图的比例尺;圆盘版式的换算不适用于平面图。
|
||||
// 刻度长度由槽位宽度反推,绝不越过槽位压到数据块上。
|
||||
func writeSolarEclipseEquirectangularScaleBar(builder *strings.Builder, layout solarEclipseMapLayout) {
|
||||
slot := layout.scaleSlot
|
||||
if slot.Width <= 0 || slot.Height <= 0 || layout.frame.Width <= 0 {
|
||||
return
|
||||
}
|
||||
kilometresPerPixel := solarEclipseEarthCircumferenceKM / layout.frame.Width
|
||||
target := 150.0 * kilometresPerPixel
|
||||
step := solarEclipseScaleStep(target, slot.Width, func(candidate float64) float64 {
|
||||
barWidth := candidate / kilometresPerPixel
|
||||
return barWidth + 0.5*svgchart.EstimatedTextWidth("0", 10) +
|
||||
0.5*svgchart.EstimatedTextWidth(formatSolarEclipseScale(candidate)+" km", 10) + 6
|
||||
})
|
||||
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)
|
||||
}
|
||||
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#334155" font-family="Arial, sans-serif" font-size="10" text-anchor="middle">0</text>`, left, y-10)
|
||||
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#334155" font-family="Arial, sans-serif" font-size="10" text-anchor="middle">%s km</text>`,
|
||||
left+barWidth, y-10, 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">比例尺</text>`,
|
||||
centreX, y+22)
|
||||
builder.WriteString(`</g>`)
|
||||
}
|
||||
Reference in New Issue
Block a user