559 lines
20 KiB
Go
559 lines
20 KiB
Go
|
|
package svg
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"math"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
eclipsecore "b612.me/astro/eclipse"
|
|||
|
|
"b612.me/astro/internal/svgchart"
|
|||
|
|
"b612.me/astro/internal/svgmap"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 数据块的表头高度与行字号:取值与 internal/svgchart 的排版常量相同,这里只用它们反推行距下限。
|
|||
|
|
const (
|
|||
|
|
solarEclipsePanelHeaderHeight = 30.0
|
|||
|
|
solarEclipsePanelRowFontSize = 10.5
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// solarEclipsePanelRowSpacingMin 是数据块行距下限:低于它相邻两行文字必然压叠。
|
|||
|
|
func solarEclipsePanelRowSpacingMin() float64 {
|
|||
|
|
above, below := svgchart.EstimatedTextExtents(solarEclipsePanelRowFontSize)
|
|||
|
|
return above + below
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMinPanelHeight 是容下 count 行文字的数据块高度下限;0.5 px 是等分行高时浮点取整的余量。
|
|||
|
|
func solarEclipseMinPanelHeight(count int) float64 {
|
|||
|
|
return solarEclipsePanelHeaderHeight + float64(count)*solarEclipsePanelRowSpacingMin() + 0.5
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipsePanelCell 是一个数据块的矩形与内容:占位与绘制共用同一份几何。
|
|||
|
|
type solarEclipsePanelCell struct {
|
|||
|
|
title string
|
|||
|
|
rows []svgchart.PanelRow
|
|||
|
|
box svgchart.LabelBox
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipsePanelCells 给出该版式下全部数据块矩形。
|
|||
|
|
func solarEclipsePanelCells(layout solarEclipseMapLayout, blocks solarEclipsePanelBlocks) []solarEclipsePanelCell {
|
|||
|
|
if layout.landscape {
|
|||
|
|
order := solarEclipseLandscapeBlockOrder(blocks)
|
|||
|
|
cells := make([]solarEclipsePanelCell, 0, len(order)+1)
|
|||
|
|
for index, block := range order {
|
|||
|
|
if len(block.rows) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
box, ok := solarEclipseLandscapeCellBox(layout, index)
|
|||
|
|
if !ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
cells = append(cells, solarEclipsePanelCell{title: block.title, rows: block.rows, box: box})
|
|||
|
|
}
|
|||
|
|
if len(blocks.libration.rows) > 0 && layout.stripHeight > 0 {
|
|||
|
|
cells = append(cells, solarEclipsePanelCell{
|
|||
|
|
title: blocks.libration.title, rows: blocks.libration.rows,
|
|||
|
|
box: svgchart.LabelBox{
|
|||
|
|
X: layout.margin, Y: layout.bottomY,
|
|||
|
|
Width: solarEclipseLandscapeCellWidth, Height: layout.stripHeight,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return cells
|
|||
|
|
}
|
|||
|
|
if !layout.nasa {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
columnWidth := solarEclipsePortraitColumnWidth(layout)
|
|||
|
|
cells := make([]solarEclipsePanelCell, 0, 5)
|
|||
|
|
for column, block := range []solarEclipsePanelBlock{blocks.penumbra, blocks.circumstances, blocks.umbra} {
|
|||
|
|
if len(block.rows) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
cells = append(cells, solarEclipsePanelCell{title: block.title, rows: block.rows, box: svgchart.LabelBox{
|
|||
|
|
X: solarEclipsePortraitColumnX(layout, column), Y: layout.panelY,
|
|||
|
|
Width: columnWidth, Height: layout.panelHeight,
|
|||
|
|
}})
|
|||
|
|
}
|
|||
|
|
second := [2]struct {
|
|||
|
|
column int
|
|||
|
|
block solarEclipsePanelBlock
|
|||
|
|
}{{0, blocks.ephemeris}, {2, blocks.libration}}
|
|||
|
|
for _, entry := range second {
|
|||
|
|
if len(entry.block.rows) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
cells = append(cells, solarEclipsePanelCell{title: entry.block.title, rows: entry.block.rows, box: svgchart.LabelBox{
|
|||
|
|
X: solarEclipsePortraitColumnX(layout, entry.column), Y: layout.secondPanelY,
|
|||
|
|
Width: columnWidth, Height: layout.panelHeight,
|
|||
|
|
}})
|
|||
|
|
}
|
|||
|
|
return cells
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipsePanelsOverlap 报告任一数据块内文字是否压叠;为真时调用方必须拒绝该画布。
|
|||
|
|
func solarEclipsePanelsOverlap(cells []solarEclipsePanelCell) bool {
|
|||
|
|
for _, cell := range cells {
|
|||
|
|
if len(cell.rows) == 0 || cell.box.Width <= 0 || cell.box.Height <= 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if svgchart.PanelBoxOverlaps(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height, cell.title, cell.rows) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipsePortraitColumnWidth(layout solarEclipseMapLayout) float64 {
|
|||
|
|
return (layout.panelWidth - 2*layout.panelGap) / 3
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipsePortraitColumnX(layout solarEclipseMapLayout, column int) float64 {
|
|||
|
|
return layout.panelX + float64(column)*(solarEclipsePortraitColumnWidth(layout)+layout.panelGap)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipseLandscapeCellBox(layout solarEclipseMapLayout, index int) (svgchart.LabelBox, bool) {
|
|||
|
|
row := index / solarEclipseLandscapeColumns
|
|||
|
|
column := index % solarEclipseLandscapeColumns
|
|||
|
|
if row < 0 || row >= len(layout.gridRowHeights) || layout.gridRowHeights[row] <= 0 {
|
|||
|
|
return svgchart.LabelBox{}, false
|
|||
|
|
}
|
|||
|
|
return svgchart.LabelBox{
|
|||
|
|
X: layout.gridX + float64(column)*(layout.gridCellWidth+layout.gridGapX),
|
|||
|
|
Y: layout.gridRowY[row], Width: layout.gridCellWidth, Height: layout.gridRowHeights[row],
|
|||
|
|
}, true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapLegendItem 是图例的一条:标签、图形种类、颜色与虚线。
|
|||
|
|
type solarEclipseMapLegendItem struct {
|
|||
|
|
label string
|
|||
|
|
kind string
|
|||
|
|
color string
|
|||
|
|
dash string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
solarEclipseLegendFontSize = 10.0
|
|||
|
|
solarEclipseLegendLineStep = 22.0
|
|||
|
|
solarEclipseLegendIconWidth = 25.0
|
|||
|
|
solarEclipseLegendColumnGap = 12.0
|
|||
|
|
solarEclipseLegendMaxColumns = 4
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// solarEclipseMapLegendItems 汇总该事件要画的图例条目,顺序即优先级。
|
|||
|
|
func solarEclipseMapLegendItems(
|
|||
|
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
|||
|
|
hasCentral bool,
|
|||
|
|
hasCentralBandFootprints bool,
|
|||
|
|
options SolarEclipseMapSVGOptions,
|
|||
|
|
) []solarEclipseMapLegendItem {
|
|||
|
|
info := partial.Eclipse
|
|||
|
|
partialLabel := "偏食可见区"
|
|||
|
|
centerLineLabel := "中心线"
|
|||
|
|
if options.Language == "en" {
|
|||
|
|
partialLabel = "Partial-eclipse visibility"
|
|||
|
|
centerLineLabel = "Center line"
|
|||
|
|
}
|
|||
|
|
items := []solarEclipseMapLegendItem{{label: partialLabel, kind: "fill", color: "#f7dd93"}}
|
|||
|
|
if hasCentral || hasCentralBandFootprints {
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{
|
|||
|
|
label: solarEclipseCentralPathLabel(info.Type, options.Language), kind: "fill",
|
|||
|
|
color: solarEclipseCentralPathColor(info.Type),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
if hasCentral {
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{label: centerLineLabel, kind: "line", color: "#263f58", dash: "5 3"})
|
|||
|
|
}
|
|||
|
|
if options.PenumbralOutlineStep > 0 {
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{
|
|||
|
|
label: solarEclipseOutlineLegendLabel("penumbra", info.Type, options.PenumbralOutlineStep, options.Language),
|
|||
|
|
kind: "line", color: "#b07a18", dash: "3 3",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
riseSetLabel := "初亏/食甚/复圆日升日落线"
|
|||
|
|
if options.Language == "en" {
|
|||
|
|
riseSetLabel = "Rise/set phase lines"
|
|||
|
|
}
|
|||
|
|
if len(partial.RiseSetCurves) > 0 {
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{label: riseSetLabel, kind: "line", color: "#d97706", dash: "6 4"})
|
|||
|
|
}
|
|||
|
|
if len(partial.MagnitudeContours) > 0 {
|
|||
|
|
magnitudeLabel := "地方食分 0.2–0.8"
|
|||
|
|
if options.Language == "en" {
|
|||
|
|
magnitudeLabel = "Local magnitude 0.2-0.8"
|
|||
|
|
}
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{label: magnitudeLabel, kind: "line", color: "#7c3aed", dash: "4 3"})
|
|||
|
|
}
|
|||
|
|
if len(partial.GreatestTimeContours) > 0 {
|
|||
|
|
isochroneLabel := "食甚时刻等时线"
|
|||
|
|
if options.Language == "en" {
|
|||
|
|
isochroneLabel = "Greatest-eclipse isochrones"
|
|||
|
|
}
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{label: isochroneLabel, kind: "line", color: "#1f6fb2"})
|
|||
|
|
}
|
|||
|
|
if info.Centrality != eclipsecore.SolarEclipseNonCentral &&
|
|||
|
|
len(partial.CentralShadowFootprints) > 0 && options.CentralShadowStep > 0 {
|
|||
|
|
items = append(items, solarEclipseMapLegendItem{
|
|||
|
|
label: solarEclipseOutlineLegendLabel("central", info.Type, options.CentralShadowStep, options.Language),
|
|||
|
|
kind: "line", color: "#7b5a42",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
contactLabel := "P/U 影锥接触"
|
|||
|
|
hasUContacts := !partial.U1.Time.IsZero() || !partial.U2.Time.IsZero() ||
|
|||
|
|
!partial.U3.Time.IsZero() || !partial.U4.Time.IsZero()
|
|||
|
|
if !hasUContacts {
|
|||
|
|
contactLabel = "P 半影接触"
|
|||
|
|
}
|
|||
|
|
if options.Language == "en" {
|
|||
|
|
contactLabel = "P/U shadow contacts"
|
|||
|
|
if !hasUContacts {
|
|||
|
|
contactLabel = "P penumbral contacts"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return append(items, solarEclipseMapLegendItem{label: contactLabel, kind: "contact", color: "#a52d70"})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapLegendRows 按可用宽度把条目分行:每行不超过四栏,栏位由实测文本宽度累加,长标签不会挤进下一栏。
|
|||
|
|
func solarEclipseMapLegendRows(items []solarEclipseMapLegendItem, available float64) [][]solarEclipseMapLegendItem {
|
|||
|
|
rows := make([][]solarEclipseMapLegendItem, 0, 2)
|
|||
|
|
current := make([]solarEclipseMapLegendItem, 0, solarEclipseLegendMaxColumns)
|
|||
|
|
used := 0.0
|
|||
|
|
flush := func() {
|
|||
|
|
if len(current) == 0 {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
rows = append(rows, current)
|
|||
|
|
current = make([]solarEclipseMapLegendItem, 0, solarEclipseLegendMaxColumns)
|
|||
|
|
used = 0
|
|||
|
|
}
|
|||
|
|
for _, item := range items {
|
|||
|
|
width := solarEclipseLegendIconWidth + svgchart.EstimatedTextWidth(item.label, solarEclipseLegendFontSize)
|
|||
|
|
next := width
|
|||
|
|
if len(current) > 0 {
|
|||
|
|
next = used + solarEclipseLegendColumnGap + width
|
|||
|
|
if len(current) >= solarEclipseLegendMaxColumns || next > available {
|
|||
|
|
flush()
|
|||
|
|
next = width
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
current = append(current, item)
|
|||
|
|
used = next
|
|||
|
|
}
|
|||
|
|
flush()
|
|||
|
|
return rows
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapLegendBlockHeight 由图例行数给出图例带高度:首行基线上方还要留出字高。
|
|||
|
|
func solarEclipseMapLegendBlockHeight(rows int) float64 {
|
|||
|
|
if rows < 1 {
|
|||
|
|
rows = 1
|
|||
|
|
}
|
|||
|
|
above, below := svgchart.EstimatedTextExtents(solarEclipseLegendFontSize)
|
|||
|
|
return float64(rows-1)*solarEclipseLegendLineStep + above + below + 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapLegendAvailableWidth 只由画布宽度与排布给出图例可用宽度,与竖向堆叠无关。
|
|||
|
|
func solarEclipseMapLegendAvailableWidth(options SolarEclipseMapSVGOptions) float64 {
|
|||
|
|
width, height := float64(options.Width), float64(options.Height)
|
|||
|
|
if width >= height {
|
|||
|
|
panelGap := math.Max(12, width*0.011)
|
|||
|
|
return solarEclipseLandscapeColumns*solarEclipseLandscapeCellWidth +
|
|||
|
|
float64(solarEclipseLandscapeColumns-1)*panelGap - 26
|
|||
|
|
}
|
|||
|
|
margin := math.Max(30, math.Min(52, width*0.044))
|
|||
|
|
return width - 2*margin - 26
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapPointLabel 是一个点标注:图形锚点、文本、字号与落位结果。
|
|||
|
|
type solarEclipseMapPointLabel struct {
|
|||
|
|
x, y float64
|
|||
|
|
text string
|
|||
|
|
fontSize float64
|
|||
|
|
placed svgchart.LabelPlacement
|
|||
|
|
ok bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipseMapPlaceLabel(
|
|||
|
|
labels *svgchart.LabelTable,
|
|||
|
|
label solarEclipseMapPointLabel,
|
|||
|
|
candidates []svgchart.LabelPlacement,
|
|||
|
|
frame svgmap.Frame,
|
|||
|
|
) solarEclipseMapPointLabel {
|
|||
|
|
label.placed, label.ok = labels.Place(label.text, label.fontSize,
|
|||
|
|
labelCandidatesInsideBox(candidates, label.text, label.fontSize, solarEclipseFrameBox(frame)))
|
|||
|
|
return label
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// labelCandidatesInsideBox 丢掉会让文本跑出给定区域的候选位次。
|
|||
|
|
func labelCandidatesInsideBox(
|
|||
|
|
candidates []svgchart.LabelPlacement,
|
|||
|
|
text string,
|
|||
|
|
fontSize float64,
|
|||
|
|
area svgchart.LabelBox,
|
|||
|
|
) []svgchart.LabelPlacement {
|
|||
|
|
kept := make([]svgchart.LabelPlacement, 0, len(candidates))
|
|||
|
|
for _, candidate := range candidates {
|
|||
|
|
box := svgchart.LabelTextBox(candidate.X, candidate.Y, fontSize, text, candidate.Anchor)
|
|||
|
|
if box.X < area.X || box.X+box.Width > area.X+area.Width {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if box.Y < area.Y || box.Y+box.Height > area.Y+area.Height {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
kept = append(kept, candidate)
|
|||
|
|
}
|
|||
|
|
return kept
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseFrameBox 把图框写成占位矩形。
|
|||
|
|
func solarEclipseFrameBox(frame svgmap.Frame) svgchart.LabelBox {
|
|||
|
|
return svgchart.LabelBox{X: frame.X, Y: frame.Y, Width: frame.Width, Height: frame.Height}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapPointLabels 汇总优先放置的点标注:食甚、影锥接触、太阳直射点与中心线时刻。
|
|||
|
|
type solarEclipseMapPointLabels struct {
|
|||
|
|
greatest solarEclipseMapPointLabel
|
|||
|
|
contacts []solarEclipseContactLabel
|
|||
|
|
subsolar solarEclipseMapPointLabel
|
|||
|
|
times []solarEclipseMapPointLabel
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseContactMarker 是一个影锥接触标记:点名、位置、颜色与原位标注偏移。
|
|||
|
|
type solarEclipseContactMarker struct {
|
|||
|
|
name string
|
|||
|
|
point eclipsecore.SolarEclipsePathPoint
|
|||
|
|
color string
|
|||
|
|
labelDX float64
|
|||
|
|
labelDY float64
|
|||
|
|
textAnchor string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseContactLabel 是接触标记的落位结果:图形锚点与标注文本。
|
|||
|
|
type solarEclipseContactLabel struct {
|
|||
|
|
marker solarEclipseContactMarker
|
|||
|
|
x, y float64
|
|||
|
|
text string
|
|||
|
|
placed svgchart.LabelPlacement
|
|||
|
|
ok bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipseContactMarkerSpecs(info eclipsecore.SolarEclipsePartialFootprintsInfo) []solarEclipseContactMarker {
|
|||
|
|
return []solarEclipseContactMarker{
|
|||
|
|
{name: "P1", point: info.P1, color: "#a52d70", labelDX: -7, labelDY: 14, textAnchor: "end"},
|
|||
|
|
{name: "P2", point: info.P2, color: "#a52d70", labelDX: 7, labelDY: -7, textAnchor: "start"},
|
|||
|
|
{name: "P3", point: info.P3, color: "#a52d70", labelDX: -7, labelDY: -7, textAnchor: "end"},
|
|||
|
|
{name: "P4", point: info.P4, color: "#a52d70", labelDX: 7, labelDY: 14, textAnchor: "start"},
|
|||
|
|
{name: "U1", point: info.U1, color: "#a12c25", labelDX: -7, labelDY: -7, textAnchor: "end"},
|
|||
|
|
{name: "U2", point: info.U2, color: "#a12c25", labelDX: 7, labelDY: 14, textAnchor: "start"},
|
|||
|
|
{name: "U3", point: info.U3, color: "#a12c25", labelDX: -7, labelDY: 14, textAnchor: "end"},
|
|||
|
|
{name: "U4", point: info.U4, color: "#a12c25", labelDX: 7, labelDY: -7, textAnchor: "start"},
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseMapPlacePointLabels 按重要性放置点标注:食甚最先占位,其余标注再让开。
|
|||
|
|
func solarEclipseMapPlacePointLabels(
|
|||
|
|
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
|
|||
|
|
central eclipsecore.SolarEclipsePath,
|
|||
|
|
hasCentral bool,
|
|||
|
|
frame svgmap.Frame,
|
|||
|
|
options SolarEclipseMapSVGOptions,
|
|||
|
|
labels *svgchart.LabelTable,
|
|||
|
|
) solarEclipseMapPointLabels {
|
|||
|
|
result := solarEclipseMapPointLabels{}
|
|||
|
|
if x, y, ok := frame.Project(partial.Eclipse.GreatestLongitude, partial.Eclipse.GreatestLatitude); ok {
|
|||
|
|
text := "食甚"
|
|||
|
|
if options.Language == "en" {
|
|||
|
|
text = "Greatest"
|
|||
|
|
}
|
|||
|
|
result.greatest = solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: text, fontSize: 11},
|
|||
|
|
[]svgchart.LabelPlacement{
|
|||
|
|
{X: x, Y: y - 10, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y + 19, Anchor: "middle"},
|
|||
|
|
{X: x + 9, Y: y + 4, Anchor: "start"},
|
|||
|
|
{X: x - 9, Y: y + 4, Anchor: "end"},
|
|||
|
|
{X: x + 9, Y: y - 8, Anchor: "start"},
|
|||
|
|
{X: x - 9, Y: y - 8, Anchor: "end"},
|
|||
|
|
{X: x, Y: y - 24, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y + 33, Anchor: "middle"},
|
|||
|
|
}, frame)
|
|||
|
|
}
|
|||
|
|
result.contacts = solarEclipseMapPlaceContactLabels(partial, frame, labels)
|
|||
|
|
result.subsolar = solarEclipseMapPlaceSubsolarLabel(partial.Eclipse, frame, options.Language, labels)
|
|||
|
|
if hasCentral {
|
|||
|
|
result.times = solarEclipseMapPlaceTimeLabels(central, frame, options, labels)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipseMapPlaceContactLabels(
|
|||
|
|
info eclipsecore.SolarEclipsePartialFootprintsInfo,
|
|||
|
|
frame svgmap.Frame,
|
|||
|
|
labels *svgchart.LabelTable,
|
|||
|
|
) []solarEclipseContactLabel {
|
|||
|
|
specs := solarEclipseContactMarkerSpecs(info)
|
|||
|
|
result := make([]solarEclipseContactLabel, 0, len(specs))
|
|||
|
|
for _, marker := range specs {
|
|||
|
|
if marker.point.Time.IsZero() {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
x, y, visible := frame.Project(marker.point.Longitude, marker.point.Latitude)
|
|||
|
|
if !visible {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
labelX, anchor := x+marker.labelDX, marker.textAnchor
|
|||
|
|
// 贴到图框边上的点名改到另一侧,避免被裁掉。
|
|||
|
|
if labelX < frame.X+18 {
|
|||
|
|
labelX, anchor = x+7, "start"
|
|||
|
|
} else if labelX > frame.X+frame.Width-18 {
|
|||
|
|
labelX, anchor = x-7, "end"
|
|||
|
|
}
|
|||
|
|
candidates := make([]svgchart.LabelPlacement, 0, 22)
|
|||
|
|
candidates = append(candidates, svgchart.LabelPlacement{X: labelX, Y: y + marker.labelDY, Anchor: anchor})
|
|||
|
|
candidates = append(candidates, svgchart.LabelCandidates(x+7, y+14, "start", 9)...)
|
|||
|
|
candidates = append(candidates, svgchart.LabelCandidates(x-7, y-7, "end", 9)...)
|
|||
|
|
candidates = append(candidates, svgchart.LabelCandidates(x+7, y-7, "start", 16)...)
|
|||
|
|
candidates = append(candidates, svgchart.LabelCandidates(x-7, y+14, "end", 16)...)
|
|||
|
|
label := solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: marker.name, fontSize: 8}, candidates, frame)
|
|||
|
|
result = append(result, solarEclipseContactLabel{marker: marker, x: x, y: y, text: marker.name, placed: label.placed, ok: label.ok})
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipseMapPlaceSubsolarLabel(
|
|||
|
|
info eclipsecore.SolarEclipseInfo,
|
|||
|
|
frame svgmap.Frame,
|
|||
|
|
language string,
|
|||
|
|
labels *svgchart.LabelTable,
|
|||
|
|
) solarEclipseMapPointLabel {
|
|||
|
|
point := solarEclipseSubsolarPoint(info.GreatestEclipse)
|
|||
|
|
x, y, visible := frame.Project(point.Longitude, point.Latitude)
|
|||
|
|
if !visible {
|
|||
|
|
return solarEclipseMapPointLabel{}
|
|||
|
|
}
|
|||
|
|
text := "太阳直射点"
|
|||
|
|
if language == "en" {
|
|||
|
|
text = "Subsolar"
|
|||
|
|
}
|
|||
|
|
return solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: text, fontSize: 7},
|
|||
|
|
[]svgchart.LabelPlacement{
|
|||
|
|
{X: x, Y: y - 9, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y + 16, Anchor: "middle"},
|
|||
|
|
{X: x + 7, Y: y + 4, Anchor: "start"},
|
|||
|
|
{X: x - 7, Y: y + 4, Anchor: "end"},
|
|||
|
|
{X: x, Y: y - 22, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y + 29, Anchor: "middle"},
|
|||
|
|
{X: x + 16, Y: y + 4, Anchor: "start"},
|
|||
|
|
{X: x - 16, Y: y + 4, Anchor: "end"},
|
|||
|
|
}, frame)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func solarEclipseMapPlaceTimeLabels(
|
|||
|
|
path eclipsecore.SolarEclipsePath,
|
|||
|
|
frame svgmap.Frame,
|
|||
|
|
options SolarEclipseMapSVGOptions,
|
|||
|
|
labels *svgchart.LabelTable,
|
|||
|
|
) []solarEclipseMapPointLabel {
|
|||
|
|
if options.TimeLabelStep <= 0 || len(path.CenterLine) < 2 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
excluded := []time.Time{path.Eclipse.CentralBeginOnEarth, path.Eclipse.CentralEndOnEarth}
|
|||
|
|
markers := solarEclipseTimeMarkerPoints(path.CenterLine, options.TimeLabelStep, options.Location, excluded)
|
|||
|
|
result := make([]solarEclipseMapPointLabel, 0, len(markers))
|
|||
|
|
projected := make([][2]float64, 0, len(markers))
|
|||
|
|
for _, marker := range markers {
|
|||
|
|
x, y, visible := frame.Project(marker.Longitude, marker.Latitude)
|
|||
|
|
if !visible || x < frame.X+22 || x > frame.X+frame.Width-22 || y < frame.Y+12 || y > frame.Y+frame.Height-12 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
tooClose := false
|
|||
|
|
for _, previous := range projected {
|
|||
|
|
if math.Hypot(x-previous[0], y-previous[1]) < 44 {
|
|||
|
|
tooClose = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if tooClose {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
projected = append(projected, [2]float64{x, y})
|
|||
|
|
preferredY := y - 8
|
|||
|
|
if mapTimesNear(marker.Time, path.Eclipse.GreatestEclipse, solarEclipseGreatestTimeLabelWindow(options.TimeLabelStep)) {
|
|||
|
|
preferredY = y + 15
|
|||
|
|
} else if preferredY < frame.Y+10 {
|
|||
|
|
preferredY = y + 15
|
|||
|
|
}
|
|||
|
|
text := marker.Time.In(options.Location).Format("15:04")
|
|||
|
|
label := solarEclipseMapPlaceLabel(labels, solarEclipseMapPointLabel{x: x, y: y, text: text, fontSize: 9},
|
|||
|
|
[]svgchart.LabelPlacement{
|
|||
|
|
{X: x, Y: preferredY, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y + 15, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y - 8, Anchor: "middle"},
|
|||
|
|
{X: x + 11, Y: y + 4, Anchor: "start"},
|
|||
|
|
{X: x - 11, Y: y + 4, Anchor: "end"},
|
|||
|
|
{X: x, Y: y + 27, Anchor: "middle"},
|
|||
|
|
{X: x, Y: y - 21, Anchor: "middle"},
|
|||
|
|
}, frame)
|
|||
|
|
result = append(result, label)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseContourLabelCandidates 先给首选点,再沿最长支路从中间向两端取候选位次,标注始终贴着等值线。
|
|||
|
|
func solarEclipseContourLabelCandidates(
|
|||
|
|
segments [][]eclipsecore.SolarEclipsePathPoint,
|
|||
|
|
frame svgmap.Frame,
|
|||
|
|
preferredX, preferredY float64,
|
|||
|
|
hasPreferred bool,
|
|||
|
|
) []svgchart.LabelPlacement {
|
|||
|
|
candidates := make([]svgchart.LabelPlacement, 0, 48)
|
|||
|
|
if hasPreferred {
|
|||
|
|
candidates = append(candidates, svgchart.LabelPlacement{X: preferredX, Y: preferredY, Anchor: "middle"})
|
|||
|
|
}
|
|||
|
|
longest := -1
|
|||
|
|
for index, segment := range segments {
|
|||
|
|
if longest < 0 || len(segment) > len(segments[longest]) {
|
|||
|
|
longest = index
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if longest < 0 {
|
|||
|
|
return candidates
|
|||
|
|
}
|
|||
|
|
segment := segments[longest]
|
|||
|
|
middle := len(segment) / 2
|
|||
|
|
for offset := 0; offset <= middle; offset++ {
|
|||
|
|
for _, index := range []int{middle + offset, middle - offset} {
|
|||
|
|
if index < 0 || index >= len(segment) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
point := segment[index]
|
|||
|
|
x, y, visible := frame.Project(point.Longitude, point.Latitude)
|
|||
|
|
if !visible ||
|
|||
|
|
x < frame.X+30 || x > frame.X+frame.Width-30 ||
|
|||
|
|
y < frame.Y+18 || y > frame.Y+frame.Height-18 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
candidates = append(candidates, svgchart.LabelPlacement{X: x, Y: y - 6, Anchor: "middle"})
|
|||
|
|
if len(candidates) >= 48 {
|
|||
|
|
return candidates
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return candidates
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// solarEclipseScaleStep 在槽位宽度内取最大的整刻度,required 给出该刻度下刻度条加标注的总横向占用。
|
|||
|
|
func solarEclipseScaleStep(target, available float64, required func(step float64) float64) float64 {
|
|||
|
|
if target <= 0 || available <= 0 {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
start := int(math.Floor(math.Log10(target)))
|
|||
|
|
for exponent := start + 1; exponent >= start-4; exponent-- {
|
|||
|
|
decade := math.Pow(10, float64(exponent))
|
|||
|
|
for _, multiplier := range []float64{5, 2, 1} {
|
|||
|
|
step := decade * multiplier
|
|||
|
|
if required(step) <= available {
|
|||
|
|
return step
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return 0
|
|||
|
|
}
|