package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
)
// LunarEclipseDetailedSVGOptions 控制 详细版式的月食组合图:摘要、穿影示意图与世界可见性底图合成一页。
// LunarEclipseDetailedSVGOptions controls the detailed combined lunar-eclipse chart: summary, shadow-path
// diagram, and world visibility map on a single page.
type LunarEclipseDetailedSVGOptions struct {
// Width 与 Height 是 SVG 画布尺寸;<=0 时使用 1000x1414 的 NASA 开本。版式按 Height 推导:
// 高度不足时先压缩穿影示意图,再压缩底图;两者都低于下限(示意图 200、底图 180)、
// 数据块行距或底图图例行放不下时返回 false。
// Width and Height are the SVG canvas size; values <= 0 use the 1000x1414 NASA page. The page
// stack is derived from Height: a short canvas shrinks the shadow-path diagram first and the
// map second, and the renderer returns false when the diagram and map minima (200 and 180), the
// data-block row spacing, or the map legend rows cannot be kept apart.
Width int
Height int
// Language 为 "en" 时使用英文,其他值使用中文。
// Language uses English for "en" and Chinese otherwise.
Language string
// Location 控制显示时刻的时区;nil 使用 UTC+8。
// Location controls the display timezone; nil uses UTC+8.
Location *time.Location
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
// Title 与 FooterNote 为空时自动生成。
// Empty Title and FooterNote use automatic text.
Title string
FooterNote string
}
// LunarEclipseDetailedSVG 生成 详细版式的月食组合图,默认模型与 LunarEclipseOnDate 一致:Danjon 为主,极浅半影回退 Chauvenet。
// LunarEclipseDetailedSVG renders a detailed combined lunar-eclipse chart with the default model of
// LunarEclipseOnDate: Danjon, falling back to Chauvenet for ultra-shallow penumbral cases.
func LunarEclipseDetailedSVG(date time.Time, options LunarEclipseDetailedSVGOptions) (string, bool) {
model, ok := lunarEclipseDefaultShadowModel(date)
if !ok {
return "", false
}
if model == eclipsecore.LunarEclipseShadowModelChauvenet {
return LunarEclipseDetailedSVGChauvenet(date, options)
}
return LunarEclipseDetailedSVGDanjon(date, options)
}
// LunarEclipseDetailedSVGDanjon 生成 详细版式月食组合图,使用 Danjon 影半径模型。
// LunarEclipseDetailedSVGDanjon renders a detailed chart with the Danjon shadow model.
func LunarEclipseDetailedSVGDanjon(date time.Time, options LunarEclipseDetailedSVGOptions) (string, bool) {
return lunarEclipseDetailedSVG(date, options,
basic.LunarEclipseDiagramDanjon, basic.LunarEclipseShadowGeometryAt, eclipsecore.ClosestLunarEclipseDanjon,
eclipsecore.LunarEclipseShadowModelDanjon)
}
// LunarEclipseDetailedSVGChauvenet 生成 详细版式月食组合图,使用 Chauvenet 影半径模型。
// LunarEclipseDetailedSVGChauvenet renders a detailed chart with the Chauvenet shadow model.
func LunarEclipseDetailedSVGChauvenet(date time.Time, options LunarEclipseDetailedSVGOptions) (string, bool) {
return lunarEclipseDetailedSVG(date, options,
basic.LunarEclipseDiagramChauvenet, basic.LunarEclipseShadowGeometryChauvenetAt, eclipsecore.ClosestLunarEclipseChauvenet,
eclipsecore.LunarEclipseShadowModelChauvenet)
}
func normalizeLunarEclipseDetailedSVGOptions(options LunarEclipseDetailedSVGOptions) LunarEclipseDetailedSVGOptions {
if options.Width <= 0 {
options.Width = 1000
}
if options.Height <= 0 {
options.Height = 1414
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", lunarEclipseSVGDefaultZone)
}
if strings.EqualFold(options.Language, lunarEclipseSVGLanguageEnglish) {
options.Language = lunarEclipseSVGLanguageEnglish
} else {
options.Language = lunarEclipseSVGLanguageChinese
}
return options
}
func lunarEclipseDetailedSVG(
date time.Time,
options LunarEclipseDetailedSVGOptions,
calculator lunarEclipseSVGCalculator,
geometry func(float64) basic.LunarEclipseShadowGeometry,
finder lunarEclipseSVGFinder,
model eclipsecore.LunarEclipseShadowModel,
) (string, bool) {
options = normalizeLunarEclipseDetailedSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
})
if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 {
return "", false
}
info := lunarEclipseInfoFromBasic(diagram.Eclipse, options.Location)
if finder != nil {
coreInfo := finder(info.Maximum)
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
if !validLunarEclipseDetailedSize(options.Width, options.Height, info, options) {
return "", false
}
return renderLunarEclipseDetailedSVG(
info, diagram, geometry(diagram.Eclipse.Maximum), timeToTTJDE(date), options, model,
), true
}
// lunarEclipseDetailedView 汇总 详细版式各区块的位置。
type lunarEclipseDetailedView struct {
width, height float64
margin float64
metaY float64
blockY float64
diagramTop float64
diagramBottom float64
rowY float64
rowHeight float64
mapFrame svgmap.Frame
legendY float64
legendRows int
footerY float64
columnWidth, columnGap float64
}
// 详细版式的竖向固定量:标题/摘要/地心块占用顶部,示意图与底图分掉剩余高度。
const (
lunarEclipseDetailedDiagramTop = 268.0
lunarEclipseDetailedDiagramHeight = 368.0
lunarEclipseDetailedPanelHeight = 152.0
lunarEclipseDetailedPanelGap = 28.0
lunarEclipseDetailedMapGap = 26.0
lunarEclipseDetailedLegendGap = 30.0
lunarEclipseDetailedFooterReserve = 52.0
lunarEclipseDetailedMinDiagram = 200.0
lunarEclipseDetailedMinMapHeight = 180.0
lunarEclipseDetailedMinColumnWidth = 150.0
// 图例与页脚的字号、页脚基线到画布底边的固定内缩,以及两者之间的最小间隙。
lunarEclipseDetailedLegendFontSize = 10.0
lunarEclipseDetailedFooterFontSize = 11.0
lunarEclipseDetailedFooterInset = 42.0
lunarEclipseDetailedLegendFooterGap = 4.0
// 底图可见性图例:字号、图标加文本占位宽、列间隙、行距与页边内缩。
lunarEclipseDetailedMapLegendFontSize = 10.0
lunarEclipseDetailedMapLegendIconWidth = 24.0
lunarEclipseDetailedMapLegendGap = 12.0
lunarEclipseDetailedMapLegendLineStep = 22.0
lunarEclipseDetailedPageInset = 20.0
)
// lunarEclipseDetailedVerticalBudget 是扣掉顶部固定带、数据面板、图例与页脚后可分给示意图与底图的高度。
func lunarEclipseDetailedVerticalBudget(height float64) float64 {
return height - (lunarEclipseDetailedDiagramTop + lunarEclipseDetailedPanelGap +
lunarEclipseDetailedPanelHeight + lunarEclipseDetailedMapGap +
lunarEclipseDetailedLegendGap + lunarEclipseDetailedFooterReserve)
}
// lunarEclipseDetailedMapHeight 给出该画布下底图高度:先保示意图下限,再把剩余高度给底图,
// 底图宽度还要受画布宽度与 2:1 长宽比限制。
func lunarEclipseDetailedMapHeight(width, height, margin float64) float64 {
budget := lunarEclipseDetailedVerticalBudget(height)
return math.Min((width-2*margin)/2, budget-lunarEclipseDetailedMinDiagram)
}
// validLunarEclipseDetailedSize 报告详细版式在该画布尺寸下能否容下全部内容:
// 除竖向预算与栏宽外,数据块行距、图例带与页脚也必须互不压叠。
func validLunarEclipseDetailedSize(width, height int, info LunarEclipseInfo, options LunarEclipseDetailedSVGOptions) bool {
w, h := float64(width), float64(height)
margin := math.Max(30, math.Min(52, w*0.044))
budget := lunarEclipseDetailedVerticalBudget(h)
mapHeight := lunarEclipseDetailedMapHeight(w, h, margin)
if mapHeight < lunarEclipseDetailedMinMapHeight || budget-mapHeight < lunarEclipseDetailedMinDiagram {
return false
}
columnGap := math.Max(16, w*0.02)
if (w-2*margin-2*columnGap)/3 < lunarEclipseDetailedMinColumnWidth {
return false
}
view := lunarEclipseDetailedViewFor(options)
if solarEclipsePanelsOverlap(lunarEclipseDetailedPanelCells(view, info, options)) {
return false
}
_, legendBelow := svgchart.EstimatedTextExtents(lunarEclipseDetailedMapLegendFontSize)
_, footerBelow := svgchart.EstimatedTextExtents(lunarEclipseDetailedFooterFontSize)
if view.footerY < view.legendY+legendBelow {
return false
}
// 页脚要留在白色页框内,越出画布就没有可读位置了。
return view.footerY+footerBelow <= h-20
}
// lunarEclipseDetailedPanelContent 给出三栏数据块的内容:左栏历时、右栏接触时刻。
func lunarEclipseDetailedPanelContent(
info LunarEclipseInfo,
options LunarEclipseDetailedSVGOptions,
) (string, []svgchart.PanelRow, string, []svgchart.PanelRow) {
english := options.Language == lunarEclipseSVGLanguageEnglish
duration := func(start, end time.Time) string {
if start.IsZero() || end.IsZero() || !end.After(start) {
return "-"
}
return formatSolarEclipseMapDuration(end.Sub(start))
}
durationsTitle := "月食历时"
contactsTitle := "接触时刻"
if english {
durationsTitle = "Eclipse durations"
contactsTitle = "Eclipse contacts"
}
durations := []svgchart.PanelRow{
{Label: "半影食", Value: duration(info.PenumbralStart, info.PenumbralEnd)},
{Label: "偏食", Value: duration(info.PartialStart, info.PartialEnd)},
{Label: "全食", Value: duration(info.TotalStart, info.TotalEnd)},
}
if english {
durations[0].Label, durations[1].Label, durations[2].Label = "Penumbral", "Partial", "Total"
}
contacts := []svgchart.PanelRow{
{Label: "P1 半影食始", Value: info.PenumbralStart.In(options.Location).Format("15:04:05")},
{Label: "U1 初亏", Value: info.PartialStart.In(options.Location).Format("15:04:05")},
{Label: "U2 食既", Value: info.TotalStart.In(options.Location).Format("15:04:05")},
{Label: "食甚", Value: info.Maximum.In(options.Location).Format("15:04:05")},
{Label: "U3 生光", Value: info.TotalEnd.In(options.Location).Format("15:04:05")},
{Label: "U4 复圆", Value: info.PartialEnd.In(options.Location).Format("15:04:05")},
{Label: "P4 半影食终", Value: info.PenumbralEnd.In(options.Location).Format("15:04:05")},
}
if english {
contacts = []svgchart.PanelRow{
{Label: "P1 penumbral begins", Value: info.PenumbralStart.In(options.Location).Format("15:04:05")},
{Label: "U1 partial begins", Value: info.PartialStart.In(options.Location).Format("15:04:05")},
{Label: "U2 total begins", Value: info.TotalStart.In(options.Location).Format("15:04:05")},
{Label: "Greatest", Value: info.Maximum.In(options.Location).Format("15:04:05")},
{Label: "U3 total ends", Value: info.TotalEnd.In(options.Location).Format("15:04:05")},
{Label: "U4 partial ends", Value: info.PartialEnd.In(options.Location).Format("15:04:05")},
{Label: "P4 penumbral ends", Value: info.PenumbralEnd.In(options.Location).Format("15:04:05")},
}
}
if !info.HasTotal {
contacts[2].Value, contacts[4].Value = "-", "-"
}
if !info.HasPartial {
contacts[1].Value, contacts[5].Value = "-", "-"
}
return durationsTitle, durations, contactsTitle, contacts
}
// lunarEclipseDetailedPanelCells 给出三栏数据块的矩形与内容:占位与绘制共用同一份几何。
func lunarEclipseDetailedPanelCells(
view lunarEclipseDetailedView,
info LunarEclipseInfo,
options LunarEclipseDetailedSVGOptions,
) []solarEclipsePanelCell {
durationsTitle, durations, contactsTitle, contacts := lunarEclipseDetailedPanelContent(info, options)
right := view.margin + 2*(view.columnWidth+view.columnGap)
return []solarEclipsePanelCell{
{title: durationsTitle, rows: durations, box: svgchart.LabelBox{
X: view.margin, Y: view.rowY, Width: view.columnWidth, Height: view.rowHeight,
}},
{title: contactsTitle, rows: contacts, box: svgchart.LabelBox{
X: right, Y: view.rowY, Width: view.columnWidth, Height: view.rowHeight,
}},
}
}
func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView {
width, height := float64(options.Width), float64(options.Height)
margin := math.Max(30, math.Min(52, width*0.044))
view := lunarEclipseDetailedView{
width: width, height: height, margin: margin,
metaY: 88, blockY: 196,
diagramTop: lunarEclipseDetailedDiagramTop,
rowHeight: lunarEclipseDetailedPanelHeight,
}
view.columnGap = math.Max(16, width*0.02)
view.columnWidth = (width - 2*margin - 2*view.columnGap) / 3
// 竖向是一条带栈:示意图与底图分掉固定带之外的高度;高度不足时两者一起缩,
// 到下限的画布由 validLunarEclipseDetailedSize 提前拒绝,这里不再兜底。
budget := lunarEclipseDetailedVerticalBudget(height)
mapHeight := lunarEclipseDetailedMapHeight(width, height, margin)
diagramHeight := math.Min(lunarEclipseDetailedDiagramHeight, budget-mapHeight)
view.diagramBottom = view.diagramTop + diagramHeight
view.rowY = view.diagramBottom + lunarEclipseDetailedPanelGap
mapTop := view.rowY + view.rowHeight + lunarEclipseDetailedMapGap
mapWidth := mapHeight * 2
if limit := width - 2*margin; mapWidth > limit {
mapWidth = limit
mapHeight = mapWidth / 2
}
view.mapFrame = svgmap.Frame{
X: (width - mapWidth) / 2, Y: mapTop, Width: mapWidth, Height: mapHeight,
Projection: svgmap.ProjectionEquirectangular,
}
view.legendY = mapTop + mapHeight + lunarEclipseDetailedLegendGap
view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language))
if view.legendRows < 1 {
view.legendRows = 1
}
// 页脚按图例实测高度往下排:图例带再往下压也不能与页脚同高。
_, legendBelow := svgchart.EstimatedTextExtents(lunarEclipseDetailedMapLegendFontSize)
footerAbove, _ := svgchart.EstimatedTextExtents(lunarEclipseDetailedFooterFontSize)
footerFloor := view.legendY + float64(view.legendRows-1)*lunarEclipseDetailedMapLegendLineStep +
legendBelow + lunarEclipseDetailedLegendFooterGap + footerAbove
view.footerY = math.Max(height-lunarEclipseDetailedFooterInset, footerFloor)
return view
}
func renderLunarEclipseDetailedSVG(
info LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
shadow basic.LunarEclipseShadowGeometry,
tt float64,
options LunarEclipseDetailedSVGOptions,
model eclipsecore.LunarEclipseShadowModel,
) string {
view := lunarEclipseDetailedViewFor(options)
english := options.Language == lunarEclipseSVGLanguageEnglish
title := options.Title
if title == "" {
date := info.Maximum.In(options.Location).Format("2006-01-02")
if english {
title = fmt.Sprintf("Total Lunar Eclipse of %s", date)
} else {
title = fmt.Sprintf("%s %s", date, lunarEclipseSVGTypeName(info.Type, "zh"))
}
}
extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72
scale := math.Min(
(view.width-2*view.margin)/(2*extent),
(view.diagramBottom-view.diagramTop)/(2*extent),
)
if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
scale = 1
}
layout := lunarEclipseSVGLayout{
width: view.width, height: view.height,
cx: view.width / 2,
cy: (view.diagramTop + view.diagramBottom) / 2,
scale: scale,
diagramTop: view.diagramTop, diagramEnd: view.diagramBottom,
diagramLeft: view.margin, diagramRight: view.width - view.margin,
}
// 不动的元素先占位:数据块、底图、图例与页脚;示意图标注只在示意图带内选位置。
labels := &svgchart.LabelTable{}
for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) {
labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height)
}
labels.Reserve(view.mapFrame.X, view.mapFrame.Y, view.mapFrame.Width, view.mapFrame.Height)
for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language) {
for _, item := range row {
labels.ReserveText(item.x, item.y, lunarEclipseDetailedMapLegendFontSize, item.text, "start")
}
}
labels.ReserveText(view.margin, view.footerY, lunarEclipseDetailedFooterFontSize,
lunarEclipseDetailedFooterText(options, model), "start")
var b strings.Builder
fmt.Fprintf(&b, ``)
return b.String()
}
// writeLunarEclipseDetailedSummary 写标题下居中的四行摘要。
func writeLunarEclipseDetailedSummary(
b *strings.Builder,
labels *svgchart.LabelTable,
info LunarEclipseInfo,
shadow basic.LunarEclipseShadowGeometry,
view lunarEclipseDetailedView,
options LunarEclipseDetailedSVGOptions,
) {
english := options.Language == lunarEclipseSVGLanguageEnglish
zone, _ := info.Maximum.In(options.Location).Zone()
lines := make([]string, 0, 4)
if english {
lines = append(lines, fmt.Sprintf("Greatest Eclipse = %s (%s) | Umbral magnitude = %.4f",
info.Maximum.In(options.Location).Format("15:04:05"), zone, info.UmbralMagnitude))
lines = append(lines, fmt.Sprintf("Penumbral magnitude = %.4f | Gamma = %.4f",
info.PenumbralMagnitude, shadow.Gamma))
lines = append(lines, fmt.Sprintf("P. Radius = %.4f° | U. Radius = %.4f° | Axis = %.4f°",
shadow.PenumbralRadiusDegrees, shadow.UmbralRadiusDegrees, shadow.AxisDegrees))
lines = append(lines, fmt.Sprintf("Saros series = %d | member %d of %d", info.Saros.Series, info.Saros.Member, info.Saros.Count))
} else {
lines = append(lines, fmt.Sprintf("食甚 = %s(%s)| 本影食分 = %.4f",
info.Maximum.In(options.Location).Format("15:04:05"), zone, info.UmbralMagnitude))
lines = append(lines, fmt.Sprintf("半影食分 = %.4f | 伽马 = %.4f", info.PenumbralMagnitude, shadow.Gamma))
lines = append(lines, fmt.Sprintf("半影半径 = %.4f° | 本影半径 = %.4f° | 影轴角距 = %.4f°",
shadow.PenumbralRadiusDegrees, shadow.UmbralRadiusDegrees, shadow.AxisDegrees))
lines = append(lines, fmt.Sprintf("沙罗序列 = %d | 第 %d / %d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count))
}
if !info.HasSaros {
lines[len(lines)-1] = ""
}
for index, line := range lines {
if line == "" {
continue
}
labels.ReserveText(view.width/2, view.metaY+float64(index)*22, 12.5, line, "middle")
fmt.Fprintf(b, `%s`,
view.width/2, view.metaY+float64(index)*22, html.EscapeString(line))
}
zoneNote := "图中时刻为 UT"
if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 {
zoneNote = fmt.Sprintf("图中时刻为 %s(UT%s)", zone, formatZoneOffset(offset))
}
if english {
zoneNote = "All times are UT"
if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 {
zoneNote = fmt.Sprintf("All times are %s (UT%s)", zone, formatZoneOffset(offset))
}
}
labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle")
fmt.Fprintf(b, `%s`,
view.width/2, view.metaY+4*22, html.EscapeString(zoneNote))
}
func zoneOffsetSeconds(value time.Time) int {
_, offset := value.Zone()
return offset
}
func formatZoneOffset(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
return fmt.Sprintf("%s%02d:%02d", sign, offsetSeconds/3600, (offsetSeconds%3600)/60)
}
// writeLunarEclipseDetailedGeocentricBlocks 在球面两侧画日月地心坐标块。
func writeLunarEclipseDetailedGeocentricBlocks(
b *strings.Builder,
labels *svgchart.LabelTable,
tt float64,
view lunarEclipseDetailedView,
options LunarEclipseDetailedSVGOptions,
) {
english := options.Language == lunarEclipseSVGLanguageEnglish
titles := [2]string{"食甚时的太阳(地心坐标)", "食甚时的月亮(地心坐标)"}
if english {
titles = [2]string{"Sun at greatest eclipse", "Moon at greatest eclipse"}
}
sunRa, sunDec := basic.SunApparentRaDec(tt)
moonRa, moonDec := basic.HMoonTrueRaDec(tt)
sunSd := basic.SunSemidiameter(tt)
moonSd := basic.MoonSemidiameter(tt)
blocks := [2][]svgchart.PanelRow{
{
{Label: "赤经 R.A.", Value: formatSolarEclipseRA(sunRa)},
{Label: "赤纬 Dec.", Value: formatSolarEclipseDec(sunDec)},
{Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(sunSd)},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(sunSd, 6378.137/696000.0))},
},
{
{Label: "赤经 R.A.", Value: formatSolarEclipseRA(moonRa)},
{Label: "赤纬 Dec.", Value: formatSolarEclipseDec(moonDec)},
{Label: "视半径 S.D.", Value: formatSolarEclipseArcsec(moonSd)},
{Label: "地平视差 H.P.", Value: formatSolarEclipseArcsec(horizontalParallaxArcsecRatio(moonSd, 6378.137/1737.4))},
},
}
if english {
for index := range blocks {
blocks[index][3].Label = "H.P."
}
}
blockWidth := math.Min(212, (view.width-2*view.margin-40)/2)
for index := 0; index < 2; index++ {
x := view.margin
if index == 1 {
x = view.width - view.margin - blockWidth
}
fmt.Fprintf(b, ``)
labels.ReserveText(x, view.blockY+11, 12, titles[index], "start")
fmt.Fprintf(b, `%s`,
x, view.blockY+11, html.EscapeString(titles[index]))
for rowIndex, row := range blocks[index] {
rowY := view.blockY + 30 + float64(rowIndex)*17
labels.ReserveText(x, rowY, 10.5, row.Label, "start")
labels.ReserveText(x+blockWidth, rowY, 10.5, row.Value, "end")
fmt.Fprintf(b, `%s`,
x, rowY, html.EscapeString(row.Label))
fmt.Fprintf(b, `%s`,
x+blockWidth, rowY, html.EscapeString(row.Value))
}
b.WriteString(``)
}
}
// horizontalParallaxArcsecRatio 由视半径换算地平视差:sin(HP) = sin(SD) × R⊕ / R天体。
func horizontalParallaxArcsecRatio(semidiameterArcsec, radiusRatio float64) float64 {
sine := math.Sin(semidiameterArcsec / 3600 * math.Pi / 180)
return math.Asin(math.Max(-1, math.Min(1, sine*radiusRatio))) * 180 / math.Pi * 3600
}
// writeLunarEclipseDetailedPanels 写三栏:历时、弧分比例尺、接触时刻。
func writeLunarEclipseDetailedPanels(
b *strings.Builder,
info LunarEclipseInfo,
shadow basic.LunarEclipseShadowGeometry,
diagramScale float64,
view lunarEclipseDetailedView,
options LunarEclipseDetailedSVGOptions,
) {
for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) {
writeLunarEclipseDetailedPanel(b, cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height, cell.title, cell.rows)
}
writeLunarEclipseArcMinuteScaleBar(b, view, shadow, diagramScale)
}
func writeLunarEclipseDetailedPanel(
b *strings.Builder,
x, y, width, height float64,
title string,
rows []svgchart.PanelRow,
) {
svgchart.WritePanelBox(b, "lunar-detailed-panel", x, y, width, height, title, rows)
}
// writeLunarEclipseArcMinuteScaleBar 画穿影示意图的弧分比例尺。
// 示意图以"月球半径"为单位,一个月球半径对应真实角径 asin(1/月距),据此换算弧分。
func writeLunarEclipseArcMinuteScaleBar(
b *strings.Builder,
view lunarEclipseDetailedView,
shadow basic.LunarEclipseShadowGeometry,
diagramScale float64,
) {
if shadow.MoonDistanceEarthRadii <= 0 || diagramScale <= 0 {
return
}
// 示意图以"月球半径"为单位,一个月球半径对应的真实角径是 asin(R月 / 月距),不是地球视差。
moonSemidiameterArcmin := math.Asin((lunarMoonRadiusKM/lunarEarthRadiusKM)/shadow.MoonDistanceEarthRadii) * 180 / math.Pi * 60
if moonSemidiameterArcmin <= 0 {
return
}
pixelsPerArcmin := diagramScale / moonSemidiameterArcmin
target := 150.0
step := math.Pow(10, math.Floor(math.Log10(target/pixelsPerArcmin)))
for _, multiplier := range []float64{1, 2, 5, 10} {
if step*multiplier >= target/pixelsPerArcmin {
step *= multiplier
break
}
}
barWidth := step * pixelsPerArcmin
centreX := view.margin + view.columnWidth + view.columnGap + view.columnWidth/2
y := view.rowY + view.rowHeight/2
fmt.Fprintf(b, ``,
centreX-barWidth/2, y, centreX+barWidth/2, y)
for _, tick := range []float64{0, 0.25, 0.5, 0.75, 1} {
x := centreX - barWidth/2 + tick*barWidth
length := 5.0
if tick == 0 || tick == 1 {
length = 9
}
fmt.Fprintf(b, ``,
x, y-length/2, x, y+length/2)
}
fmt.Fprintf(b, `0`,
centreX-barWidth/2, y-10)
fmt.Fprintf(b, `%.0f`,
centreX+barWidth/2, y-10, step)
fmt.Fprintf(b, `角分`,
centreX, y+22)
b.WriteString(``)
}
// lunarEclipseDetailedFooterText 是页脚说明:默认模型会在极浅半影上回退 Chauvenet,必须写出实际用的那套。
func lunarEclipseDetailedFooterText(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string {
if options.FooterNote != "" {
return options.FooterNote
}
if options.Language == lunarEclipseSVGLanguageEnglish {
return "Shadow-path diagram uses the " + lunarEclipseShadowModelName(model) + " shadow model; the lower map marks where the whole eclipse is visible, where the Moon rises or sets eclipsed, and where it is not visible. Natural Earth 1:50m physical land, no administrative boundaries."
}
return "穿影示意图使用 " + lunarEclipseShadowModelName(model) + " 影半径模型;下方底图区分全程可见、带食月出、带食月落与不可见四类区域。Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
func writeLunarEclipseDetailedFooter(
b *strings.Builder,
view lunarEclipseDetailedView,
options LunarEclipseDetailedSVGOptions,
model eclipsecore.LunarEclipseShadowModel,
) {
// 页脚按页宽折行、按画布底边截断,首行位置不变;默认说明放得下时折行结果仍是同一行。
maxWidth := view.width - 2*view.margin
lines := svgchart.TruncateTextLines(
svgchart.WrapText(lunarEclipseDetailedFooterText(options, model), maxWidth, lunarEclipseDetailedFooterFontSize),
maxWidth, lunarEclipseDetailedFooterFontSize,
svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, 15, view.footerY, view.height-4))
for index, line := range lines {
fmt.Fprintf(b, `%s`,
view.margin, view.footerY+float64(index)*15, lunarEclipseDetailedFooterFontSize, html.EscapeString(line))
}
}
const (
// lunarMoonRadiusKM 与 lunarEarthRadiusKM 只用于把示意图的"月球半径"刻度换算成角分比例尺。
lunarMoonRadiusKM = 1737.4
lunarEarthRadiusKM = 6378.137
)