package svg
import (
"errors"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro/basic"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
// ErrInvalidOccultationDetailedSVGOptions 表示详细版式画布装不下地图、数据块与页脚。
// ErrInvalidOccultationDetailedSVGOptions reports a detailed-layout canvas that cannot hold the map, data blocks, and footer.
var ErrInvalidOccultationDetailedSVGOptions = errors.New("invalid occultation detailed SVG options")
const (
occultationDetailedMinimumWidth = 480
occultationDetailedMinimumHeight = 320
// occultationDetailedMinimumMap 与 occultationDetailedMinimumCell 是球面与数据格仍可辨认的最小尺寸。
occultationDetailedMinimumMap = 120
occultationDetailedMinimumCell = 120
)
// OccultationDetailedSVGOptions 控制详细版式的月掩星组合图。
type OccultationDetailedSVGOptions struct {
// Width 与 Height 是画布尺寸;<=0 使用 1000x1414 的整页开本。
// 版式必须容得下地图与数据块,否则返回 ErrInvalidOccultationDetailedSVGOptions。
Width int
Height int
// Language 为 "en" 时使用英文,其他值使用中文。
Language string
// Location 控制显示时刻的时区;nil 使用 UTC+8。
Location *time.Location
// TimeLabelStep 控制中心线上的时刻标签;零值用 30 分钟,负值禁用。
TimeLabelStep time.Duration
// Title 与 FooterNote 为空时自动生成。
Title string
FooterNote string
}
// occultationDetailedView 汇总详细版式各区块的位置。
type occultationDetailedView struct {
width, height float64
margin float64
mapX, mapY float64
mapSize float64
gridX, gridY float64
cellWidth float64
cellHeight float64
gapX, gapY float64
footerY float64
landscape bool
}
func normalizeOccultationDetailedSVGOptions(options OccultationDetailedSVGOptions) OccultationDetailedSVGOptions {
if options.Width <= 0 {
options.Width = 1000
}
if options.Height <= 0 {
options.Height = 1414
}
if options.Location == nil {
options.Location = time.FixedZone("UTC+8", starOccultationSVGDefaultZone)
}
if strings.EqualFold(options.Language, starOccultationSVGLanguageEnglish) {
options.Language = starOccultationSVGLanguageEnglish
} else {
options.Language = starOccultationSVGLanguageChinese
}
if options.TimeLabelStep == 0 {
options.TimeLabelStep = 30 * time.Minute
}
return options
}
func validateOccultationDetailedSVGOptions(options OccultationDetailedSVGOptions) error {
if options.Width > 0 && options.Width < occultationDetailedMinimumWidth {
return fmt.Errorf("%w: width must be zero or at least %d",
ErrInvalidOccultationDetailedSVGOptions, occultationDetailedMinimumWidth)
}
if options.Height > 0 && options.Height < occultationDetailedMinimumHeight {
return fmt.Errorf("%w: height must be zero or at least %d",
ErrInvalidOccultationDetailedSVGOptions, occultationDetailedMinimumHeight)
}
return nil
}
func occultationDetailedMargin(width float64) float64 {
return math.Max(30, math.Min(48, width*0.044))
}
func occultationDetailedViewFor(options OccultationDetailedSVGOptions, summaryLines int) (occultationDetailedView, error) {
width, height := float64(options.Width), float64(options.Height)
margin := occultationDetailedMargin(width)
// 摘要折行数随画布宽度变化,标题区必须跟着长,否则窄画布上摘压到地图或数据块。
headerBottom := math.Max(150, math.Max(74+float64(summaryLines)*20+16, height*0.115))
footerHeight := math.Max(56, height*0.05)
legendHeight := math.Max(42, height*0.04)
gap := math.Max(14, height*0.014)
view := occultationDetailedView{
width: width, height: height, margin: margin,
gapX: math.Max(14, width*0.014), gapY: math.Max(12, height*0.013),
}
legendY := height - footerHeight - legendHeight
view.footerY = height - 42
blockHeight := math.Max(74, height*0.055)
panelHeight := math.Max(96, height*0.10)
if width >= height {
// 横版:地图在左,数据块两栏三行在右。
view.landscape = true
gridWidth := 2*230 + view.gapX
view.gridX = width - margin - gridWidth
view.cellWidth = 230
bandTop := headerBottom + gap
bandBottom := legendY - gap
view.gridY = bandTop
view.cellHeight = (bandBottom - bandTop - 2*view.gapY) / 3
view.mapX = margin
view.mapY = bandTop
view.mapSize = math.Min(view.gridX-gap-margin, bandBottom-bandTop)
if view.mapSize < occultationDetailedMinimumMap || view.cellHeight <= 0 || view.cellWidth <= 0 {
return view, detailedCanvasError(options)
}
return view, nil
}
// 竖版:日月块在地图上方两侧,地图居中,两行三栏面板在地图下方。
view.mapY = headerBottom + blockHeight + gap
// 球面下方先留图例的位置,再排两行面板;地图边长取剩余高度,不足则拒绝而不是按宽度放大。
const legendSpace = 56.0
view.mapSize = math.Min(width-2*margin,
height-view.mapY-legendSpace-2*panelHeight-3*gap-legendHeight-footerHeight)
view.cellWidth = (width - 2*margin - 2*view.gapX) / 3
if view.mapSize < occultationDetailedMinimumMap || view.cellWidth < occultationDetailedMinimumCell {
return view, detailedCanvasError(options)
}
view.mapX = (width - view.mapSize) / 2
panelY := view.mapY + view.mapSize + legendSpace
view.gridX = margin
view.gridY = panelY
view.cellHeight = panelHeight
return view, nil
}
func detailedCanvasError(options OccultationDetailedSVGOptions) error {
return fmt.Errorf("%w: canvas %dx%d cannot hold the map, data blocks, and footer",
ErrInvalidOccultationDetailedSVGOptions, options.Width, options.Height)
}
func (view occultationDetailedView) mapFrame(projection svgmap.Projection, center svgmap.GeoPoint) svgmap.Frame {
return svgmap.Frame{
X: view.mapX, Y: view.mapY, Width: view.mapSize, Height: view.mapSize,
Projection: projection, CenterLongitude: center.Longitude, CenterLatitude: center.Latitude,
}
}
// validateOccultationDetailedBlocks 逐块检查等分行高下文字是否压叠;太矮的画布必须拒绝而不是画出压叠的面板。
func validateOccultationDetailedBlocks(view occultationDetailedView, blocks []svgchart.PanelBlock) error {
columns := 3
if view.landscape {
columns = 2
}
for index, block := range blocks {
if len(block.Rows) == 0 {
continue
}
column, row := index%columns, index/columns
x := view.gridX + float64(column)*(view.cellWidth+view.gapX)
y := view.gridY + float64(row)*(view.cellHeight+view.gapY)
if svgchart.PanelBoxOverlaps(x, y, view.cellWidth, view.cellHeight, block.Title, block.Rows) {
return fmt.Errorf("%w: canvas %gx%g cannot keep data block %q rows apart",
ErrInvalidOccultationDetailedSVGOptions, view.width, view.height, block.Title)
}
}
return nil
}
// writeOccultationDetailedGrid 按两栏三行(横版)或三栏两行(竖版)画数据块。
func writeOccultationDetailedGrid(b *strings.Builder, view occultationDetailedView, blocks []svgchart.PanelBlock) {
columns := 3
if view.landscape {
columns = 2
}
for index, block := range blocks {
if len(block.Rows) == 0 {
continue
}
column := index % columns
row := index / columns
x := view.gridX + float64(column)*(view.cellWidth+view.gapX)
y := view.gridY + float64(row)*(view.cellHeight+view.gapY)
svgchart.WritePanelBox(b, "occultation-detailed-panel", x, y, view.cellWidth, view.cellHeight, block.Title, block.Rows)
}
}
// occultationDetailedMoonRows 月亮的地心坐标;S.D./H.P. 由地心月距换算,与日食月食同口径。
func occultationDetailedMoonRows(at time.Time, language string) []svgchart.PanelRow {
jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true)
ra, dec := basic.HMoonTrueRaDec(jde)
sd := basic.MoonSemidiameter(jde)
hp := math.Asin(math.Sin(sd/3600*math.Pi/180)*6378.137/1737.4) * 180 / math.Pi * 3600
rows := []svgchart.PanelRow{
{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)},
{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)},
{Label: "视半径 S.D.", Value: occultationDetailedArcsec(sd)},
{Label: "地平视差 H.P.", Value: occultationDetailedArcsec(hp)},
}
if language == starOccultationSVGLanguageEnglish {
rows[0].Label, rows[1].Label = "R.A.", "Dec."
rows[2].Label, rows[3].Label = "S.D.", "H.P."
}
return rows
}
func occultationDetailedRA(degrees float64) string {
total := math.Mod(degrees, 360) / 15
hours := math.Floor(total)
minutes := math.Floor((total - hours) * 60)
return fmt.Sprintf("%02.0fh%02.0fm%04.1fs", hours, minutes, ((total-hours)*60-minutes)*60)
}
func occultationDetailedDec(degrees float64) string {
sign := "+"
if degrees < 0 {
sign, degrees = "-", -degrees
}
whole := math.Floor(degrees)
minutes := math.Floor((degrees - whole) * 60)
return fmt.Sprintf("%s%02.0f°%02.0f'%04.1f\"", sign, whole, minutes, ((degrees-whole)*60-minutes)*60)
}
func occultationDetailedArcsec(arcsec float64) string {
whole := math.Floor(arcsec / 3600)
minutes := math.Floor((arcsec/3600 - whole) * 60)
return fmt.Sprintf("%02.0f°%02.0f'%04.1f\"", whole, minutes, ((arcsec/3600-whole)*60-minutes)*60)
}
// StarOccultationDetailedSVG 生成详细版式的恒星月掩星图。
// StarOccultationDetailedSVG renders a detailed stellar-occultation diagram.
func StarOccultationDetailedSVG(
path moon.StarOccultationPath,
star moon.StarCoordinate,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validateStarOccultationPath(path); err != nil {
return "", err
}
return renderOccultationDetailedSVG(path, star, nil, options)
}
// PlanetOccultationDetailedSVG 生成详细版式的行星月掩星图。
// PlanetOccultationDetailedSVG renders a detailed planetary-occultation diagram.
func PlanetOccultationDetailedSVG(
path moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validatePlanetOccultationPath(path); err != nil {
return "", err
}
// 中文标题与目标块要写"木星"这类中文行星名,不能直接用 TargetID 里的英文。
shape := planetOccultationStarShape(path, path.TargetID)
if options.Language == starOccultationSVGLanguageChinese {
shape.TargetID = planetOccultationChineseName(path.Planet)
}
return renderOccultationDetailedSVG(shape, moon.StarCoordinate{}, &path, options)
}
func renderOccultationDetailedSVG(
path moon.StarOccultationPath,
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) (string, error) {
if err := validateOccultationDetailedSVGOptions(options); err != nil {
return "", err
}
options = normalizeOccultationDetailedSVGOptions(options)
summaryLines := occultationDetailedSummaryLines(path, planetPath, options)
view, err := occultationDetailedViewFor(options, len(summaryLines))
if err != nil {
return "", err
}
center := svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}
layout := starOccultationSVGLayout{
width: view.width, height: view.height, margin: view.margin,
mapX: view.mapX, mapY: view.mapY, mapWidth: view.mapSize, mapHeight: view.mapSize,
panelX: view.gridX, panelY: view.gridY, panelWidth: view.cellWidth,
footerY: view.footerY, projection: svgmap.ProjectionOrthographic, center: center,
}
svgOptions := StarOccultationSVGOptions{
Width: options.Width, Height: options.Height, Language: options.Language,
Location: options.Location, TimeLabelStep: options.TimeLabelStep,
Projection: MapProjectionOrthographic, Title: options.Title,
}
title := starOccultationSVGTitle(path, svgOptions)
var b strings.Builder
fmt.Fprintf(&b, ``)
return b.String(), nil
}
// occultationDetailedSummaryLines 汇总标题下的摘要行,并按画布可用宽度折行。
func occultationDetailedSummaryLines(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) []string {
available := float64(options.Width) - 2*occultationDetailedMargin(float64(options.Width))
lines := []string{}
for _, line := range occultationDetailedSummaryText(path, planetPath, options) {
lines = append(lines, starOccultationWrapText(line, available, 12.5)...)
}
return lines
}
func occultationDetailedSummaryText(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) []string {
zone := starOccultationLocationLabel(path.Greatest.Time.In(options.Location), options.Location)
english := options.Language == starOccultationSVGLanguageEnglish
lines := []string{}
format := func(value time.Time) string {
return value.In(options.Location).Format("15:04:05")
}
name := func(zh, en string) string {
if english {
return en
}
return zh
}
if planetPath != nil && planetPath.HasTotalBand {
if english {
lines = append(lines, fmt.Sprintf("Partial begins %s | Total begins %s | Greatest %s | Total ends %s | Partial ends %s (%s)",
format(path.Start.Time), format(planetPath.TotalStart.Time), format(path.Greatest.Time),
format(planetPath.TotalEnd.Time), format(path.End.Time), zone))
} else {
lines = append(lines, fmt.Sprintf("外掩始 %s | 全掩始 %s | 掩甚 %s | 全掩终 %s | 外掩终 %s(%s)",
format(path.Start.Time), format(planetPath.TotalStart.Time), format(path.Greatest.Time),
format(planetPath.TotalEnd.Time), format(path.End.Time), zone))
}
} else if english {
lines = append(lines, fmt.Sprintf("Occultation begins %s | Greatest %s | Occultation ends %s (%s)",
format(path.Start.Time), format(path.Greatest.Time), format(path.End.Time), zone))
} else {
lines = append(lines, fmt.Sprintf("掩始 %s | 掩甚 %s | 掩终 %s(%s)",
format(path.Start.Time), format(path.Greatest.Time), format(path.End.Time), zone))
}
lines = append(lines, fmt.Sprintf("%s %s | %s", name("掩甚点", "Greatest point"),
starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude),
name("月球高度", "Moon altitude")+" "+starOccultationFormatSignedDegree(path.Greatest.MoonAltitude)))
return lines
}
func writeOccultationDetailedSummary(
b *strings.Builder,
view occultationDetailedView,
lines []string,
) {
for index, line := range lines {
fmt.Fprintf(b, `%s`,
view.width/2, 74+float64(index)*20, html.EscapeString(line))
}
}
func writeOccultationDetailedFooter(b *strings.Builder, view occultationDetailedView, options OccultationDetailedSVGOptions) {
text := options.FooterNote
if text == "" {
if options.Language == starOccultationSVGLanguageEnglish {
text = "Orthographic globe centred on the greatest occultation; Natural Earth 1:50m physical land, no administrative boundaries."
} else {
text = "正射球面投影,视点取掩甚点;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
maxWidth := view.width - 2*view.margin
// 默认说明本来就是单行,只有调用方文本才折行截断。
lines := []string{text}
if options.FooterNote != "" {
lines = svgchart.TruncateTextLines(svgchart.WrapText(text, maxWidth, 11), maxWidth, 11,
svgchart.BaselineLineLimit(11, 15, view.footerY, view.height))
}
for index, line := range lines {
fmt.Fprintf(b, `%s`,
view.margin, view.footerY+float64(index)*15, html.EscapeString(line))
}
}
// occultationDetailedTargetRows 目标天体的坐标与视半径;恒星没有视圆面,相关行留空。
func occultationDetailedTargetRows(
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
at time.Time,
language string,
) []svgchart.PanelRow {
english := language == starOccultationSVGLanguageEnglish
jde := basic.TD2UT(basic.Date2JDE(at.UTC()), true)
rows := []svgchart.PanelRow{{Label: "目标", Value: star.ID}}
if english {
rows[0].Label = "Target"
}
if planetPath != nil {
rows[0].Value = planetPath.TargetID
if language == starOccultationSVGLanguageChinese {
rows[0].Value = planetOccultationChineseName(planetPath.Planet)
}
ra, dec := occultationDetailedPlanetRaDec(planetPath.Planet, jde)
if math.IsNaN(ra) {
rows = append(rows, svgchart.PanelRow{Label: "赤经 R.A.", Value: "—"},
svgchart.PanelRow{Label: "赤纬 Dec.", Value: "—"})
} else {
rows = append(rows, svgchart.PanelRow{Label: "赤经 R.A.", Value: occultationDetailedRA(ra)},
svgchart.PanelRow{Label: "赤纬 Dec.", Value: occultationDetailedDec(dec)})
}
rows = append(rows, svgchart.PanelRow{Label: "视半径 S.D.",
Value: occultationDetailedArcsec(occultationDetailedPlanetSemidiameter(planetPath.Planet, jde))})
if english {
rows[1].Label, rows[2].Label, rows[3].Label = "R.A.", "Dec.", "S.D."
}
return rows
}
rows = append(rows,
svgchart.PanelRow{Label: "赤经 R.A.", Value: occultationDetailedRA(star.RA)},
svgchart.PanelRow{Label: "赤纬 Dec.", Value: occultationDetailedDec(star.Dec)},
svgchart.PanelRow{Label: "视半径 S.D.", Value: "—"})
if english {
rows[1].Label, rows[2].Label, rows[3].Label = "R.A.", "Dec.", "S.D."
}
return rows
}
// occultationDetailedPlanetRaDec 空值表示该行星没有可用的赤经入口。
func occultationDetailedPlanetRaDec(planet moon.OccultationPlanet, jde float64) (float64, float64) {
switch planet {
case moon.OccultationMercury:
return basic.MercuryApparentRaDec(jde)
case moon.OccultationVenus:
return basic.VenusApparentRaDec(jde)
case moon.OccultationMars:
return basic.MarsApparentRaDec(jde)
case moon.OccultationJupiter:
return basic.JupiterApparentRaDec(jde)
case moon.OccultationSaturn:
return basic.SaturnApparentRaDec(jde)
case moon.OccultationUranus:
return basic.UranusApparentRaDec(jde)
case moon.OccultationNeptune:
return basic.NeptuneApparentRaDec(jde)
}
return math.NaN(), math.NaN()
}
func occultationDetailedPlanetSemidiameter(planet moon.OccultationPlanet, jde float64) float64 {
switch planet {
case moon.OccultationMercury:
return basic.MercurySemidiameter(jde)
case moon.OccultationVenus:
return basic.VenusSemidiameter(jde)
case moon.OccultationMars:
return basic.MarsSemidiameter(jde)
case moon.OccultationJupiter:
return basic.JupiterSemidiameter(jde)
case moon.OccultationSaturn:
return basic.SaturnSemidiameter(jde)
case moon.OccultationUranus:
return basic.UranusSemidiameter(jde)
case moon.OccultationNeptune:
return basic.NeptuneSemidiameter(jde)
}
return math.NaN()
}
func occultationDetailedBlocks(
path moon.StarOccultationPath,
star moon.StarCoordinate,
planetPath *moon.PlanetOccultationPath,
options OccultationDetailedSVGOptions,
) []svgchart.PanelBlock {
english := options.Language == starOccultationSVGLanguageEnglish
name := func(zh, en string) string {
if english {
return en
}
return zh
}
format := func(value time.Time) string {
if value.IsZero() {
return "—"
}
return value.In(options.Location).Format("15:04:05")
}
point := func(label string, value moon.OccultationPathPoint) svgchart.PanelRow {
if value.Time.IsZero() {
return svgchart.PanelRow{}
}
return svgchart.PanelRow{Label: label, Value: format(value.Time),
Detail: starOccultationFormatCoordinates(value.Longitude, value.Latitude)}
}
pathRows := []svgchart.PanelRow{
point(name("外掩始", "Partial begins"), path.Start),
point(name("掩甚", "Greatest"), path.Greatest),
point(name("外掩终", "Partial ends"), path.End),
}
// 行星图传入的 path 是由行星路径合成的恒星壳,带宽口径要从原始行星路径取。
widthSource := path
if planetPath != nil {
widthSource = moon.StarOccultationPath{Greatest: planetPath.Greatest,
GreatestLimitSeparationKM: planetPath.GreatestLimitSeparationKM}
}
partialWidth := widthSource.GreatestLimitSeparationKM
if partialWidth <= 0 {
partialWidth = widthSource.Greatest.WidthKM
}
ephemeris := []svgchart.PanelRow{
{Label: name("投影", "Projection"), Value: name("正射球面", "Orthographic")},
{Label: "ΔT", Value: fmt.Sprintf("%.1f s", basic.DeltaT(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true), true))},
{Label: name("月距", "Moon distance"), Value: fmt.Sprintf("%.0f km", basic.HMoonAway(basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)))},
{Label: name("部分掩带宽", "Partial-band width"), Value: fmt.Sprintf("%.1f km", partialWidth)},
}
if planetPath != nil && planetPath.HasTotalBand {
ephemeris = append(ephemeris, svgchart.PanelRow{Label: name("全掩带宽", "Total-band width"),
Value: fmt.Sprintf("%.1f km", planetPath.GreatestTotalWidthKM)})
}
jde := basic.TD2UT(basic.Date2JDE(path.Greatest.Time.UTC()), true)
physical := basic.MoonPhysical(jde)
libration := []svgchart.PanelRow{
{Label: name("经天平动 l", "Libration l"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLongitude)},
{Label: name("纬天平动 b", "Libration b"), Value: fmt.Sprintf("%+.2f°", physical.LibrationLatitude)},
{Label: name("自转轴位置角 c", "Axis position angle c"), Value: fmt.Sprintf("%.2f°", physical.PositionAngle)},
}
contacts := []svgchart.PanelRow{
{Label: name("外掩始", "Partial begins"), Value: format(path.Start.Time)},
}
if planetPath != nil && planetPath.HasTotalBand {
contacts = append(contacts,
svgchart.PanelRow{Label: name("全掩始", "Total begins"), Value: format(planetPath.TotalStart.Time)})
}
contacts = append(contacts, svgchart.PanelRow{Label: name("掩甚", "Greatest"), Value: format(path.Greatest.Time)})
if planetPath != nil && planetPath.HasTotalBand {
contacts = append(contacts,
svgchart.PanelRow{Label: name("全掩终", "Total ends"), Value: format(planetPath.TotalEnd.Time)})
}
contacts = append(contacts, svgchart.PanelRow{Label: name("外掩终", "Partial ends"), Value: format(path.End.Time)})
return []svgchart.PanelBlock{
{Title: name("月亮(地心坐标)", "Moon (geocentric)"), Rows: occultationDetailedMoonRows(path.Greatest.Time, options.Language)},
{Title: name("目标天体", "Target body"), Rows: occultationDetailedTargetRows(star, planetPath, path.Greatest.Time, options.Language)},
{Title: name("掩带路径点", "Band path points"), Rows: pathRows},
{Title: name("接触时刻", "Contacts"), Rows: contacts},
{Title: name("历表与常数", "Ephemeris and constants"), Rows: ephemeris},
{Title: name("天平动", "Libration"), Rows: libration},
}
}