Files
astro/eclipse/svg/transit_local.go
T
b612 044f2f4381 feat: 引入可切换星历模型并扩展天象观测链路
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算
- 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询
- 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出
- 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解
- 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤
- 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持
- 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理
- 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
2026-10-15 23:54:15 +08:00

439 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package svg
import (
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/timenote"
)
const (
transitLocalDefaultWidth = 960
transitLocalDefaultHeight = 680
)
// TransitLocalSVGOptions 控制站心凌日日面图。
// TransitLocalSVGOptions controls a topocentric planetary-transit solar-disk chart.
type TransitLocalSVGOptions struct {
// Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 480 时使用 960x680 默认值。
// Width and Height are SVG canvas dimensions. Width values below 640 and height values below 480 use the 960x680 defaults.
Width int
Height int
// Step 是行星轨迹采样步长;非正值使用两分钟。
// Step is the planet-track sampling step. Non-positive values use two minutes.
Step time.Duration
// Language 为 "en"(不区分大小写)时使用英文,否则使用中文。
// Language uses English for "en" (case-insensitive) and Chinese otherwise.
Language string
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
FooterNote string
}
// TransitLocalSVG 绘制距 date 最近一次水星或金星凌日在观测地(经度 lon、纬度 lat,东经北纬为正,海拔 height 米)
// 的站心日面图:上北左东的天空视向,画出行星中心轨迹、各接触与凌甚时的行星圆面,以及接触时刻表。
// 轨迹上太阳中心在视地平下(计入标准大气折射)的部分画成灰色虚线。该地几何上看不到凌日、找不到凌日或选项无效时返回 false。
// TransitLocalSVG renders the topocentric solar-disk view of the Mercury or Venus transit closest to date, seen from
// longitude lon and latitude lat (east and north positive) at height metres: north up and east left as on the sky, with
// the planet-centre track, the planet disk at each contact and at greatest, and a contact table. Track parts where the
// Sun's centre is below the apparent horizon (standard refraction) are drawn grey and dashed. It returns false when the
// site sees no geometric transit, no transit is found or the options are invalid.
func TransitLocalSVG(planet TransitPlanet, date time.Time, lon, lat, height float64, options TransitLocalSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
local := basic.PlanetTransitLocal(transit, basic.Observer{Longitude: lon, Latitude: lat, Height: height})
if !local.Valid {
return "", false
}
if options.Step <= 0 {
options.Step = 2 * time.Minute
}
frames := basic.PlanetTransitLocalFrames(transit, local, options.Step.Hours()/24)
if len(frames) < 2 {
return "", false
}
options = normalizeTransitLocalSVGOptions(date, options)
return renderTransitLocalSVG(planet, local, frames, options), true
}
func normalizeTransitLocalSVGOptions(date time.Time, options TransitLocalSVGOptions) TransitLocalSVGOptions {
if options.Width < 640 {
options.Width = transitLocalDefaultWidth
}
if options.Height < 480 {
options.Height = transitLocalDefaultHeight
}
if strings.EqualFold(options.Language, "en") {
options.Language = "en"
} else {
options.Language = "zh"
}
if options.Location == nil {
options.Location = date.Location()
}
return options
}
// transitLocalEvent 是日面上标注的一个接触或凌甚。
type transitLocalEvent struct {
label, name string
contact basic.PlanetTransitLocalContact
separation float64
}
func transitLocalEvents(local basic.PlanetTransitLocalResult, language string) []transitLocalEvent {
names := []string{"一触", "二触", "凌甚", "三触", "四触"}
if language == "en" {
names = []string{"C1", "C2", "Greatest", "C3", "C4"}
}
external := local.SunSemidiameterArcsec + local.PlanetSemidiameterArcsec
internal := local.SunSemidiameterArcsec - local.PlanetSemidiameterArcsec
candidates := []transitLocalEvent{
{"C1", names[0], local.ExternalIngress, external},
{"C2", names[1], local.InternalIngress, internal},
{"G", names[2], local.Greatest, local.MinimumSeparationArcsec},
{"C3", names[3], local.InternalEgress, internal},
{"C4", names[4], local.ExternalEgress, external},
}
events := candidates[:0]
for _, event := range candidates {
if event.contact.Valid {
events = append(events, event)
}
}
return events
}
// transitLocalSunUp 判断太阳中心几何高度 altitude 是否在视地平上(标准大气折射)。
func transitLocalSunUp(altitude float64) bool {
return altitude > -basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC)
}
func transitDisplayTime(value time.Time, scale astro.TimeScale, location *time.Location) time.Time {
if value.IsZero() {
return value
}
if scale == astro.TimeScaleUT1 {
return basic.UT1FromUTC(value)
}
return value.In(location)
}
func renderTransitLocalSVG(
planet TransitPlanet,
local basic.PlanetTransitLocalResult,
frames []basic.PlanetTransitLocalFrame,
options TransitLocalSVGOptions,
) string {
width, height := float64(options.Width), float64(options.Height)
at := func(jd float64) time.Time {
return transitDisplayTime(basic.JD2DateByZone(jd, time.UTC, false), options.TimeScale, options.Location)
}
title := options.Title
if title == "" {
date := at(local.Greatest.JD).Format("2006-01-02")
if options.Language == "en" {
title = fmt.Sprintf("%s Transit of %s, Local Circumstances", date, transitMapPlanetName(planet, "en"))
} else {
title = fmt.Sprintf("%s %s凌日站心图", date, transitMapPlanetName(planet, "zh"))
}
}
site := transitLocalSiteText(local.Observer, options.Language)
summary := transitLocalSummaryText(local, options.Language)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs><radialGradient id="transit-sun" cx="40%" cy="34%" r="64%"><stop offset="0%" stop-color="#fff8bb"/><stop offset="58%" stop-color="#ffd55f"/><stop offset="100%" stop-color="#f5a623"/></radialGradient></defs>`)
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"/>`, width-44, height-36)
titleText := title
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, width-52, 24)
}
fmt.Fprintf(&b, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(titleText))
for index, line := range []string{site, summary} {
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
width/2, 72+float64(index)*19, html.EscapeString(line))
}
// 日面区占左侧,接触表占右侧。
panelWidth := 300.0
top, bottom := 112.0, height-86
diskLeft, diskRight := 46.0, width-panelWidth-60
cx, cy := (diskLeft+diskRight)/2, (top+bottom)/2
extent := local.SunSemidiameterArcsec + 2.2*local.PlanetSemidiameterArcsec + 60
scale := math.Min((diskRight-diskLeft)/2, (bottom-top)/2) / extent
mapX := func(x float64) float64 { return cx - x*scale } // 东在左
mapY := func(y float64) float64 { return cy - y*scale }
sunRadius := local.SunSemidiameterArcsec * scale
fmt.Fprintf(&b, `<circle class="transit-sun" cx="%.3f" cy="%.3f" r="%.3f" fill="url(#transit-sun)" stroke="#c78211" stroke-width="1.4"/>`, cx, cy, sunRadius)
writeTransitLocalAxes(&b, cx, cy, sunRadius, options.Language)
writeTransitLocalTrack(&b, frames, mapX, mapY)
events := transitLocalEvents(local, options.Language)
planetRadius := math.Max(local.PlanetSemidiameterArcsec*scale, 2)
table := &svgchart.LabelTable{}
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
fill, stroke := "#1d1d1d", "#000000"
if !transitLocalSunUp(event.contact.SunAltitudeDeg) {
fill, stroke = "#9a9a9a", "#6d6d6d"
}
fmt.Fprintf(&b, `<circle class="transit-planet-%s" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.92" stroke="%s" stroke-width="0.8"/>`,
strings.ToLower(event.label), x, y, planetRadius, fill, stroke)
table.Reserve(x-planetRadius, y-planetRadius, 2*planetRadius, 2*planetRadius)
}
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
// 标签放在远离日心的一侧,避开日面内的轨迹与圆面。
dx, dy := x-cx, y-cy
norm := math.Hypot(dx, dy)
if norm < 1e-6 {
dx, dy, norm = 0, -1, 1
}
text := event.name + " " + at(event.contact.JD).Format("15:04:05")
var placement svgchart.LabelPlacement
placed := false
// 相邻接触相隔仅数分钟时近处位次全被占,逐圈外推并画引线,不得丢标签。
for ring := 0; ring < 6 && !placed; ring++ {
offset := planetRadius + 12 + float64(ring)*16
candidates := []svgchart.LabelPlacement{}
for _, turn := range []float64{0, 0.5, -0.5, 1, -1} {
ux := dx/norm*math.Cos(turn) - dy/norm*math.Sin(turn)
uy := dx/norm*math.Sin(turn) + dy/norm*math.Cos(turn)
anchor := "start"
if ux < -0.3 {
anchor = "end"
} else if ux < 0.3 {
anchor = "middle"
}
candidate := svgchart.LabelPlacement{X: x + ux*offset, Y: y + uy*offset + 4, Anchor: anchor}
// 标签平移进白色图框内、接触表左侧,不能伸出画面。
box := svgchart.LabelTextBox(candidate.X, candidate.Y, 11, text, anchor)
candidate.X += math.Max(0, 30-box.X) - math.Max(0, box.X+box.Width-(width-panelWidth-44))
candidate.Y += math.Max(0, top-8-box.Y) - math.Max(0, box.Y+box.Height-(height-30))
candidates = append(candidates, candidate)
}
placement, placed = table.Place(text, 11, candidates)
if placed && transitLocalLabelGap(placement, text, x, y) > planetRadius+16 {
// 引线连到标签框上离接触点最近的位置。
box := svgchart.LabelTextBox(placement.X, placement.Y, 11, text, placement.Anchor)
endX := math.Max(box.X, math.Min(x, box.X+box.Width))
endY := math.Max(box.Y, math.Min(y, box.Y+box.Height))
fmt.Fprintf(&b, `<line class="transit-event-leader" x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#555" stroke-width="0.7" opacity="0.7"/>`,
x, y, endX, endY)
}
}
if !placed {
continue
}
fmt.Fprintf(&b, `<text class="transit-event-label" x="%.3f" y="%.3f" fill="#222" stroke="#ffffff" stroke-width="2.6" paint-order="stroke" font-family="Arial, sans-serif" font-size="11" text-anchor="%s">%s</text>`,
placement.X, placement.Y, placement.Anchor, html.EscapeString(text))
}
writeTransitLocalContactTable(&b, local, events, at, width-panelWidth-36, top+10, panelWidth, options.Language)
writeTransitLocalFooter(&b, local, options, width, height)
b.WriteString(`</svg>`)
return b.String()
}
// transitLocalLabelGap 是接触点到标签框的最近距离。
func transitLocalLabelGap(placement svgchart.LabelPlacement, text string, x, y float64) float64 {
box := svgchart.LabelTextBox(placement.X, placement.Y, 11, text, placement.Anchor)
return math.Hypot(x-math.Max(box.X, math.Min(x, box.X+box.Width)), y-math.Max(box.Y, math.Min(y, box.Y+box.Height)))
}
func transitLocalSiteText(observer basic.Observer, language string) string {
ew, ns := "E", "N"
if language != "en" {
ew, ns = "东经", "北纬"
}
lon, lat := observer.Longitude, observer.Latitude
if lon < 0 {
ew = map[bool]string{true: "W", false: "西经"}[language == "en"]
lon = -lon
}
if lat < 0 {
ns = map[bool]string{true: "S", false: "南纬"}[language == "en"]
lat = -lat
}
if language == "en" {
return fmt.Sprintf("Site %.4f°%s %.4f°%s, height %.0f m", lon, ew, lat, ns, observer.Height)
}
return fmt.Sprintf("观测地 %s %.4f° %s %.4f°,海拔 %.0f 米", ew, lon, ns, lat, observer.Height)
}
func transitLocalSummaryText(local basic.PlanetTransitLocalResult, language string) string {
kind := map[bool]string{true: "完整凌日", false: "掠凌(无内切接触)"}[local.HasInternal]
visible := map[bool]string{true: "可见", false: "全程太阳在地平下"}[local.Visible]
if language == "en" {
kind = map[bool]string{true: "full transit", false: "grazing transit (no internal contacts)"}[local.HasInternal]
visible = map[bool]string{true: "visible", false: "Sun below the horizon throughout"}[local.Visible]
return fmt.Sprintf("Topocentric %s, %s | minimum separation %.1f″ | Sun %.1f″ | planet %.2f″",
kind, visible, local.MinimumSeparationArcsec, local.SunSemidiameterArcsec, local.PlanetSemidiameterArcsec)
}
return fmt.Sprintf("站心%s,%s | 最小角距 %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″",
kind, visible, local.MinimumSeparationArcsec, local.SunSemidiameterArcsec, local.PlanetSemidiameterArcsec)
}
// writeTransitLocalAxes 画过日心的南北、东西虚线与方向字。
func writeTransitLocalAxes(b *strings.Builder, cx, cy, radius float64, language string) {
reach := radius + 18
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx, cy-reach, cx, cy+reach)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx-reach, cy, cx+reach, cy)
labels := [4]string{"北", "南", "东", "西"}
if language == "en" {
labels = [4]string{"N", "S", "E", "W"}
}
for index, item := range []struct{ x, y float64 }{
{cx, cy - reach - 6}, {cx, cy + reach + 16}, {cx - reach - 12, cy + 5}, {cx + reach + 12, cy + 5},
} {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#6b5222" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
item.x, item.y, labels[index])
}
}
// writeTransitLocalTrack 画行星中心轨迹:太阳在视地平上的段为实线,地平下为灰色虚线。
func writeTransitLocalTrack(b *strings.Builder, frames []basic.PlanetTransitLocalFrame, mapX, mapY func(float64) float64) {
start := 0
for index := 1; index <= len(frames); index++ {
if index < len(frames) && transitLocalSunUp(frames[index].SunAltitudeDeg) == transitLocalSunUp(frames[start].SunAltitudeDeg) {
continue
}
// 段尾取下一段首点,相邻两段首尾相接。
end := index
if end == len(frames) {
end--
}
class, color, dash := "transit-track", "#24518a", "none"
if !transitLocalSunUp(frames[start].SunAltitudeDeg) {
class, color, dash = "transit-track-below-horizon", "#8a8a8a", "5 4"
}
fmt.Fprintf(b, `<path class="%s" d="`, class)
for position := start; position <= end; position++ {
command := "L"
if position == start {
command = "M"
}
fmt.Fprintf(b, `%s %.3f %.3f `, command, mapX(frames[position].PlanetXArcsec), mapY(frames[position].PlanetYArcsec))
}
fmt.Fprintf(b, `" fill="none" stroke="%s" stroke-width="1.4" stroke-dasharray="%s" stroke-linecap="round"/>`, color, dash)
start = index
}
}
// transitLocalEventPosition 取时刻最接近 jd 的采样帧位置;接触与凌甚时刻本身在采样里。
func transitLocalEventPosition(frames []basic.PlanetTransitLocalFrame, jd float64, mapX, mapY func(float64) float64) (float64, float64) {
best := 0
for index := range frames {
if math.Abs(frames[index].JD-jd) < math.Abs(frames[best].JD-jd) {
best = index
}
}
return mapX(frames[best].PlanetXArcsec), mapY(frames[best].PlanetYArcsec)
}
func writeTransitLocalContactTable(
b *strings.Builder,
local basic.PlanetTransitLocalResult,
events []transitLocalEvent,
at func(float64) time.Time,
x, y, width float64,
language string,
) {
heading := [4]string{"接触时刻", "时刻", "太阳高度", "方位"}
if language == "en" {
heading = [4]string{"Contacts", "Time", "Sun alt.", "Az."}
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="700">%s</text>`, x, y, heading[0])
columns := []float64{x, x + 78, x + 176, x + 252}
rowY := y + 26
for index, text := range []string{"", heading[1], heading[2], heading[3]} {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Arial, sans-serif" font-size="11">%s</text>`, columns[index], rowY, text)
}
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#bbb" stroke-width="0.8"/>`, x, rowY+6, x+width-8, rowY+6)
for _, event := range events {
rowY += 22
fill := "#222"
if !transitLocalSunUp(event.contact.SunAltitudeDeg) {
fill = "#8a8a8a"
}
cells := []string{
event.name,
at(event.contact.JD).Format("15:04:05"),
fmt.Sprintf("%+.1f°", event.contact.SunAltitudeDeg),
fmt.Sprintf("%.1f°", event.contact.SunAzimuthDeg),
}
for index, text := range cells {
fmt.Fprintf(b, `<text class="transit-contact-row" x="%.3f" y="%.3f" fill="%s" font-family="Arial, sans-serif" font-size="12">%s</text>`,
columns[index], rowY, fill, html.EscapeString(text))
}
}
rowY += 30
lines := []string{}
duration := time.Duration((local.ExternalEgress.JD - local.ExternalIngress.JD) * 86400 * float64(time.Second)).Round(time.Second)
if language == "en" {
lines = append(lines, "Duration C1–C4 "+duration.String())
} else {
lines = append(lines, "一触至四触历时 "+duration.String())
}
for _, item := range []struct {
jd float64
zh, en string
}{{local.Sunrise, "凌日中日出 ", "Sunrise during transit "}, {local.Sunset, "凌日中日落 ", "Sunset during transit "}} {
if item.jd == 0 {
continue
}
label := item.zh
if language == "en" {
label = item.en
}
lines = append(lines, label+at(item.jd).Format("15:04:05"))
}
for _, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Arial, sans-serif" font-size="12">%s</text>`, x, rowY, html.EscapeString(line))
rowY += 19
}
}
func writeTransitLocalFooter(b *strings.Builder, local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width, height float64) {
note := options.FooterNote
if note == "" {
if options.Language == "en" {
note = "Sky view, north up and east left. Topocentric contacts; Sun altitudes are geometric centre altitudes, grey parts have the Sun's centre below the apparent horizon (standard refraction)."
} else {
note = "天空视向,上北左东;站心接触时刻,太阳高度为日心几何高度,灰色部分太阳中心在视地平下(计入标准大气折射)。"
}
}
scaleNote := timenote.Scale(options.TimeScale, basic.JD2DateByZone(local.Greatest.JD, time.UTC, false), options.Location, options.Language)
lines := svgchart.FooterLinesWithScale(svgchart.WrapText(note, width-80, 12), scaleNote, width-80, 12, 3)
baselines, _ := svgchart.FooterBlock(len(lines), height, 12, svgchart.FooterLineHeight(12), svgchart.FooterBottomPadding(12)+18)
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
baselines[index], html.EscapeString(line))
}
}