package svg import ( "fmt" "html" "math" "strings" "time" "b612.me/astro" "b612.me/astro/basic" "b612.me/astro/internal/geodata" "b612.me/astro/internal/svgchart" "b612.me/astro/internal/svgmap" "b612.me/astro/internal/timenote" ) const ( transitMapDefaultWidth = 960 transitMapDefaultHeight = 640 ) // TransitPlanet 选择凌日行星 / selects the transiting planet. type TransitPlanet int const ( // TransitMercury 是水星凌日 / Mercury transit. TransitMercury TransitPlanet = 1 // TransitVenus 是金星凌日 / Venus transit. TransitVenus TransitPlanet = 2 ) // TransitIsochrones 是等时线开关的位组合 / bit set of contact isochrones. type TransitIsochrones uint8 const ( // TransitIsochroneC1 画一触等时线 / draw C1 isochrones. TransitIsochroneC1 TransitIsochrones = 1 << iota // TransitIsochroneC2 画二触等时线;掠凌没有内切接触时忽略 / draw C2 isochrones, ignored for a grazing transit. TransitIsochroneC2 // TransitIsochroneC3 画三触等时线;掠凌没有内切接触时忽略 / draw C3 isochrones, ignored for a grazing transit. TransitIsochroneC3 // TransitIsochroneC4 画四触等时线 / draw C4 isochrones. TransitIsochroneC4 // TransitIsochroneExternal 是一触与四触 / C1 and C4. TransitIsochroneExternal = TransitIsochroneC1 | TransitIsochroneC4 // TransitIsochroneAll 是全部四个接触 / all four contacts. TransitIsochroneAll = TransitIsochroneC1 | TransitIsochroneC2 | TransitIsochroneC3 | TransitIsochroneC4 ) // TransitMapSVGOptions 控制全球凌日可见图。 // TransitMapSVGOptions controls a global planetary-transit visibility map. type TransitMapSVGOptions struct { // Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。 // Width and Height are SVG canvas dimensions. Width values below 640 and height values below 420 use the 960x640 defaults. Width int Height int // 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 // Projection 选择地图投影;零值使用等经纬投影,正射投影视点取凌甚时的太阳直射点。 // Projection selects the map projection. The zero value uses equirectangular; orthographic centres on the subsolar point at greatest. Projection EclipseMapProjection // Isochrones 选择要画的站心接触等时线,可按位组合;零值不画(默认关闭,需显式开启)。 // Isochrones selects the topocentric contact isochrones to draw and may combine flags. The zero value draws none (off by default). Isochrones TransitIsochrones // IsochroneStep 是等时线的时间间隔;非正值按行星自动取(水星 20 秒、金星 1 分钟)。 // IsochroneStep is the isochrone spacing. Non-positive values select 20 s for Mercury and 1 min for Venus. IsochroneStep time.Duration // 空文本字段使用本地化的自动标签。 // Empty text fields use localized automatic labels. Title string FooterNote string } // TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:按一触、凌甚、四触太阳中心在视地平上 // (计入标准大气折射)的区域分区;Options.Isochrones 显式开启时叠加所选接触的站心等时线。找不到凌日或选项无效时返回 false。 // TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: regions follow where the // Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4. Topocentric isochrones of the // selected contacts are overlaid only when Options.Isochrones enables them. It returns false when no transit is found // or the options are invalid. func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOptions) (string, bool) { transit, ok := closestPlanetTransit(planet, date) if !ok || !validEclipseMapProjection(options.Projection) { return "", false } if options.TimeScale == astro.TimeScaleUT1 { if options.Location != nil && options.Location != time.UTC { return "", false } options.Location = time.UTC } options = normalizeTransitMapSVGOptions(date, options) if !transit.HasInternal { options.Isochrones &^= TransitIsochroneC2 | TransitIsochroneC3 } options.Isochrones &= TransitIsochroneAll if options.IsochroneStep <= 0 { options.IsochroneStep = time.Minute if planet == TransitMercury { options.IsochroneStep = 20 * time.Second } } projection := internalEclipseMapProjection(options.Projection) if projection == "" { projection = svgmap.ProjectionEquirectangular } return renderTransitMapSVG(planet, transit, options, projection), true } // closestPlanetTransit 返回距 date 最近的一次地心凌日;行星无效或找不到时第二个返回值为 false。 func closestPlanetTransit(planet TransitPlanet, date time.Time) (basic.PlanetTransitResult, bool) { jd := basic.Date2JD(date.UTC()) var transit basic.PlanetTransitResult switch planet { case TransitMercury: transit = basic.ClosestMercuryTransit(jd) case TransitVenus: transit = basic.ClosestVenusTransit(jd) default: return transit, false } return transit, transit.Valid } func normalizeTransitMapSVGOptions(date time.Time, options TransitMapSVGOptions) TransitMapSVGOptions { if options.Width < 640 { options.Width = transitMapDefaultWidth } if options.Height < 420 { options.Height = transitMapDefaultHeight } if strings.EqualFold(options.Language, "en") { options.Language = "en" } else { options.Language = "zh" } if options.Location == nil { options.Location = date.Location() } return options } // transitMapContacts 是地心一触、凌甚、四触的 UTC 时刻。 type transitMapContacts struct { start, greatest, end time.Time internalStart, internalEnd time.Time hasInternal bool } func transitMapContactsOf(transit basic.PlanetTransitResult) transitMapContacts { at := func(jd float64) time.Time { return basic.JD2DateByZone(jd, time.UTC, false) } contacts := transitMapContacts{ start: at(transit.ExternalIngress), greatest: at(transit.Greatest), end: at(transit.ExternalEgress), hasInternal: transit.HasInternal, } if transit.HasInternal { contacts.internalStart, contacts.internalEnd = at(transit.InternalIngress), at(transit.InternalEgress) } return contacts } func renderTransitMapSVG( planet TransitPlanet, transit basic.PlanetTransitResult, options TransitMapSVGOptions, projection svgmap.Projection, ) string { contacts := transitMapContactsOf(transit) // 底部比月食图多留两行:图例下要放可折行的说明与时标声明。 frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 122) if projection == svgmap.ProjectionOrthographic { center := solarEclipseSubsolarPoint(contacts.greatest) frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude } title := options.Title if title == "" { date := transitMapDisplayTime(contacts.greatest, options).Format("2006-01-02") if options.Language == "en" { title = fmt.Sprintf("%s Transit of %s Global Visibility", date, transitMapPlanetName(planet, "en")) } else { title = fmt.Sprintf("%s %s凌日全球可见图", date, transitMapPlanetName(planet, "zh")) } } var b strings.Builder fmt.Fprintf(&b, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) b.WriteString(``) writeTransitMapDefinitions(&b, frame, contacts) b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, options.Width-44, options.Height-36) titleText := title if options.Title != "" { titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24) } fmt.Fprintf(&b, `%s`, float64(options.Width)/2, html.EscapeString(titleText)) writeTransitMapSummary(&b, transit, contacts, options) frame.WriteOcean(&b) frame.WriteGraticule(&b, "transit-map-clip") frame.WriteLand(&b, "transit-map-clip") writeTransitMapRegions(&b, frame, contacts) if options.Isochrones != 0 { writeTransitMapIsochrones(&b, frame, transit, options) } frame.WriteFrame(&b) legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options) writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows) b.WriteString(``) return b.String() } func transitMapPlanetName(planet TransitPlanet, language string) string { if language == "en" { if planet == TransitMercury { return "Mercury" } return "Venus" } if planet == TransitMercury { return "水星" } return "金星" } // transitMapDisplayTime 把 UTC 时刻换成图上显示的时刻:UT1 时标给 UT1 读数,否则换到 Location。 func transitMapDisplayTime(value time.Time, options TransitMapSVGOptions) time.Time { return transitDisplayTime(value, options.TimeScale, options.Location) } // transitMapSunUpPath 返回 value 时刻太阳中心在视地平上的区域面路径(evenodd)与地平边界。 // 视地平计入标准大气折射:太阳中心距直射点小于 90°+34′ 即可见,太阳视差 8.8″ 忽略。 // 半径超过 90° 的球冠环在拆分日界线时分不清内外,所以改画对日点为心、半径不足 90° 的不可见冠, // 再用图框减去它得到可见区。 func transitMapSunUpPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) { subsolar := solarEclipseSubsolarPoint(value) antisolar := svgmap.GeoPoint{Longitude: geodata.NormalizeLongitude(subsolar.Longitude + 180), Latitude: -subsolar.Latitude} radius := 90 - basic.RefractionFromApparentAltitude(0, transitMapRefractionPressureHPa, transitMapRefractionTemperatureC) boundary := geodata.SphericalCircle(antisolar, radius, 360) closed := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0]) path := fmt.Sprintf("M %.3f %.3f H %.3f V %.3f H %.3f Z %s", frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height, frame.X, lunarEclipseGeoPolygonPath(boundary, frame)) return path, closed } // 标准大气,与 basic 站心凌日的视地平判定一致。 const ( transitMapRefractionPressureHPa = 1010.0 transitMapRefractionTemperatureC = 10.0 ) // writeTransitMapDefinitions 输出一触、凌甚、四触三个太阳在地平上半球的形状与排除掩膜,分区口径同月食可见图。 func writeTransitMapDefinitions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) { b.WriteString(frame.ClipDefinition("transit-map-clip")) startPath, _ := transitMapSunUpPath(contacts.start, frame) endPath, _ := transitMapSunUpPath(contacts.end, frame) greatestPath, _ := transitMapSunUpPath(contacts.greatest, frame) fmt.Fprintf(b, ``, startPath) fmt.Fprintf(b, ``, endPath) fmt.Fprintf(b, ``, greatestPath) b.WriteString(``) b.WriteString(``) writeLunarEclipseVisibilityMask(b, "transit-not-visible-mask", frame, "transit-sun-c1-shape", "transit-sun-c4-shape", "transit-sun-greatest-shape") writeLunarEclipseVisibilityMask(b, "transit-not-c1-c4-mask", frame, "transit-sun-c1-shape", "transit-sun-c4-shape") writeLunarEclipseVisibilityMask(b, "transit-not-greatest-mask", frame, "transit-sun-greatest-shape") writeLunarEclipseVisibilityMask(b, "transit-not-c4-mask", frame, "transit-sun-c4-shape") writeLunarEclipseVisibilityMask(b, "transit-not-c1-mask", frame, "transit-sun-c1-shape") } const ( transitMapColorEntire = "#5e846d" transitMapColorSunrise = "#4e9da0" transitMapColorSunset = "#e2aa4b" transitMapColorNone = "#747b7d" transitMapColorC1 = "#a3392f" transitMapColorC2 = "#c0702a" transitMapColorC3 = "#7a4ca0" transitMapColorC4 = "#2f5ea3" ) // writeTransitMapRegions 着色四类可见区:全程可见、日出时凌日已开始、日落时凌日未结束、不可见。 // 只有凌甚前后太阳在地平上的极区透镜与两端可见、凌甚不可见的高纬带都归入日落带,同月食可见图。 func writeTransitMapRegions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) { _, startBoundary := transitMapSunUpPath(contacts.start, frame) _, endBoundary := transitMapSunUpPath(contacts.end, frame) fmt.Fprintf(b, ``, eclipseMapSourceTransitVisibilityRegions) if frame.IsPolar() { fmt.Fprintf(b, ``, frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone) } else { fmt.Fprintf(b, ``, frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone) } fmt.Fprintf(b, ``, transitMapColorSunset) fmt.Fprintf(b, ``, transitMapColorSunrise) fmt.Fprintf(b, ``, transitMapColorEntire) fmt.Fprintf(b, ``, transitMapColorSunset) fmt.Fprintf(b, ``, transitMapColorSunset) b.WriteString(``) writeEclipseMapGeoLine(b, frame, startBoundary, "c1-horizon", "#a56c16", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries) writeEclipseMapGeoLine(b, frame, endBoundary, "c4-horizon", "#197a82", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries) } // writeTransitMapIsochrones 画选中接触的站心等时线:每条线是同一时刻看到该接触的地点,只保留该时刻太阳在视地平上的部分。 // 每隔若干条标一次时刻,标签按 LabelTable 避让。 func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) { table := &svgchart.LabelTable{} format := "15:04" if options.IsochroneStep%time.Minute != 0 { format = "15:04:05" } for _, contact := range []struct { kind basic.PlanetTransitContactKind class string color string flag TransitIsochrones }{ {basic.PlanetTransitC1, "c1-isochrone", transitMapColorC1, TransitIsochroneC1}, {basic.PlanetTransitC2, "c2-isochrone", transitMapColorC2, TransitIsochroneC2}, {basic.PlanetTransitC3, "c3-isochrone", transitMapColorC3, TransitIsochroneC3}, {basic.PlanetTransitC4, "c4-isochrone", transitMapColorC4, TransitIsochroneC4}, } { if options.Isochrones&contact.flag == 0 { continue } lines := basic.PlanetTransitContactIsochrones(transit, contact.kind, options.IsochroneStep) labelEvery := transitMapIsochroneLabelEvery(len(lines)) for index, line := range lines { segments := make([][]svgmap.GeoPoint, 0, len(line.Segments)) for _, segment := range line.Segments { points := make([]svgmap.GeoPoint, len(segment)) for position, point := range segment { points[position] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } segments = append(segments, points) writeEclipseMapGeoLine(b, frame, points, contact.class, contact.color, 0.9, "none", "transit-map-clip", eclipseMapSourceTransitContactIsochrones) } if index%labelEvery != 0 { continue } text := transitMapDisplayTime(basic.JD2DateByZone(line.JD, time.UTC, false), options).Format(format) placement, ok := table.Place(text, 10, transitMapIsochroneLabelCandidates(segments, frame)) if !ok { continue } fmt.Fprintf(b, `%s`, contact.class, placement.X, placement.Y, contact.color, placement.Anchor, html.EscapeString(text)) } } } // transitMapIsochroneLabelEvery 让每个接触最多标八条线。 func transitMapIsochroneLabelEvery(count int) int { every := int(math.Ceil(float64(count) / 8)) if every < 1 { every = 1 } return every } // transitMapIsochroneLabelCandidates 从最长支路的中点向两端取画布内的候选位置。 func transitMapIsochroneLabelCandidates(segments [][]svgmap.GeoPoint, frame svgmap.Frame) []svgchart.LabelPlacement { longest := -1 for index, segment := range segments { if longest < 0 || len(segment) > len(segments[longest]) { longest = index } } if longest < 0 { return nil } segment := segments[longest] middle := len(segment) / 2 candidates := make([]svgchart.LabelPlacement, 0, 32) for offset := 0; offset <= middle && len(candidates) < 32; offset++ { for _, index := range []int{middle + offset, middle - offset} { if index < 0 || index >= len(segment) || (offset == 0 && index != middle) { continue } x, y, visible := frame.Project(segment[index].Longitude, segment[index].Latitude) if !visible || x < frame.X+24 || x > frame.X+frame.Width-24 || y < frame.Y+14 || y > frame.Y+frame.Height-6 { continue } candidates = append(candidates, svgchart.LabelPlacement{X: x, Y: y - 4, Anchor: "middle"}) } } return candidates } func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResult, contacts transitMapContacts, options TransitMapSVGOptions) { clock := func(value time.Time) string { return transitMapDisplayTime(value, options).Format("15:04:05") } zone, _ := transitMapDisplayTime(contacts.greatest, options).Zone() if zone == "" { zone = "UTC" } if options.TimeScale == astro.TimeScaleUT1 { zone = "UT1" } greatest, separation := "凌甚", "最小角距" if options.Language == "en" { greatest, separation = "Greatest", "least separation" } parts := []string{"C1 " + clock(contacts.start)} if contacts.hasInternal { parts = append(parts, "C2 "+clock(contacts.internalStart)) } parts = append(parts, greatest+" "+clock(contacts.greatest)) if contacts.hasInternal { parts = append(parts, "C3 "+clock(contacts.internalEnd)) } parts = append(parts, "C4 "+clock(contacts.end)) line := fmt.Sprintf("%s (%s)", strings.Join(parts, " | "), zone) fmt.Fprintf(b, `%s`, float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(line, float64(options.Width)-60, 13))) note := fmt.Sprintf("%s %.1f″ | 太阳视半径 %.1f″ | 行星视半径 %.2f″(地心)", separation, transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec) if options.Language == "en" { note = fmt.Sprintf("%s %.1f″ | solar semidiameter %.1f″ | planet semidiameter %.2f″ (geocentric)", separation, transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec) } fmt.Fprintf(b, `%s`, float64(options.Width)/2, html.EscapeString(note)) } // writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。 func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions) int { type entry struct { text, color string line bool } labels := []string{"全程可见", "日出时已在凌日", "日落时仍在凌日", "不可见", "一触等时线", "二触等时线", "三触等时线", "四触等时线"} if options.Language == "en" { labels = []string{"Entire transit", "Transit at sunrise", "Transit at sunset", "Not visible", "C1 isochrones", "C2 isochrones", "C3 isochrones", "C4 isochrones"} } entries := []entry{ {labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false}, {labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false}, } for index, item := range []struct { flag TransitIsochrones color string }{ {TransitIsochroneC1, transitMapColorC1}, {TransitIsochroneC2, transitMapColorC2}, {TransitIsochroneC3, transitMapColorC3}, {TransitIsochroneC4, transitMapColorC4}, } { if options.Isochrones&item.flag != 0 { entries = append(entries, entry{labels[4+index], item.color, true}) } } width := func(item entry) float64 { return lunarEclipseDetailedMapLegendIconWidth + svgchart.EstimatedTextWidth(item.text, lunarEclipseDetailedMapLegendFontSize) } available := canvasWidth - 2*lunarEclipseDetailedPageInset var rows [][]entry used := 0.0 for _, item := range entries { if len(rows) == 0 || used+lunarEclipseDetailedMapLegendGap+width(item) > available { rows = append(rows, nil) used = -lunarEclipseDetailedMapLegendGap } rows[len(rows)-1] = append(rows[len(rows)-1], item) used += lunarEclipseDetailedMapLegendGap + width(item) } baseY := frame.Y + frame.Height + 31 for rowIndex, row := range rows { total := -lunarEclipseDetailedMapLegendGap for _, item := range row { total += lunarEclipseDetailedMapLegendGap + width(item) } x := math.Max(lunarEclipseDetailedPageInset, frame.X+frame.Width/2-total/2) y := baseY + float64(rowIndex)*lunarEclipseDetailedMapLegendLineStep for _, item := range row { if item.line { fmt.Fprintf(b, ``, x, y-4, x+18, y-4, item.color) } else { fmt.Fprintf(b, ``, x, y-8, item.color) } fmt.Fprintf(b, `%s`, x+lunarEclipseDetailedMapLegendIconWidth, y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(item.text)) x += width(item) + lunarEclipseDetailedMapLegendGap } } return len(rows) } func writeTransitMapFooter( b *strings.Builder, frame svgmap.Frame, options TransitMapSVGOptions, projection svgmap.Projection, instant time.Time, legendRows int, ) { text := options.FooterNote if text == "" { if options.Language == "en" { text = eclipseMapProjectionLabel(projection, "en") + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4" if options.Isochrones != 0 { text += "; isochrones are topocentric contact times" } text += "." } else { text = eclipseMapProjectionLabel(projection, "zh") + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)" if options.Isochrones != 0 { text += ";等时线为站心接触时刻" } text += "。" } } const footerBottomPadding = 26.0 // 页脚紧跟图例末行,不与图例重叠;行数按画布底边截断,时标声明独占最后一行。 baseline := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 18 maxWidth := float64(options.Width) - frame.X - 24 lines := svgchart.WrapText(text, maxWidth, 11) lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language), maxWidth, 11, svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline, float64(options.Height)-footerBottomPadding)) for index, line := range lines { fmt.Fprintf(b, `%s`, frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line)) } }