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, ``,
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, ``,
x, y-length/2, x, y+length/2)
}
fmt.Fprintf(builder, `0`, left, y-10)
fmt.Fprintf(builder, `%s km`,
left+barWidth, y-10, html.EscapeString(formatSolarEclipseScale(step)))
fmt.Fprintf(builder, `比例尺`,
centreX, y+22)
builder.WriteString(``)
}