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, ``)
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, ``, cx, cy-reach, cx, cy+reach)
fmt.Fprintf(b, ``, 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, `%s`,
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, ``, 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, `%s`, 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, `%s`, columns[index], rowY, text)
}
fmt.Fprintf(b, ``, 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, `%s`,
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, `%s`, 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, `%s`,
baselines[index], html.EscapeString(line))
}
}