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, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) b.WriteString(``) b.WriteString(layout.mapFrame().ClipDefinition("occultation-map-clip")) b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, view.width-44, view.height-36) titleText := title if options.Title != "" { titleText = svgchart.EllipsizeText(title, view.width-80, 25) } fmt.Fprintf(&b, `%s`, view.width/2, html.EscapeString(titleText)) writeOccultationDetailedSummary(&b, view, summaryLines) // 几何引擎失败不是数据格式错误,不能裹成 ErrInvalid…Path 哨兵。 if planetPath == nil { if err := writeStarOccultationMap(&b, path, layout, svgOptions); err != nil { return "", fmt.Errorf("stellar occultation detailed band geometry: %w", err) } } else { if err := writePlanetOccultationMap(&b, *planetPath, path, layout, svgOptions); err != nil { return "", fmt.Errorf("planetary occultation detailed band geometry: %w", err) } } blocks := occultationDetailedBlocks(path, star, planetPath, options) if err := validateOccultationDetailedBlocks(view, blocks); err != nil { return "", err } writeOccultationDetailedGrid(&b, view, blocks) writeOccultationDetailedFooter(&b, view, options) b.WriteString(``) 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}, } }