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 }