feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
+348
-85
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user