feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+348 -85
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
@@ -17,8 +19,8 @@ import (
const (
starOccultationSVGDefaultWidth = 1200
starOccultationSVGDefaultHeight = 800
starOccultationSVGMinimumWidth = 480
starOccultationSVGMinimumHeight = 360
starOccultationSVGMinimumWidth = 640
starOccultationSVGMinimumHeight = 480
starOccultationSVGDefaultZone = 8 * 60 * 60
starOccultationSVGLanguageChinese = "zh"
@@ -32,8 +34,9 @@ var ErrInvalidStarOccultationSVGOptions = errors.New("invalid stellar occultatio
// StarOccultationSVGOptions 控制恒星月掩全球掩带 SVG 输出。
// StarOccultationSVGOptions controls a global stellar-occultation path SVG.
type StarOccultationSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值。
// Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default.
// Width 和 Height 是 SVG 画布尺寸;非正值使用 1200x800 全球地图默认值,宽度小于 640 或高度小于 480 时返回 ErrInvalidStarOccultationSVGOptions。
// Width and Height are the SVG canvas dimensions. Values <= 0 use the 1200x800 global-map default;
// a width below 640 or a height below 480 returns ErrInvalidStarOccultationSVGOptions.
Width int
Height int
// Title 及后续文本字段覆盖自动生成的标签。
@@ -96,7 +99,12 @@ func StarOccultationPathSVG(
return "", err
}
options = normalizeStarOccultationSVGOptions(options)
return renderStarOccultationPathSVG(path, options), nil
diagram, err := renderStarOccultationPathSVG(path, options)
if err != nil {
// 几何引擎失败不是数据格式错误,不能裹成 ErrInvalidStarOccultationPath 哨兵。
return "", fmt.Errorf("stellar occultation SVG band geometry: %w", err)
}
return diagram, nil
}
func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error {
@@ -107,7 +115,7 @@ func validateStarOccultationSVGOptions(options StarOccultationSVGOptions) error
return fmt.Errorf("%w: height must be zero or at least %d", ErrInvalidStarOccultationSVGOptions, starOccultationSVGMinimumHeight)
}
switch options.Projection {
case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar:
case MapProjectionAuto, MapProjectionEquirectangular, MapProjectionNorthPolar, MapProjectionSouthPolar, MapProjectionOrthographic:
default:
return fmt.Errorf("%w: unsupported map projection %q", ErrInvalidStarOccultationSVGOptions, options.Projection)
}
@@ -139,21 +147,53 @@ func normalizeStarOccultationSVGOptions(options StarOccultationSVGOptions) StarO
return options
}
func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
return renderOccultationPathSVG(path, nil, options)
func renderStarOccultationPathSVG(path moon.StarOccultationPath, options StarOccultationSVGOptions) (string, error) {
return renderOccultationPathSVG(path, nil, options, starOccultationSVGTextFlags(options))
}
// occultationSVGTextFlags 记录哪些文本字段由调用方显式给出:只有这类文本走保守折行与截断,
// 自动文案必须逐字节保持原样。
type occultationSVGTextFlags struct {
title bool
summaryText bool
greatestText bool
footerNote bool
}
func starOccultationSVGTextFlags(options StarOccultationSVGOptions) occultationSVGTextFlags {
return occultationSVGTextFlags{
title: options.Title != "",
summaryText: options.SummaryText != "",
greatestText: options.GreatestText != "",
footerNote: options.FooterNote != "",
}
}
func renderOccultationPathSVG(
path moon.StarOccultationPath,
planetPath *moon.PlanetOccultationPath,
options StarOccultationSVGOptions,
) string {
flags occultationSVGTextFlags,
) (string, error) {
projection := resolveStarOccultationMapProjection(path, options.Projection)
options.Projection = MapProjection(projection)
title := starOccultationSVGTitle(path, options)
headerLines := starOccultationSVGHeaderLines(path, options)
headerLines := starOccultationSVGHeaderLines(path, options, flags)
// 页眉字号 14/13,省略号按较大的 14 估算。
headerLines = svgchart.TruncateTextLines(headerLines, float64(options.Width)-80, 14,
starOccultationSVGHeaderLineLimit(options, projection))
headerBottom := 72.0 + float64(len(headerLines))*19
layout := starOccultationSVGLayoutFor(options, headerBottom, projection)
// 只有正射球面需要视点:不给就会退回 (0°,0°) 而完全不对准事件,与日食球面图不一致。
// 等经纬与极区图不受影响,避免改动既有版面。
center := svgmap.GeoPoint{}
if projection == svgmap.ProjectionOrthographic {
if planetPath != nil {
center = planetOccultationOrthographicCentre(*planetPath)
} else {
center = svgmap.GeoPoint{Longitude: path.Greatest.Longitude, Latitude: path.Greatest.Latitude}
}
}
layout := starOccultationSVGLayoutFor(options, headerBottom, projection, center)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMid meet" style="max-width:100%%;height:auto;display:block" role="img" aria-label="%s">`,
@@ -164,8 +204,13 @@ func renderOccultationPathSVG(
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
layout.width-44, layout.height-36)
titleSize := starOccultationTitleFontSize(title, layout.width)
titleText := title
if flags.title {
titleText = svgchart.EllipsizeText(title, layout.width-80, float64(titleSize))
}
fmt.Fprintf(&b, `<text x="%.3f" y="44" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="%d" font-weight="700" text-anchor="middle">%s</text>`,
layout.width/2, starOccultationTitleFontSize(title, layout.width), html.EscapeString(title))
layout.width/2, titleSize, html.EscapeString(titleText))
fmt.Fprintf(&b, `<line x1="%.3f" y1="57" x2="%.3f" y2="57" stroke="#555" stroke-width="1"/>`, layout.width/2-78, layout.width/2+78)
for index, line := range headerLines {
fontSize := 13
@@ -178,18 +223,24 @@ func renderOccultationPathSVG(
layout.width/2, 82+float64(index)*19, fill, fontSize, html.EscapeString(line))
}
mapTitle := occultationTitleText(starOccultationSVGMapTitle(options), options.MapTitle != "",
layout.width-layout.mapX-layout.margin, 14)
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.mapX, layout.mapY-10, html.EscapeString(starOccultationSVGMapTitle(options)))
layout.mapX, layout.mapY-10, html.EscapeString(mapTitle))
if planetPath == nil {
writeStarOccultationMap(&b, path, layout, options)
if err := writeStarOccultationMap(&b, path, layout, options); err != nil {
return "", err
}
writeStarOccultationEventsPanel(&b, path, layout, options)
} else {
writePlanetOccultationMap(&b, *planetPath, path, layout, options)
if err := writePlanetOccultationMap(&b, *planetPath, path, layout, options); err != nil {
return "", err
}
writePlanetOccultationEventsPanel(&b, *planetPath, layout, options)
}
writeStarOccultationFooter(&b, layout, options)
writeStarOccultationFooter(&b, layout, options, flags)
b.WriteString(`</svg>`)
return b.String()
return b.String(), nil
}
func writeStarOccultationMap(
@@ -197,22 +248,69 @@ func writeStarOccultationMap(
path moon.StarOccultationPath,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
) {
) error {
layout.mapFrame().WriteOcean(b)
writeStarOccultationGraticule(b, layout)
writeStarOccultationLand(b, layout)
writeStarOccultationBand(b, path, layout)
writeStarOccultationGeoLine(b, path.NorthernLimit, layout, "northern-limit", "#a66f18", 1.35, "")
writeStarOccultationGeoLine(b, path.SouthernLimit, layout, "southern-limit", "#a66f18", 1.35, "")
writeStarOccultationGeoLine(b, path.CenterLine, layout, "center-line", "#59676b", 1.6, "5 4")
band := occultationBandDraw{}
footprints := path.BandFootprints
compact := len(footprints) > 0
if len(footprints) == 0 {
footprints = path.Footprints
}
if len(footprints) > 0 {
draw, err := (occultationFootprintSweep{
footprints: footprints,
contours: path.BandContours,
visibilityContours: path.VisibilityContours,
northern: path.NorthernLimit,
southern: path.SouthernLimit,
curves: path.RiseSetCurves,
layout: layout,
layerClass: "occultation-band-layer",
pathClass: "occultation-band",
color: "#e0ae43",
opacity: 0.28,
compactBand: compact,
kind: starOccultationFootprintSweep,
}).write(b)
if err != nil {
return err
}
band = draw
} else {
writeStarOccultationBand(b, path, layout)
}
if !band.compact {
writeStarOccultationGeoLine(b, path.NorthernLimit, layout,
occultationLineStyle{className: "northern-limit", color: "#a66f18", strokeWidth: 1.35, boundary: true})
writeStarOccultationGeoLine(b, path.SouthernLimit, layout,
occultationLineStyle{className: "southern-limit", color: "#a66f18", strokeWidth: 1.35, boundary: true})
}
// Draw phase curves after the translucent band so the physical rise/set
// lines remain visible across the filled area. These are open phase curves,
// not an artificial outline of the static band.
writeOccultationRiseSetCurves(b, path.RiseSetCurves, layout, "occultation")
writeOccultationHorizonConnectors(
b, footprints, path.NorthernLimit, path.SouthernLimit, path.RiseSetCurves, layout,
true,
)
// 事件标记先占位、后绘制:三族标签共用一张占位表,后一族必须绕开先放置的标签。
placer := &occultationLabelPlacer{}
eventMarkers := starOccultationEventMarkerDraws(path, layout, options.Language, placer)
writeOccultationGreatestTimeContours(b, path.GreatestTimeContours, layout, options, placer)
writeStarOccultationGeoLine(b, path.CenterLine, layout,
occultationLineStyle{className: "center-line", color: "#59676b", strokeWidth: 1.6, dash: "5 4"})
writeStarOccultationVisibleCenterLine(b, path.CenterLine, layout)
writeOccultationTimeMarkers(b, path.CenterLine, layout, options,
[]time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time)
writeStarOccultationEventMarker(b, path.Start, layout, starOccultationEventLabel("start", options.Language), "start")
writeStarOccultationEventMarker(b, path.Greatest, layout, starOccultationEventLabel("greatest", options.Language), "greatest")
writeStarOccultationEventMarker(b, path.End, layout, starOccultationEventLabel("end", options.Language), "end")
[]time.Time{path.Start.Time, path.End.Time}, path.Greatest.Time, placer)
for _, marker := range eventMarkers {
marker.write(b)
}
layout.mapFrame().WriteFrame(b)
writeStarOccultationLegend(b, layout, options.Language)
writeStarOccultationLegend(b, layout, options.Language, band, len(path.RiseSetCurves) > 0,
len(path.GreatestTimeContours) > 0)
return nil
}
func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVGLayout) {
@@ -227,8 +325,14 @@ func writeStarOccultationGraticule(b *strings.Builder, layout starOccultationSVG
}
for _, latitude := range []float64{-60, -30, 0, 30, 60} {
_, y := layout.project(0, latitude)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="end">%.0f°</text>`,
layout.mapX-5, y+3, latitude)
text := fmt.Sprintf("%.0f°", latitude)
// 图框左侧放不下刻度时改放框内左侧,避免负 x 越出画布。
x, anchor := layout.mapX-5, "end"
if x-occultationLabelTextWidth(text, 9) < 3 {
x, anchor = layout.mapX+4, "start"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#687577" font-family="Arial, sans-serif" font-size="9" text-anchor="%s">%s</text>`,
x, y+3, anchor, text)
}
}
@@ -236,6 +340,25 @@ func writeStarOccultationLand(b *strings.Builder, layout starOccultationSVGLayou
layout.mapFrame().WriteLand(b, "occultation-map-clip")
}
// writeOccultationRiseSetCurves 绘制一组升落阶段线;prefix 区分外接触与内接触曲线。
func writeOccultationRiseSetCurves(
b *strings.Builder,
curves []moon.OccultationRiseSetCurve,
layout starOccultationSVGLayout,
prefix string,
) {
curves = occultationgeo.DensifyRiseSetCurves(curves, 35)
for _, curve := range curves {
className := fmt.Sprintf("%s-rise-set-boundary %s-%s-%s", prefix, prefix, curve.Phase, curve.Direction)
for _, segment := range occultationgeo.StitchedRiseSetCurveSegments(curve) {
writeStarOccultationGeoLine(
b, segment, layout,
occultationLineStyle{className: className, color: "#d97706", strokeWidth: 1.35},
)
}
}
}
func writeStarOccultationBand(b *strings.Builder, path moon.StarOccultationPath, layout starOccultationSVGLayout) {
writeOccultationBand(b, path.NorthernLimit, path.SouthernLimit, layout,
"occultation-band-layer", "occultation-band", "#e0ae43", 0.28)
@@ -248,51 +371,61 @@ func writeOccultationBand(
layerClass, pathClass, color string,
opacity float64,
) {
segments := starOccultationBandFragments(northern, southern, layout.projection)
if len(segments) == 0 {
segments := starOccultationBandFragments(northern, southern, layout.mapFrame().Clip())
sections := starOccultationBandEndpointSections(northern, southern, layout.mapFrame().Clip())
if len(segments) == 0 && len(sections) == 0 {
return
}
fmt.Fprintf(b, `<g class="%s" clip-path="url(#occultation-map-clip)" fill="%s" fill-opacity="%.2f" stroke="none">`,
layerClass, color, opacity)
for _, segment := range segments {
fmt.Fprintf(b, `<path class="%s" d="`, pathClass)
for index, point := range segment {
x, y := layout.project(point.longitude, point.latitude)
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, x, y)
}
writeOccultationPathCommands(b, layout.projectOccultationRing(segment))
b.WriteString(`Z"/>`)
}
for _, section := range sections {
fmt.Fprintf(b, `<path class="%s" d="`, pathClass)
writeOccultationPathCommands(b, layout.projectOccultationPolyline(section))
fmt.Fprintf(b, `" fill="none" stroke="%s" stroke-opacity="%.2f" stroke-width="1.25" stroke-linecap="round"/>`,
color, math.Min(1, opacity+0.35))
}
b.WriteString(`</g>`)
}
// occultationLineStyle 描述掩带折线的画布样式。
type occultationLineStyle struct {
className string
color string
strokeWidth float64
dash string
// boundary 为 true 时按连续边界分段(掩带限线),否则按反经线分段。
boundary bool
}
func writeStarOccultationGeoLine(
b *strings.Builder,
points []moon.OccultationPathPoint,
layout starOccultationSVGLayout,
className, color string,
strokeWidth float64,
dash string,
style occultationLineStyle,
) {
for _, segment := range starOccultationPathSegmentsForProjection(points, layout.projection) {
segments := starOccultationPathSegmentsForProjection(points, layout.mapFrame().Clip())
if style.boundary {
segments = starOccultationBoundarySegmentsForProjection(points, layout.mapFrame().Clip())
}
for _, segment := range segments {
if len(segment) < 2 {
continue
}
fmt.Fprintf(b, `<path class="%s" d="`, className)
for index, point := range segment {
x, y := layout.project(point.Longitude, point.Latitude)
command := "L"
if index == 0 {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, x, y)
screen := layout.projectOccultationPathLine(segment)
if len(screen) < 2 {
continue
}
fmt.Fprintf(b, `" clip-path="url(#occultation-map-clip)" fill="none" stroke="%s" stroke-width="%.2f"`, color, strokeWidth)
if dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, dash)
fmt.Fprintf(b, `<path class="%s" d="`, style.className)
writeOccultationPathCommands(b, screen)
fmt.Fprintf(b, `" clip-path="url(#occultation-map-clip)" fill="none" stroke="%s" stroke-width="%.2f"`,
style.color, style.strokeWidth)
if style.dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, style.dash)
}
b.WriteString(` stroke-linecap="round" stroke-linejoin="round"/>`)
}
@@ -305,7 +438,8 @@ func writeStarOccultationVisibleCenterLine(
) {
visible := make([]moon.OccultationPathPoint, 0, len(points))
flush := func() {
writeStarOccultationGeoLine(b, visible, layout, "visible-center-line", "#087f8c", 2.8, "")
writeStarOccultationGeoLine(b, visible, layout,
occultationLineStyle{className: "visible-center-line", color: "#087f8c", strokeWidth: 2.8})
visible = visible[:0]
}
for _, point := range points {
@@ -321,18 +455,44 @@ func writeStarOccultationVisibleCenterLine(
flush()
}
func writeStarOccultationEventMarker(
b *strings.Builder,
point moon.OccultationPathPoint,
type starOccultationEventMarkerDraw struct {
x, y float64
label, kind string
placement starOccultationEventMarkerPlacement
}
func (draw starOccultationEventMarkerDraw) write(b *strings.Builder) {
writeStarOccultationProjectedEventMarker(b, draw.x, draw.y, draw.label, draw.kind, draw.placement)
}
// starOccultationEventMarkerDraws 计算起点、掩甚、终点三个事件标记及其标签占位。
func starOccultationEventMarkerDraws(
path moon.StarOccultationPath,
layout starOccultationSVGLayout,
label, kind string,
) {
x, y, visible := layout.mapFrame().Project(point.Longitude, point.Latitude)
if !visible {
return
language string,
placer *occultationLabelPlacer,
) []starOccultationEventMarkerDraw {
markers := []struct {
point moon.OccultationPathPoint
label, kind string
}{
{point: path.Start, label: starOccultationEventLabel("start", language), kind: "start"},
{point: path.Greatest, label: starOccultationEventLabel("greatest", language), kind: "greatest"},
{point: path.End, label: starOccultationEventLabel("end", language), kind: "end"},
}
placement := starOccultationDefaultEventMarkerPlacement(x, y, kind, layout)
writeStarOccultationProjectedEventMarker(b, x, y, label, kind, placement)
draws := make([]starOccultationEventMarkerDraw, 0, len(markers))
for _, marker := range markers {
x, y, visible := layout.mapFrame().Project(marker.point.Longitude, marker.point.Latitude)
if !visible {
continue
}
draws = append(draws, starOccultationEventMarkerDraw{
x: x, y: y, label: marker.label, kind: marker.kind,
placement: starOccultationDefaultEventMarkerPlacement(x, y, marker.kind, layout),
})
}
reserveOccultationEventMarkerLabels(draws, layout, placer)
return draws
}
type starOccultationEventMarkerPlacement struct {
@@ -341,6 +501,69 @@ type starOccultationEventMarkerPlacement struct {
leader bool
}
// occultationEventMarkerShifts 是事件标签在默认位置之外的候选位移,按优先级排列。
var occultationEventMarkerShifts = [][2]float64{
{0, 0}, {0, -14}, {0, 16}, {-12, 0}, {12, 0},
{-12, -14}, {12, -14}, {-12, 16}, {12, 16},
{0, -28}, {0, 30}, {-24, 0}, {24, 0}, {0, 44}, {0, -42},
}
// reserveOccultationEventMarkerLabels 把事件标签逐个登记到共用占位表。候选位置依次为默认位置、
// 上下左右位移与左右镜像;全部被占用时退回框内居中的兜底位置,避免标签压到右侧事件面板上。
func reserveOccultationEventMarkerLabels(draws []starOccultationEventMarkerDraw, layout starOccultationSVGLayout, placer *occultationLabelPlacer) {
for index := range draws {
draw := &draws[index]
base := draw.placement
found := false
for _, mirror := range []bool{false, true} {
for _, shift := range occultationEventMarkerShifts {
if mirror && base.anchor == "middle" {
continue
}
candidate := base
if mirror {
candidate.anchor = "start"
candidate.labelX = draw.x + 7 + shift[0]
if base.anchor == "start" {
candidate.anchor, candidate.labelX = "end", draw.x-7+shift[0]
}
} else {
candidate.labelX = base.labelX + shift[0]
}
candidate.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, base.labelY+shift[1]))
if !occultationLabelInsideFrame(layout, candidate.labelX, candidate.labelY, candidate.anchor, draw.label, 11) {
continue
}
if placer.place(candidate.labelX, candidate.labelY, candidate.anchor, draw.label, 11, 2) {
draw.placement = candidate
found = true
break
}
}
if found {
break
}
}
if found {
continue
}
fallback := base
fallback.anchor = "middle"
width := occultationLabelTextWidth(draw.label, 11)
fallback.labelX = math.Max(layout.mapX+width/2+2, math.Min(layout.mapX+layout.mapWidth-width/2-2, draw.x))
fallback.labelY = math.Max(layout.mapY+12, math.Min(layout.mapY+layout.mapHeight-5, base.labelY))
draw.placement = fallback
placer.reserve(occultationLabelRectFor(fallback.labelX, fallback.labelY, fallback.anchor, draw.label, 11).expanded(2))
}
}
// occultationLabelInsideFrame 报告标签占位是否完全落在地图框内。
func occultationLabelInsideFrame(layout starOccultationSVGLayout, x, y float64, anchor, text string, fontSize float64) bool {
rect := occultationLabelRectFor(x, y, anchor, text, fontSize)
return rect.left >= layout.mapX+2 && rect.right <= layout.mapX+layout.mapWidth-2 &&
rect.top >= layout.mapY+2 && rect.bottom <= layout.mapY+layout.mapHeight-2
}
func starOccultationDefaultEventMarkerPlacement(
x, y float64,
kind string,
@@ -402,31 +625,66 @@ func writeStarOccultationProjectedEventMarker(
placement.labelX, placement.labelY, placement.anchor, html.EscapeString(label))
}
func writeStarOccultationLegend(b *strings.Builder, layout starOccultationSVGLayout, language string) {
func writeStarOccultationLegend(
b *strings.Builder,
layout starOccultationSVGLayout,
language string,
band occultationBandDraw,
hasRiseSet bool,
hasIsochrones bool,
) {
y := layout.mapY + layout.mapHeight + 30
labels := []string{"可见中心线", "几何中心线", "掩带边界"}
if language == starOccultationSVGLanguageEnglish {
labels = []string{"Visible center line", "Geometric center line", "Occultation limits"}
}
available := layout.mapWidth / 3
styles := []struct {
color string
dash string
}{
{"#087f8c", ""},
{"#59676b", "5 4"},
{"#a66f18", ""},
}
for index, label := range labels {
x := layout.mapX + float64(index)*available
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="2"`, x, y-4, x+22, y-4, styles[index].color)
if styles[index].dash != "" {
fmt.Fprintf(b, ` stroke-dasharray="%s"`, styles[index].dash)
if band.drawn() {
labels[2] = "掩带范围"
if language == starOccultationSVGLanguageEnglish {
labels[2] = "Band extent"
}
if band.claimBoundary() {
labels[2] = "掩带范围与边界"
if language == starOccultationSVGLanguageEnglish {
labels[2] = "Band and limits"
}
}
b.WriteString(`/>`)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="10">%s</text>`,
x+28, y, html.EscapeString(label))
}
if hasRiseSet {
label := "初掩/掩甚/终掩月升月落线"
if language == starOccultationSVGLanguageEnglish {
label = "Rise/set phase lines"
}
labels = append(labels, label)
}
if hasIsochrones {
label := "掩甚时刻等时线"
if language == starOccultationSVGLanguageEnglish {
label = "Greatest-time isochrones"
}
labels = append(labels, label)
}
colors := []string{"#087f8c", "#59676b", "#a66f18"}
dashes := []string{"", "5 4", ""}
if hasRiseSet {
colors = append(colors, "#d97706")
dashes = append(dashes, "")
}
if hasIsochrones {
colors = append(colors, "#1f6fb2")
dashes = append(dashes, "")
}
items := make([]occultationLegendItem, 0, len(labels))
for index, label := range labels {
item := occultationLegendItem{
label: label, color: colors[index], dash: dashes[index], markerWidth: 22,
}
if band.drawn() && index == 2 {
item.fill, item.fillOpacity = "#e0ae43", 0.45
}
items = append(items, item)
}
writeOccultationLegendItems(b, y, layout, items, 10)
}
func writeStarOccultationEventsPanel(
@@ -446,8 +704,10 @@ func writeOccultationEventsPanel(
) {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#d0d3d1" stroke-width="1"/>`,
layout.panelX-10, layout.panelY, layout.panelX-10, layout.panelY+layout.mapHeight)
contactsTitle := occultationTitleText(starOccultationSVGContactsTitle(options), options.ContactsTitle != "",
layout.width-layout.panelX-layout.margin, 14)
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#161a1b" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="700">%s</text>`,
layout.panelX, layout.panelY+13, html.EscapeString(starOccultationSVGContactsTitle(options)))
layout.panelX, layout.panelY+13, html.EscapeString(contactsTitle))
rowTop := layout.panelY + 27
rowHeight := math.Max(34, (layout.mapHeight-27)/float64(len(rows)))
for index, row := range rows {
@@ -478,8 +738,11 @@ func writeStarOccultationFooter(
b *strings.Builder,
layout starOccultationSVGLayout,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
) {
lines := starOccultationWrapText(starOccultationSVGFooter(options), layout.width-80, 11)
lines := occultationWrappedTextLines(starOccultationSVGFooter(options), flags.footerNote, layout.width-80, 11)
lines = svgchart.TruncateTextLines(lines, layout.width-80, 11,
svgchart.BaselineLineLimit(11, 15, layout.footerY, layout.height))
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
layout.footerY+float64(index)*15, html.EscapeString(line))