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, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, width-44, height-36) titleText := title if options.Title != "" { titleText = svgchart.EllipsizeText(title, width-52, 24) } fmt.Fprintf(&b, `%s`, width/2, html.EscapeString(titleText)) for index, line := range []string{site, summary} { fmt.Fprintf(&b, `%s`, 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, ``, 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, ``, 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, ``, x, y, endX, endY) } } if !placed { continue } fmt.Fprintf(&b, `%s`, 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(``) 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)) } }