feat: 完善水星与金星全球凌日搜索及制图导出

- 基于平均下合周期与节点相位预筛候选,减少无效星历计算;跳过无法求解的单个会合周期,避免有限覆盖星历导致搜索提前终止
- 引入影轴 γ、半影半径及地球尺度几何判定,结合站心可见性识别地心无接触、仅部分地区可见的掠凌
- 添加金星 243 年、水星 46 年周期族编号,并明确编号适用范围及未分配时的零值语义
- 为 SVG、GeoJSON 和 KML 增加站心部分凌日可见区,适配接触等时线、几何元数据、图层样式及取景;为地心无接触凌日事件增加正射图,按可见区球面面积加权居中
This commit is contained in:
b612 committed 2026-10-20 22:35:39 +08:00
1 parent 72460fa228
commit ccf5880269
41 files changed
+1554 -307

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
"central-path-limits", "penumbral-outlines", "central-shadow-outlines",
"p1-p4-visibility-regions", "p1-p4-horizon-boundaries",
"c1-c4-visibility-regions", "c1-c4-horizon-boundaries", "transit-contact-isochrones",
"topocentric-transit-visibility",
}
if len(documented) != len(eclipseMapDataSources) {
t.Fatalf("vocabulary has %d entries, want %d", len(eclipseMapDataSources), len(documented))
+3
View File
@@ -19,6 +19,7 @@
// c1-c4-visibility-regions 凌日可见性分区
// c1-c4-horizon-boundaries 凌日 C1、C4 视地平边界
// transit-contact-isochrones 凌日站心接触等时线
// topocentric-transit-visibility 地心无接触时按站心求得的部分凌日可见区
//
// Package svg renders solar-eclipse, lunar-eclipse and Mercury/Venus transit SVG charts. Degradable layers tag the geometry
// source they actually used in data-source, using exactly the values above.
@@ -43,6 +44,7 @@ const (
eclipseMapSourceTransitVisibilityRegions = "c1-c4-visibility-regions"
eclipseMapSourceTransitHorizonBoundaries = "c1-c4-horizon-boundaries"
eclipseMapSourceTransitContactIsochrones = "transit-contact-isochrones"
eclipseMapSourceTransitPartialVisibility = "topocentric-transit-visibility"
)
// eclipseMapDataSources 是全部合法取值,测试用它封闭词表。
@@ -64,4 +66,5 @@ var eclipseMapDataSources = []string{
eclipseMapSourceTransitVisibilityRegions,
eclipseMapSourceTransitHorizonBoundaries,
eclipseMapSourceTransitContactIsochrones,
eclipseMapSourceTransitPartialVisibility,
}
+44 -10
View File
@@ -175,13 +175,31 @@ func renderTransitLocalSVG(
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} {
line = svgchart.EllipsizeText(line, width-80, 13)
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
footerLines := transitLocalFooterLines(local, options, width)
footerBaselines := transitLocalFooterBaselines(len(footerLines), height)
footerTop := height - 30.0
if len(footerBaselines) > 0 {
footerTop = footerBaselines[0] - 10.2
}
const (
headerBottom = 72 + 19 + 10 // 经纬度行、摘要行与留白
axisLabelAbove = 33.0 // 盘顶到“北”字上沿
axisLabelBelow = 36.0 // 盘底到“南”字下沿
minimumDiskHeight = 150.0
)
top, bottom := headerBottom+axisLabelAbove, footerTop-axisLabelBelow
if bottom-top < minimumDiskHeight {
shrink := (minimumDiskHeight - (bottom - top)) / 2
top -= shrink
bottom += shrink
}
diskLeft, diskRight := 46.0, width-panelWidth-60
cx, cy := (diskLeft+diskRight)/2, (top+bottom)/2
extent := local.SunSemidiameterArcsec + 2.2*local.PlanetSemidiameterArcsec + 60
@@ -196,6 +214,9 @@ func renderTransitLocalSVG(
events := transitLocalEvents(local, options.Language)
planetRadius := math.Max(local.PlanetSemidiameterArcsec*scale, 2)
table := &svgchart.LabelTable{}
table.Reserve(30, footerTop-4, width-60, height-footerTop+4)
table.Reserve(cx-14, cy+sunRadius+16, 28, 18)
table.Reserve(cx-14, cy-sunRadius-32, 28, 20)
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
fill, stroke := "#1d1d1d", "#000000"
@@ -234,7 +255,7 @@ func renderTransitLocalSVG(
// 标签平移进白色图框内、接触表左侧,不能伸出画面。
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))
candidate.Y += math.Max(0, top-8-box.Y) - math.Max(0, box.Y+box.Height-(footerTop-4))
candidates = append(candidates, candidate)
}
placement, placed = table.Place(text, 11, candidates)
@@ -254,8 +275,14 @@ func renderTransitLocalSVG(
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)
tableWidth := panelWidth
tableX := width - panelWidth - 36
if minimum := cx + sunRadius + 40; tableX < minimum {
tableX = minimum
tableWidth = width - tableX - 36
}
writeTransitLocalContactTable(&b, local, events, at, tableX, top+10, tableWidth, options.Language)
writeTransitLocalFooter(&b, footerLines, footerBaselines)
b.WriteString(`</svg>`)
return b.String()
}
@@ -301,7 +328,7 @@ func transitLocalSummaryText(local basic.PlanetTransitLocalResult, language stri
// writeTransitLocalAxes 画过日心的南北、东西虚线与方向字。
func writeTransitLocalAxes(b *strings.Builder, cx, cy, radius float64, language string) {
reach := radius + 18
reach := radius + 12
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{"北", "南", "东", "西"}
@@ -419,18 +446,25 @@ func writeTransitLocalContactTable(
}
}
func writeTransitLocalFooter(b *strings.Builder, local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width, height float64) {
func transitLocalFooterLines(local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width float64) []string {
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 = "天空视向,上北左东;站心接触时刻,太阳高度为日心几何高度,灰色部分太阳中心在视地平下(计入标准大气折射)。"
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)
return svgchart.FooterLinesWithScale(svgchart.WrapText(note, width-80, 12), scaleNote, width-80, 12, 3)
}
func transitLocalFooterBaselines(lineCount int, height float64) []float64 {
baselines, _ := svgchart.FooterBlock(lineCount, height, 12, svgchart.FooterLineHeight(12), svgchart.FooterBottomPadding(12)+18)
return baselines
}
func writeTransitLocalFooter(b *strings.Builder, lines []string, baselines []float64) {
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))
+119 -50
View File
@@ -36,9 +36,9 @@ type TransitIsochrones uint8
const (
// TransitIsochroneC1 画一触等时线 / draw C1 isochrones.
TransitIsochroneC1 TransitIsochrones = 1 << iota
// TransitIsochroneC2 画二触等时线;掠凌没有内切接触时忽略 / draw C2 isochrones, ignored for a grazing transit.
// TransitIsochroneC2 画二触等时线;地表没有站心内切时忽略 / draw C2 isochrones, ignored where no site has internal contact.
TransitIsochroneC2
// TransitIsochroneC3 画三触等时线;掠凌没有内切接触时忽略 / draw C3 isochrones, ignored for a grazing transit.
// TransitIsochroneC3 画三触等时线;地表没有站心内切时忽略 / draw C3 isochrones, ignored where no site has internal contact.
TransitIsochroneC3
// TransitIsochroneC4 画四触等时线 / draw C4 isochrones.
TransitIsochroneC4
@@ -64,8 +64,7 @@ type TransitMapSVGOptions struct {
// 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 选择地图投影,默认等经纬;正射视点在地心无接触时取站心可见区中心,否则取凌甚太阳直射点 / projection, default equirectangular; orthographic centres on the visible footprint without geocentric contacts, otherwise 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).
@@ -79,12 +78,14 @@ type TransitMapSVGOptions struct {
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.
// TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:普通凌日按一触、凌甚、四触太阳中心在视地平上
// (计入标准大气折射)的区域分区,地心无接触时绘制站心部分凌日可见区;Options.Isochrones 显式开启时叠加所选接触的
// 站心等时线,图例只列实际画出的接触。找不到凌日或选项无效时返回 false。
// TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: ordinary transits are
// partitioned by whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4;
// events without geocentric contacts show the topocentric partial-transit footprint. Topocentric isochrones of the
// selected contacts are overlaid only when Options.Isochrones enables them, and the legend lists only contacts that
// produced lines. 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) {
@@ -97,7 +98,7 @@ func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOp
options.Location = time.UTC
}
options = normalizeTransitMapSVGOptions(date, options)
if !transit.HasInternal {
if !transit.HasExternal {
options.Isochrones &^= TransitIsochroneC2 | TransitIsochroneC3
}
options.Isochrones &= TransitIsochroneAll
@@ -114,7 +115,7 @@ func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOp
return renderTransitMapSVG(planet, transit, options, projection), true
}
// closestPlanetTransit 返回距 date 最近的一次地心凌日;行星无效或找不到时第二个返回值为 false。
// closestPlanetTransit 返回距 date 最近的一次全球凌日。
func closestPlanetTransit(planet TransitPlanet, date time.Time) (basic.PlanetTransitResult, bool) {
jd := basic.Date2JD(date.UTC())
var transit basic.PlanetTransitResult
@@ -155,7 +156,12 @@ type transitMapContacts struct {
}
func transitMapContactsOf(transit basic.PlanetTransitResult) transitMapContacts {
at := func(jd float64) time.Time { return basic.JD2DateByZone(jd, time.UTC, false) }
at := func(jd float64) time.Time {
if jd == 0 {
return time.Time{}
}
return basic.JD2DateByZone(jd, time.UTC, false)
}
contacts := transitMapContacts{
start: at(transit.ExternalIngress),
greatest: at(transit.Greatest),
@@ -178,7 +184,7 @@ func renderTransitMapSVG(
// 底部比月食图多留两行:图例下要放可折行的说明与时标声明。
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 122)
if projection == svgmap.ProjectionOrthographic {
center := solarEclipseSubsolarPoint(contacts.greatest)
center := transitMapOrthographicCenter(transit)
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
}
title := options.Title
@@ -192,8 +198,12 @@ func renderTransitMapSVG(
}
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">`,
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))
if projection == svgmap.ProjectionOrthographic {
fmt.Fprintf(&b, ` data-center-longitude="%.6f" data-center-latitude="%.6f"`, frame.CenterLongitude, frame.CenterLatitude)
}
b.WriteString(`>`)
b.WriteString(`<defs>`)
writeTransitMapDefinitions(&b, frame, contacts)
b.WriteString(`</defs>`)
@@ -211,13 +221,15 @@ func renderTransitMapSVG(
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)
if transit.HasExternal {
writeTransitMapRegions(&b, frame, contacts)
} else {
writeTransitMapPartialRegions(&b, frame, transit)
}
options.Isochrones = writeTransitMapIsochrones(&b, frame, transit, options)
frame.WriteFrame(&b)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options, transit.HasExternal)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows, transit.HasExternal)
b.WriteString(`</svg>`)
return b.String()
}
@@ -264,6 +276,9 @@ const (
// writeTransitMapDefinitions 输出一触、凌甚、四触三个太阳在地平上半球的形状与排除掩膜,分区口径同月食可见图。
func writeTransitMapDefinitions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
b.WriteString(frame.ClipDefinition("transit-map-clip"))
if contacts.start.IsZero() || contacts.end.IsZero() {
return
}
startPath, _ := transitMapSunUpPath(contacts.start, frame)
endPath, _ := transitMapSunUpPath(contacts.end, frame)
greatestPath, _ := transitMapSunUpPath(contacts.greatest, frame)
@@ -285,10 +300,19 @@ const (
transitMapColorSunrise = "#4e9da0"
transitMapColorSunset = "#e2aa4b"
transitMapColorNone = "#747b7d"
transitMapColorPartial = "#f7dd93"
transitMapColorC1 = "#a3392f"
transitMapColorC2 = "#c0702a"
transitMapColorC3 = "#7a4ca0"
transitMapColorC4 = "#2f5ea3"
transitMapFooterSideInset = 120.0
transitMapRegionFillOpacity = 0.34
// 部分凌日只有一块可见区,要比普通凌日分区更浓才能从底图上辨认。
transitMapPartialFillOpacity = 0.55
// 图例色块直接画在白底上,比叠在海陆底色上的分区取更浓的值,同月食图。
transitMapLegendFillOpacity = 0.78
)
// writeTransitMapRegions 着色四类可见区:全程可见、日出时凌日已开始、日落时凌日未结束、不可见。
@@ -298,25 +322,27 @@ func writeTransitMapRegions(b *strings.Builder, frame svgmap.Frame, contacts tra
_, endBoundary := transitMapSunUpPath(contacts.end, frame)
fmt.Fprintf(b, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitVisibilityRegions)
if frame.IsPolar() {
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone)
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="%.2f"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone, transitMapRegionFillOpacity)
} else {
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone)
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="%.2f"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone, transitMapRegionFillOpacity)
}
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunrise)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorEntire)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="0.34"/></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="%.2f"/>`, transitMapColorSunset, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="%.2f"/>`, transitMapColorSunrise, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="%.2f"/></g></g>`, transitMapColorEntire, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="%.2f"/></g></g>`, transitMapColorSunset, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="%.2f"/></g>`, transitMapColorSunset, transitMapRegionFillOpacity)
b.WriteString(`</g>`)
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) {
func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) TransitIsochrones {
var drawn TransitIsochrones
table := &svgchart.LabelTable{}
format := "15:04"
if options.IsochroneStep%time.Minute != 0 {
@@ -337,6 +363,9 @@ func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit b
continue
}
lines := basic.PlanetTransitContactIsochrones(transit, contact.kind, options.IsochroneStep)
if len(lines) > 0 {
drawn |= contact.flag
}
labelEvery := transitMapIsochroneLabelEvery(len(lines))
for index, line := range lines {
segments := make([][]svgmap.GeoPoint, 0, len(line.Segments))
@@ -360,6 +389,7 @@ func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit b
contact.class, placement.X, placement.Y, contact.color, placement.Anchor, html.EscapeString(text))
}
}
return drawn
}
// transitMapIsochroneLabelEvery 让每个接触最多标八条线。
@@ -413,7 +443,10 @@ func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResul
if options.Language == "en" {
greatest, separation = "Greatest", "least separation"
}
parts := []string{"C1 " + clock(contacts.start)}
parts := []string{}
if !contacts.start.IsZero() {
parts = append(parts, "C1 "+clock(contacts.start))
}
if contacts.hasInternal {
parts = append(parts, "C2 "+clock(contacts.internalStart))
}
@@ -421,7 +454,13 @@ func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResul
if contacts.hasInternal {
parts = append(parts, "C3 "+clock(contacts.internalEnd))
}
parts = append(parts, "C4 "+clock(contacts.end))
if !contacts.end.IsZero() {
parts = append(parts, "C4 "+clock(contacts.end))
} else if options.Language == "en" {
parts = append(parts, "Partial transit; no geocentric contacts")
} else {
parts = append(parts, "仅部分凌日,地心无接触")
}
line := fmt.Sprintf("%s (%s)", strings.Join(parts, " | "), zone)
fmt.Fprintf(b, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(line, float64(options.Width)-60, 13)))
@@ -432,23 +471,33 @@ func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResul
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
}
fmt.Fprintf(b, `<text x="%.3f" y="100" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(note))
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(transitMapGeometryNote(transit, options.Language)+" | "+note, float64(options.Width)-60, 13)))
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions) int {
type entry struct {
text, color string
line bool
}
type transitMapLegendEntry struct {
text, color string
line bool
}
// transitMapLegendEntries 列出图例项:地心无接触时是部分凌日可见区,否则是四个分区;两者都追加实际画出的等时线。
func transitMapLegendEntries(options TransitMapSVGOptions, hasExternal bool) []transitMapLegendEntry {
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},
var entries []transitMapLegendEntry
if !hasExternal {
partial := "部分凌日可见区"
if options.Language == "en" {
partial = "Partial transit visible"
}
entries = []transitMapLegendEntry{{partial, transitMapColorPartial, false}}
} else {
entries = []transitMapLegendEntry{
{labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false},
{labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false},
}
}
for index, item := range []struct {
flag TransitIsochrones
@@ -458,14 +507,20 @@ func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth f
{TransitIsochroneC3, transitMapColorC3}, {TransitIsochroneC4, transitMapColorC4},
} {
if options.Isochrones&item.flag != 0 {
entries = append(entries, entry{labels[4+index], item.color, true})
entries = append(entries, transitMapLegendEntry{labels[4+index], item.color, true})
}
}
width := func(item entry) float64 {
return entries
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions, hasExternal bool) int {
entries := transitMapLegendEntries(options, hasExternal)
var rows [][]transitMapLegendEntry
width := func(item transitMapLegendEntry) 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 {
@@ -487,7 +542,7 @@ func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth f
if item.line {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.4"/>`, x, y-4, x+18, y-4, item.color)
} else {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, item.color)
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="%.2f"/>`, x, y-8, item.color, transitMapLegendFillOpacity)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
x+lunarEclipseDetailedMapLegendIconWidth, y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(item.text))
@@ -504,17 +559,25 @@ func writeTransitMapFooter(
projection svgmap.Projection,
instant time.Time,
legendRows int,
hasExternal bool,
) {
text := options.FooterNote
if text == "" {
projectionLabel := transitMapProjectionLabel(projection, hasExternal, options.Language)
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"
text = projectionLabel + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4"
if !hasExternal {
text = projectionLabel + "; shaded footprint uses topocentric disk overlap and apparent-horizon visibility (standard refraction)"
}
if options.Isochrones != 0 {
text += "; isochrones are topocentric contact times"
}
text += "."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
text = projectionLabel + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
if !hasExternal {
text = projectionLabel + ";着色区域按站心圆面相交与视地平可见性计算(计入标准大气折射)"
}
if options.Isochrones != 0 {
text += ";等时线为站心接触时刻"
}
@@ -525,12 +588,18 @@ func writeTransitMapFooter(
// 页脚紧跟图例末行,不与图例重叠;行数按画布底边截断,时标声明独占最后一行。
baseline := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 18
x, anchor := frame.X, "start"
maxWidth := float64(options.Width) - frame.X - 24
// 球面投影的盘面居中而左缘靠里,页脚改为随图例居中,否则文字整体右偏。
if frame.IsDisk() {
x, anchor = float64(options.Width)/2, "middle"
maxWidth = float64(options.Width) - 2*transitMapFooterSideInset
}
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, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11" text-anchor="%s">%s</text>`,
x, baseline+float64(index)*svgchart.FooterLineHeight(11), anchor, html.EscapeString(line))
}
}
+158
View File
@@ -0,0 +1,158 @@
package svg
import (
"fmt"
"math"
"regexp"
"strings"
"testing"
"time"
"b612.me/astro/basic"
)
func TestTransitMapVisibilityCenter(tester *testing.T) {
for _, test := range []struct {
name string
columns []basic.PlanetTransitVisibilityColumn
longitude float64
latitude float64
valid bool
}{
{name: "empty"},
{name: "no-visible-interval", columns: []basic.PlanetTransitVisibilityColumn{{Longitude: -180}, {Longitude: 180}}},
{name: "antimeridian", columns: []basic.PlanetTransitVisibilityColumn{
{Longitude: -180, Intervals: [][2]float64{{-10, 10}}},
{Longitude: -178, Intervals: [][2]float64{{-10, 10}}},
{Longitude: -176}, {Longitude: 176},
{Longitude: 178, Intervals: [][2]float64{{-10, 10}}},
{Longitude: 180, Intervals: [][2]float64{{-10, 10}}},
}, longitude: 180, valid: true},
{name: "north", columns: []basic.PlanetTransitVisibilityColumn{
{Longitude: -180, Intervals: [][2]float64{{70, 90}}},
{Longitude: -90, Intervals: [][2]float64{{70, 90}}},
{Longitude: 0, Intervals: [][2]float64{{70, 90}}},
{Longitude: 90, Intervals: [][2]float64{{70, 90}}},
{Longitude: 180, Intervals: [][2]float64{{70, 90}}},
}, latitude: 90, valid: true},
{name: "south", columns: []basic.PlanetTransitVisibilityColumn{
{Longitude: -180, Intervals: [][2]float64{{-90, -70}}},
{Longitude: -90, Intervals: [][2]float64{{-90, -70}}},
{Longitude: 0, Intervals: [][2]float64{{-90, -70}}},
{Longitude: 90, Intervals: [][2]float64{{-90, -70}}},
{Longitude: 180, Intervals: [][2]float64{{-90, -70}}},
}, latitude: -90, valid: true},
} {
tester.Run(test.name, func(tester *testing.T) {
center, valid := transitMapVisibilityCenter(test.columns)
if valid != test.valid {
tester.Fatalf("valid=%v, want %v", valid, test.valid)
}
if valid && (math.Abs(center.Latitude-test.latitude) > 1e-8 ||
math.Abs(test.latitude) < 90 && math.Abs(math.Remainder(center.Longitude-test.longitude, 360)) > 1e-8) {
tester.Fatalf("center=%+v, want longitude=%v latitude=%v", center, test.longitude, test.latitude)
}
})
}
}
func TestTransitMapOrthographicPartialCenter(tester *testing.T) {
for _, test := range []struct {
planet TransitPlanet
year int
month time.Month
day int
longitude float64
latitude float64
}{
{TransitVenus, 2854, time.December, 14, -135, -60},
{TransitMercury, 1937, time.May, 11, 0, -60},
} {
tester.Run(fmt.Sprintf("%d-%d", test.planet, test.year), func(tester *testing.T) {
date := time.Date(test.year, test.month, test.day, 0, 0, 0, 0, time.UTC)
transit, valid := closestPlanetTransit(test.planet, date)
if !valid || transit.HasExternal {
tester.Fatal("expected a transit without geocentric contacts")
}
center := transitMapOrthographicCenter(transit)
local := basic.PlanetTransitLocal(transit, basic.Observer{Longitude: center.Longitude, Latitude: center.Latitude})
if !local.Valid || !local.Visible {
tester.Fatalf("view centre must lie in the visible footprint: %+v", center)
}
for _, projection := range []EclipseMapProjection{EclipseMapProjectionEquirectangular, EclipseMapProjectionSouthPolar, EclipseMapProjectionOrthographic} {
chart, valid := TransitMapSVG(test.planet, date, TransitMapSVGOptions{Projection: projection, Language: "en"})
if !valid || strings.Contains(chart, "NaN") || strings.Contains(chart, "Inf") {
tester.Fatalf("%s: invalid chart", projection)
}
paths := regexp.MustCompile(`<path class="transit-partial-region" d="([^"]+)"[^>]+>`).FindAllStringSubmatch(chart, -1)
if len(paths) != 1 || !strings.Contains(paths[0][0], `fill-rule="nonzero"`) {
tester.Fatalf("%s: footprint must use a single nonzero fill, got %d paths", projection, len(paths))
}
frame := eclipseMapFrame(960, 640, internalEclipseMapProjection(projection), 142, 122)
if projection == EclipseMapProjectionOrthographic {
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
if !strings.Contains(chart, fmt.Sprintf(`data-center-longitude="%.6f" data-center-latitude="%.6f"`, center.Longitude, center.Latitude)) ||
!strings.Contains(chart, "centred on the topocentric visible footprint") {
tester.Fatal("orthographic view centre or explanation missing")
}
}
x, y, projectable := frame.Project(test.longitude, test.latitude)
if !projectable || !lunarPenumbralTestInside(lunarPenumbralTestRings(tester, paths[0][1]), x, y) {
tester.Fatalf("%s: known visible site is outside the painted footprint", projection)
}
}
})
}
}
func TestTransitMapOrthographicCenterFallback(tester *testing.T) {
for _, test := range []struct {
planet TransitPlanet
date time.Time
}{
{TransitMercury, time.Date(2019, 11, 11, 0, 0, 0, 0, time.UTC)},
{TransitVenus, time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)},
} {
transit, valid := closestPlanetTransit(test.planet, test.date)
if !valid || !transit.HasExternal {
tester.Fatal("expected an ordinary geocentric transit")
}
want := solarEclipseSubsolarPoint(transitMapContactsOf(transit).greatest)
if got := transitMapOrthographicCenter(transit); got != want {
tester.Fatalf("ordinary transit view changed: got %+v want %+v", got, want)
}
transit.Valid, transit.HasExternal = false, false
if got := transitMapOrthographicCenter(transit); got != want {
tester.Fatalf("empty visibility must fall back to the subsolar point: %+v", got)
}
}
}
func TestTransitMapFooterAlignment(tester *testing.T) {
footer := regexp.MustCompile(`<text x="([0-9.]+)" y="[0-9.]+" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11" text-anchor="(\w+)">`)
date := time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC)
for _, test := range []struct {
projection EclipseMapProjection
x, anchor string
}{
{EclipseMapProjectionOrthographic, "480.000", "middle"},
{"", "104.000", "start"},
} {
chart, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Projection: test.projection, Language: "en"})
if !ok {
tester.Fatalf("%q: render failed", test.projection)
}
lines := footer.FindAllStringSubmatch(chart, -1)
if len(lines) < 2 {
tester.Fatalf("%q: footer lines=%d", test.projection, len(lines))
}
for _, line := range lines {
if line[1] != test.x || line[2] != test.anchor {
tester.Fatalf("%q: footer at x=%s anchor=%s, want %s %s", test.projection, line[1], line[2], test.x, test.anchor)
}
}
if !strings.Contains(chart, `class="transit-partial-region"`) || strings.Contains(chart, "#747b7d") || !strings.Contains(chart, `fill="#f7dd93"`) {
tester.Fatalf("%q: partial region fill changed", test.projection)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package svg
import (
"fmt"
"math"
"strings"
"b612.me/astro/basic"
"b612.me/astro/internal/svgmap"
)
func transitMapOrthographicCenter(transit basic.PlanetTransitResult) svgmap.GeoPoint {
if !transit.HasExternal {
if center, ok := transitMapVisibilityCenter(basic.PlanetTransitVisibilityColumns(transit)); ok {
return center
}
}
return solarEclipseSubsolarPoint(transitMapContactsOf(transit).greatest)
}
func transitMapVisibilityCenter(columns []basic.PlanetTransitVisibilityColumn) (svgmap.GeoPoint, bool) {
var momentX, momentY, momentZ float64
for index, column := range columns {
weight := 0.0
if index > 0 {
weight += (column.Longitude - columns[index-1].Longitude) / 2
}
if index+1 < len(columns) {
weight += (columns[index+1].Longitude - column.Longitude) / 2
}
longitude := column.Longitude * math.Pi / 180
for _, interval := range column.Intervals {
lower, upper := interval[0]*math.Pi/180, interval[1]*math.Pi/180
horizontalMoment := (upper-lower)/2 + (math.Sin(2*upper)-math.Sin(2*lower))/4
verticalMoment := (math.Sin(upper)*math.Sin(upper) - math.Sin(lower)*math.Sin(lower)) / 2
momentX += weight * horizontalMoment * math.Cos(longitude)
momentY += weight * horizontalMoment * math.Sin(longitude)
momentZ += weight * verticalMoment
}
}
if math.Sqrt(momentX*momentX+momentY*momentY+momentZ*momentZ) < 1e-12 {
return svgmap.GeoPoint{}, false
}
return svgmap.GeoPoint{
Longitude: math.Atan2(momentY, momentX) * 180 / math.Pi,
Latitude: math.Atan2(momentZ, math.Hypot(momentX, momentY)) * 180 / math.Pi,
}, true
}
func transitMapProjectionLabel(projection svgmap.Projection, hasExternal bool, language string) string {
if projection != svgmap.ProjectionOrthographic {
return eclipseMapProjectionLabel(projection, language)
}
if language == "en" {
center := "the subsolar point at greatest"
if !hasExternal {
center = "the topocentric visible footprint"
}
return "Orthographic globe projection, centred on " + center + "; one hemisphere only"
}
center := "凌甚太阳直射点"
if !hasExternal {
center = "站心可见区中心"
}
return "正射球面投影,视点取" + center + ",只画朝向视点的半个地球"
}
func writeTransitMapPartialRegions(builder *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult) {
fmt.Fprintf(builder, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitPartialVisibility)
var path strings.Builder
for _, polygon := range basic.PlanetTransitVisibilityPolygons(transit) {
points := make([]svgmap.GeoPoint, len(polygon))
for index, point := range polygon {
points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
path.WriteString(lunarEclipseGeoPolygonPath(points, frame))
}
if path.Len() > 0 {
fmt.Fprintf(builder, `<path class="transit-partial-region" d="%s" fill="%s" fill-opacity="%.2f" fill-rule="nonzero"/>`, path.String(), transitMapColorPartial, transitMapPartialFillOpacity)
}
builder.WriteString(`</g>`)
}
func transitMapGeometryNote(transit basic.PlanetTransitResult, language string) string {
text := fmt.Sprintf("γ %.4f", transit.Gamma)
if transit.Series.Number != 0 {
if language == "en" {
text += fmt.Sprintf(" | Series %d (%d yr)", transit.Series.Number, transit.Series.PeriodYears)
} else {
text += fmt.Sprintf(" | 周期族 %d(%d 年)", transit.Series.Number, transit.Series.PeriodYears)
}
}
return text
}
+106
View File
@@ -0,0 +1,106 @@
package svg
import (
"regexp"
"strconv"
"strings"
"testing"
"time"
)
func TestTransitSVGGlobalPartial(tester *testing.T) {
date := time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC)
chart, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Isochrones: TransitIsochroneAll, IsochroneStep: 30 * time.Second})
if !ok {
tester.Fatal("global partial transit render failed")
}
for _, expected := range []string{"2854-12-14", "transit-partial-region", "地心无接触", "周期族 4", "γ ", "c1-isochrone", "c4-isochrone"} {
if !strings.Contains(chart, expected) {
tester.Fatalf("missing %s", expected)
}
}
for _, absent := range []string{"NaN", "Inf", "transit-sun-c1-shape", "c2-isochrone", "c3-isochrone"} {
if strings.Contains(chart, absent) {
tester.Fatalf("invalid SVG contains %s", absent)
}
}
local, ok := TransitLocalSVG(TransitVenus, date, -135, -60, 0, TransitLocalSVGOptions{})
if !ok || !strings.Contains(local, "transit-planet-c1") || !strings.Contains(local, "transit-planet-c4") || strings.Contains(local, "transit-planet-c2") {
tester.Fatal("local partial-transit diagram has invalid contacts")
}
labels := regexp.MustCompile(`class="transit-event-label" x="[0-9.-]+" y="([0-9.-]+)"`).FindAllStringSubmatch(local, -1)
if len(labels) != 3 {
tester.Fatalf("grazing contact labels=%d, want 3", len(labels))
}
footer := regexp.MustCompile(`<text x="40" y="([0-9.-]+)" fill="#555"`).FindAllStringSubmatch(local, -1)
if len(footer) == 0 {
tester.Fatal("local partial-transit diagram has no footer")
}
firstFooterBaseline, _ := strconv.ParseFloat(footer[0][1], 64)
for _, label := range labels {
baseline, _ := strconv.ParseFloat(label[1], 64)
if baseline > firstFooterBaseline-8 {
tester.Fatal("contact label overlaps the footer")
}
}
if _, ok := TransitLocalSVG(TransitVenus, date, 0, 45, 0, TransitLocalSVGOptions{}); ok {
tester.Fatal("a northern site must miss the transit")
}
}
func TestTransitMapLegendListsDrawnIsochrones(tester *testing.T) {
for _, test := range []struct {
planet TransitPlanet
date time.Time
inner bool
}{
{TransitVenus, time.Date(554, 5, 22, 0, 0, 0, 0, time.UTC), true},
{TransitMercury, time.Date(-513, 10, 22, 0, 0, 0, 0, time.UTC), false},
} {
chart, ok := TransitMapSVG(test.planet, test.date, TransitMapSVGOptions{Isochrones: TransitIsochroneAll, Language: "en"})
if !ok {
tester.Fatalf("%v: render failed", test.date)
}
for _, marker := range []string{"c2-isochrone", "C2 isochrones", "C3 isochrones"} {
if strings.Contains(chart, marker) != test.inner {
tester.Fatalf("%v: %s present=%v, want %v", test.date, marker, !test.inner, test.inner)
}
}
}
}
// 地心无接触的部分凌日也要在图例里列出实际画出的一触、四触等时线;二触、三触不存在。
func TestTransitPartialLegendListsDrawnIsochrones(tester *testing.T) {
for _, language := range []string{"zh", "en"} {
chart, ok := TransitMapSVG(TransitVenus, time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC), TransitMapSVGOptions{
Isochrones: TransitIsochroneAll, Language: language,
})
if !ok {
tester.Fatalf("%s: render failed", language)
}
partial, c1, c4 := "部分凌日可见区", "一触等时线", "四触等时线"
if language == "en" {
partial, c1, c4 = "Partial transit visible", "C1 isochrones", "C4 isochrones"
}
for _, expected := range []string{"c1-isochrone", "c4-isochrone", partial, c1, c4} {
if !strings.Contains(chart, expected) {
tester.Fatalf("%s: partial chart is missing %q", language, expected)
}
}
for _, absent := range []string{"c2-isochrone", "c3-isochrone", "二触等时线", "三触等时线", "C2 isochrones", "C3 isochrones"} {
if strings.Contains(chart, absent) {
tester.Fatalf("%s: partial chart must not contain %q", language, absent)
}
}
}
// 默认不画等时线时图例只留可见区色块,不凭空加等时线条目。
chart, ok := TransitMapSVG(TransitVenus, time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC), TransitMapSVGOptions{Language: "en"})
if !ok {
tester.Fatal("default render failed")
}
for _, absent := range []string{"c1-isochrone", "C1 isochrones", "C4 isochrones"} {
if strings.Contains(chart, absent) {
tester.Fatalf("default chart must not contain %q", absent)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package svg
import (
"fmt"
"regexp"
"strings"
"testing"
"time"
)
var transitLegendSwatchPattern = regexp.MustCompile(`<rect [^>]*width="18" height="9"[^>]*fill-opacity="([0-9.]+)"`)
type transitOverlapCase struct {
name string
render func(width, height int) (string, bool)
}
func transitOverlapCases() []transitOverlapCase {
partial := time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC)
ordinary := time.Date(2012, time.June, 6, 0, 0, 0, 0, time.UTC)
return []transitOverlapCase{
{"local-partial-zh", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitMercury, partial, 0, -60, 0, TransitLocalSVGOptions{Width: width, Height: height})
}},
{"local-partial-en", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitMercury, partial, 0, -60, 0, TransitLocalSVGOptions{Width: width, Height: height, Language: "en"})
}},
{"local-full-zh", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitVenus, ordinary, 116.4, 39.9, 0, TransitLocalSVGOptions{Width: width, Height: height})
}},
{"local-full-en", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitVenus, ordinary, 116.4, 39.9, 0, TransitLocalSVGOptions{Width: width, Height: height, Language: "en"})
}},
{"map-partial", func(width, height int) (string, bool) {
return TransitMapSVG(TransitMercury, partial, TransitMapSVGOptions{Width: width, Height: height, Isochrones: TransitIsochroneAll})
}},
{"map-partial-orthographic", func(width, height int) (string, bool) {
return TransitMapSVG(TransitMercury, partial, TransitMapSVGOptions{
Width: width, Height: height, Isochrones: TransitIsochroneAll, Projection: EclipseMapProjectionOrthographic,
})
}},
{"map-ordinary", func(width, height int) (string, bool) {
return TransitMapSVG(TransitVenus, ordinary, TransitMapSVGOptions{Width: width, Height: height, Isochrones: TransitIsochroneAll})
}},
}
}
// 凌日图与日月食图同一口径:任意两个尺寸下返回的图都必须 0 文字压叠。
func TestTransitChartTextOverlapMatrix(t *testing.T) {
for _, size := range [][2]int{{640, 420}, {640, 480}, {800, 600}, {960, 640}, {960, 680}, {1200, 800}} {
for _, test := range transitOverlapCases() {
name := fmt.Sprintf("%s/%dx%d", test.name, size[0], size[1])
document, ok := test.render(size[0], size[1])
if !ok {
continue
}
assertNoTextOverlap(t, name, document)
}
}
// 文档下限尺寸必须继续出图,拒绝只允许出现在真放不下的画布上。
if _, ok := TransitLocalSVG(TransitMercury, time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC), 0, -60, 0,
TransitLocalSVGOptions{Width: 640, Height: 480}); !ok {
t.Error("documented 640x480 local transit canvas was rejected")
}
}
// 地心无接触的可见区填充必须落在 data-source 词表内,消费方才能判断该层来源。
func TestTransitPartialRegionsUseDocumentedDataSource(t *testing.T) {
chart, ok := TransitMapSVG(TransitMercury, time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC), TransitMapSVGOptions{})
if !ok {
t.Fatal("partial transit render failed")
}
if !strings.Contains(chart, `data-source="`+eclipseMapSourceTransitPartialVisibility+`"`) {
t.Fatalf("partial regions carry no documented data-source marker")
}
for _, match := range eclipseMapDataSourcePattern.FindAllStringSubmatch(chart, -1) {
known := false
for _, source := range eclipseMapDataSources {
known = known || source == match[1]
}
if !known {
t.Fatalf("undocumented data-source %q", match[1])
}
}
}
// 图例色块画在白底上取月食图同值;部分凌日单块可见区比普通分区更浓。
func TestTransitMapFillOpacities(t *testing.T) {
for _, test := range []struct {
planet TransitPlanet
date time.Time
pattern *regexp.Regexp
region float64
}{
{TransitVenus, time.Date(2012, time.June, 6, 0, 0, 0, 0, time.UTC),
regexp.MustCompile(`class="transit-[a-z-]+-region"[^>]*fill-opacity="([0-9.]+)"`), transitMapRegionFillOpacity},
{TransitMercury, time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC),
regexp.MustCompile(`class="transit-partial-region"[^>]*fill-opacity="([0-9.]+)"`), transitMapPartialFillOpacity},
} {
chart, ok := TransitMapSVG(test.planet, test.date, TransitMapSVGOptions{})
if !ok {
t.Fatalf("%v: render failed", test.date)
}
regions := test.pattern.FindAllStringSubmatch(chart, -1)
if len(regions) == 0 {
t.Fatalf("%v: no region fill found", test.date)
}
for _, match := range regions {
if match[1] != fmt.Sprintf("%.2f", test.region) {
t.Fatalf("%v: region fill-opacity %s, want %.2f", test.date, match[1], test.region)
}
}
legends := transitLegendSwatchPattern.FindAllStringSubmatch(chart, -1)
if len(legends) == 0 {
t.Fatalf("%v: no legend swatch found", test.date)
}
for _, match := range legends {
if match[1] != fmt.Sprintf("%.2f", transitMapLegendFillOpacity) {
t.Fatalf("%v: legend fill-opacity %s, want %.2f", test.date, match[1], transitMapLegendFillOpacity)
}
}
}
if transitMapPartialFillOpacity <= transitMapRegionFillOpacity {
t.Fatal("partial-transit footprint must be denser than ordinary regions")
}
}