feat: 完善时标与天象几何计算并扩展输出接口

- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
This commit is contained in:
2026-09-23 18:55:12 +08:00
parent 1f31a9b5b5
commit 16c62a97d5
503 changed files with 33290 additions and 9471 deletions
+165
View File
@@ -0,0 +1,165 @@
package eclipse
import (
"time"
"b612.me/astro"
)
// 以下函数把结构里的民用时刻换成同一物理时刻的 UT1 时刻,供出图与导出共用。
// 零值 time.Time 表示该点或阶段不存在,一律原样保留;切片返回新副本,不改写调用方数据。
func ut1Label(t time.Time) time.Time {
if t.IsZero() {
return t
}
return astro.LabelIn(astro.TimeScaleUT1, t)
}
// TimeLabelsInUT1 返回换成 UT1 时刻的新切片 / returns a copy in UT1.
func TimeLabelsInUT1(times []time.Time) []time.Time {
return ut1Labels(times)
}
func ut1Labels(times []time.Time) []time.Time {
out := make([]time.Time, len(times))
for i := range times {
out[i] = ut1Label(times[i])
}
return out
}
func ut1PathPoint(p SolarEclipsePathPoint) SolarEclipsePathPoint {
p.Time = ut1Label(p.Time)
return p
}
func ut1PathPoints(points []SolarEclipsePathPoint) []SolarEclipsePathPoint {
out := make([]SolarEclipsePathPoint, len(points))
for i := range points {
out[i] = ut1PathPoint(points[i])
}
return out
}
func ut1PathPointGrid(grid [][]SolarEclipsePathPoint) [][]SolarEclipsePathPoint {
out := make([][]SolarEclipsePathPoint, len(grid))
for i := range grid {
out[i] = ut1PathPoints(grid[i])
}
return out
}
func ut1PartialFootprints(list []SolarEclipsePartialFootprint) []SolarEclipsePartialFootprint {
out := make([]SolarEclipsePartialFootprint, len(list))
for i := range list {
out[i] = list[i]
out[i].Time = ut1Label(list[i].Time)
out[i].Boundaries = ut1PathPointGrid(list[i].Boundaries)
out[i].HorizonEnds = ut1PathPoints(list[i].HorizonEnds)
}
return out
}
// SolarEclipseInfoInUT1 返回各阶段时刻换成 UT1 时刻的副本 / returns a copy in UT1.
func SolarEclipseInfoInUT1(info SolarEclipseInfo) SolarEclipseInfo {
info.GreatestEclipse = ut1Label(info.GreatestEclipse)
info.PartialBeginOnEarth = ut1Label(info.PartialBeginOnEarth)
info.PartialEndOnEarth = ut1Label(info.PartialEndOnEarth)
info.CentralBeginOnEarth = ut1Label(info.CentralBeginOnEarth)
info.CentralEndOnEarth = ut1Label(info.CentralEndOnEarth)
return info
}
// SolarEclipsePartialFootprintsInUT1 返回足迹、轮廓与接触点都换成 UT1 时刻的副本 / copy in UT1.
func SolarEclipsePartialFootprintsInUT1(info SolarEclipsePartialFootprintsInfo) SolarEclipsePartialFootprintsInfo {
info.Eclipse = SolarEclipseInfoInUT1(info.Eclipse)
info.Footprints = ut1PartialFootprints(info.Footprints)
info.CentralShadowFootprints = ut1PartialFootprints(info.CentralShadowFootprints)
info.CentralBandFootprints = ut1PartialFootprints(info.CentralBandFootprints)
info.CentralBandSegments = ut1PathPointGrid(info.CentralBandSegments)
info.CentralBandHorizonClosures = ut1PathPointGrid(info.CentralBandHorizonClosures)
info.PartialBandContours = ut1PathPointGrid(info.PartialBandContours)
contours := make([]SolarEclipseMagnitudeContour, len(info.MagnitudeContours))
for i := range info.MagnitudeContours {
contours[i] = info.MagnitudeContours[i]
contours[i].Segments = ut1PathPointGrid(info.MagnitudeContours[i].Segments)
contours[i].NorthernLimit = ut1PathPoints(info.MagnitudeContours[i].NorthernLimit)
contours[i].SouthernLimit = ut1PathPoints(info.MagnitudeContours[i].SouthernLimit)
}
info.MagnitudeContours = contours
greatest := make([]SolarEclipseGreatestTimeContour, len(info.GreatestTimeContours))
for i := range info.GreatestTimeContours {
greatest[i] = info.GreatestTimeContours[i]
greatest[i].Time = ut1Label(info.GreatestTimeContours[i].Time)
greatest[i].Segments = ut1PathPointGrid(info.GreatestTimeContours[i].Segments)
}
info.GreatestTimeContours = greatest
curves := make([]SolarEclipseRiseSetCurve, len(info.RiseSetCurves))
for i := range info.RiseSetCurves {
curves[i] = info.RiseSetCurves[i]
curves[i].Segments = ut1PathPointGrid(info.RiseSetCurves[i].Segments)
}
info.RiseSetCurves = curves
info.P1 = ut1PathPoint(info.P1)
info.P2 = ut1PathPoint(info.P2)
info.P3 = ut1PathPoint(info.P3)
info.P4 = ut1PathPoint(info.P4)
info.U1 = ut1PathPoint(info.U1)
info.U2 = ut1PathPoint(info.U2)
info.U3 = ut1PathPoint(info.U3)
info.U4 = ut1PathPoint(info.U4)
return info
}
// SolarEclipsePathInUT1 返回中心线与限界都换成 UT1 时刻的副本 / copy in UT1.
func SolarEclipsePathInUT1(path SolarEclipsePath) SolarEclipsePath {
path.Eclipse = SolarEclipseInfoInUT1(path.Eclipse)
path.Greatest = ut1PathPoint(path.Greatest)
path.CenterLine = ut1PathPoints(path.CenterLine)
path.NorthernLimit = ut1PathPoints(path.NorthernLimit)
path.SouthernLimit = ut1PathPoints(path.SouthernLimit)
path.CentralBandSegments = ut1PathPointGrid(path.CentralBandSegments)
return path
}
// LunarEclipseInfoInUT1 返回各阶段与接触点换成 UT1 时刻的副本 / copy in UT1.
func LunarEclipseInfoInUT1(info LunarEclipseInfo) LunarEclipseInfo {
info.PenumbralStart = ut1Label(info.PenumbralStart)
info.PartialStart = ut1Label(info.PartialStart)
info.TotalStart = ut1Label(info.TotalStart)
info.Maximum = ut1Label(info.Maximum)
info.TotalEnd = ut1Label(info.TotalEnd)
info.PartialEnd = ut1Label(info.PartialEnd)
info.PenumbralEnd = ut1Label(info.PenumbralEnd)
contacts := make([]LunarEclipseContactPoint, len(info.ContactPoints))
for i := range info.ContactPoints {
contacts[i] = info.ContactPoints[i]
contacts[i].Time = ut1Label(info.ContactPoints[i].Time)
}
info.ContactPoints = contacts
return info
}
// LocalSolarEclipseInfoInUT1 返回各阶段与接触点换成 UT1 时刻的副本 / copy in UT1.
func LocalSolarEclipseInfoInUT1(info LocalSolarEclipseInfo) LocalSolarEclipseInfo {
info.GreatestEclipse = ut1Label(info.GreatestEclipse)
info.PartialStart = ut1Label(info.PartialStart)
info.PartialEnd = ut1Label(info.PartialEnd)
info.CentralStart = ut1Label(info.CentralStart)
info.CentralEnd = ut1Label(info.CentralEnd)
contacts := make([]LocalSolarEclipseContactPoint, len(info.ContactPoints))
for i := range info.ContactPoints {
contacts[i] = info.ContactPoints[i]
contacts[i].Time = ut1Label(info.ContactPoints[i].Time)
}
info.ContactPoints = contacts
return info
}
// SolarEclipseGeocentricPanelInUT1 返回两个朔时刻换成 UT1 时刻的副本 / copy in UT1.
func SolarEclipseGeocentricPanelInUT1(panel SolarEclipseGeocentricPanel) SolarEclipseGeocentricPanel {
panel.Conjunction = ut1Label(panel.Conjunction)
panel.RightAscensionConjunction = ut1Label(panel.RightAscensionConjunction)
return panel
}
+12 -4
View File
@@ -108,6 +108,12 @@ type LunarEclipseInfo struct {
PartialEnd time.Time
// PenumbralEnd 半影终, penumbral eclipse ends.
PenumbralEnd time.Time
// GreatestJDE 是食甚的力学时儒略日;与 DeltaTSeconds 配对即可复现上列民用时刻。
// GreatestJDE is the TT Julian ephemeris day of greatest eclipse; with DeltaTSeconds it reproduces the civil times above.
GreatestJDE float64
// DeltaTSeconds 是本次计算实际使用的 TT−UT1,单位秒。
// DeltaTSeconds is the TT−UT1 used by this computation, in seconds.
DeltaTSeconds float64
// ContactPoints 是各接触时刻在月面上的接触点方位。
// ContactPoints are Moon-limb contact position angles at eclipse contacts.
@@ -370,6 +376,8 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L
TotalEnd: ttJDEToTime(result.TotalEnd, location),
PartialEnd: ttJDEToTime(result.PartialEnd, location),
PenumbralEnd: ttJDEToTime(result.PenumbralEnd, location),
GreatestJDE: result.Maximum,
DeltaTSeconds: basic.DeltaT(result.Maximum, true),
ContactPoints: lunarEclipseContactPointsFromBasic(result, location),
HasPenumbral: result.HasPenumbral,
HasPartial: result.HasPartial,
@@ -482,13 +490,13 @@ func ttJDEToTime(ttJDE float64, location *time.Location) time.Time {
if ttJDE == 0 || math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) {
return time.Time{}
}
utcJDE := basic.TD2UT(ttJDE, false)
return basic.JDE2DateByZone(utcJDE, location, false)
utcJD := basic.TT2UTC(ttJDE)
return basic.JD2DateByZone(utcJD, location, false)
}
func timeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
utcJD := basic.Date2JD(date.UTC())
return basic.UTC2TT(utcJD)
}
func normalizeDegree180(angle float64) float64 {
+124 -15
View File
@@ -16,6 +16,28 @@ const (
localLunarEclipseQueryGeometric
)
// LocalLunarEclipseVisibility 站点在本次月食中的可见性类别 / how much of the eclipse stays above the local horizon.
type LocalLunarEclipseVisibility string
const (
// LocalLunarEclipseInvisible 食内月亮始终在地平线下 / the Moon never clears the local horizon.
LocalLunarEclipseInvisible LocalLunarEclipseVisibility = "invisible"
// LocalLunarEclipseMoonrise 带食月出:食始时月亮尚未升起 / moonrise falls inside the eclipse.
LocalLunarEclipseMoonrise LocalLunarEclipseVisibility = "moonrise"
// LocalLunarEclipseMoonset 带食月落:食终时月亮已经落下 / moonset falls inside the eclipse.
LocalLunarEclipseMoonset LocalLunarEclipseVisibility = "moonset"
// LocalLunarEclipseRiseAndSet 食内先月出后月落:半影首尾都在地平线下 / both a moonrise and a moonset fall inside the eclipse.
LocalLunarEclipseRiseAndSet LocalLunarEclipseVisibility = "rise-and-set"
// LocalLunarEclipseInterrupted 半影首尾可见,中途却落到地平线下 / above the horizon at both penumbral contacts but not in between.
LocalLunarEclipseInterrupted LocalLunarEclipseVisibility = "interrupted"
// LocalLunarEclipseFull 半影全程可见 / the Moon stays above the horizon for the whole eclipse.
LocalLunarEclipseFull LocalLunarEclipseVisibility = "full"
// LocalLunarEclipsePenumbraMoonrise 仅见半影的月出:月出落在食内,本影阶段整段在地平线下 / moonrise inside the eclipse with the whole umbral phase below the horizon.
LocalLunarEclipsePenumbraMoonrise LocalLunarEclipseVisibility = "penumbra-moonrise"
// LocalLunarEclipsePenumbraMoonset 仅见半影的月落:月落落在食内,本影阶段整段在地平线下 / moonset inside the eclipse with the whole umbral phase below the horizon.
LocalLunarEclipsePenumbraMoonset LocalLunarEclipseVisibility = "penumbra-moonset"
)
// LocalLunarEclipseInfo 站点月食信息, local lunar eclipse information.
//
// 所有时刻字段都保持用户输入的时区。
@@ -33,7 +55,7 @@ type LocalLunarEclipseInfo struct {
Longitude float64
// Latitude 观测点纬度,北正南负, observer latitude, north positive.
Latitude float64
// Height 观测点海拔高度,单位米, observer height in meters.
// Height 观测点椭球高(大地高),单位米;只有正高 H 时须先加大地水准面差距 N, observer ellipsoidal height in meters.
Height float64
// PenumbralMagnitude 半影食分, penumbral magnitude.
@@ -62,6 +84,11 @@ type LocalLunarEclipseInfo struct {
MoonAzimuth float64
// VisibleAtMaximum 食甚时月亮中心在本地几何地平线上方, Moon center above the local geometric horizon at maximum.
VisibleAtMaximum bool
// Visibility 本次月食在站点的可见性类别;月出/月落落在食内且本影阶段整段在地平线下时取
// penumbra-moonrise / penumbra-moonset(只看到半影)/ how much of the eclipse is above the local
// horizon; of the umbral phase stays below it, moonrise and moonset are reported as
// penumbra-moonrise and penumbra-moonset.
Visibility LocalLunarEclipseVisibility
// HasPenumbral 有半影阶段, has penumbral phase.
HasPenumbral bool
@@ -386,7 +413,7 @@ func localLunarEclipseInfoFromBasic(
moonAltitude := lunarAltitude(maximum, lon, lat)
saros, hasSaros := lunarSarosInfo(result.Maximum)
return LocalLunarEclipseInfo{
info := LocalLunarEclipseInfo{
HasSaros: hasSaros,
Saros: saros,
Type: mapBasicLunarEclipseType(result.Type),
@@ -409,6 +436,8 @@ func localLunarEclipseInfoFromBasic(
HasPartial: result.HasPartial,
HasTotal: result.HasTotal,
}
info.Visibility = localLunarEclipseVisibility(info)
return info
}
func localLunarEclipseOverlapsDate(info LocalLunarEclipseInfo, dayStart, dayEnd time.Time) bool {
@@ -454,23 +483,103 @@ func localLunarEclipseVisibleOnDate(info LocalLunarEclipseInfo, dayStart, dayEnd
return localLunarEclipseVisibleDuring(info, segmentStart, segmentEnd)
}
// localLunarEclipseVisibility 按站点自己的中天对食内高度分类。
func localLunarEclipseVisibility(info LocalLunarEclipseInfo) LocalLunarEclipseVisibility {
start, end, ok := localLunarEclipseRange(info)
if !ok {
return LocalLunarEclipseInvisible
}
startVisible := localLunarEclipseAltitudeVisible(start, info)
endVisible := localLunarEclipseAltitudeVisible(end, info)
umbralVisible := localLunarEclipseUmbralVisible(info)
switch {
case startVisible && endVisible:
if localLunarEclipseExtremumAltitude(info, start, end, false) > localLunarEclipseVisibilityThreshold(info.Height, info.Latitude) {
return LocalLunarEclipseFull
}
return LocalLunarEclipseInterrupted
case endVisible:
if !umbralVisible {
return LocalLunarEclipsePenumbraMoonrise
}
return LocalLunarEclipseMoonrise
case startVisible:
if !umbralVisible {
return LocalLunarEclipsePenumbraMoonset
}
return LocalLunarEclipseMoonset
case localLunarEclipseVisibleDuring(info, start, end):
return LocalLunarEclipseRiseAndSet
default:
return LocalLunarEclipseInvisible
}
}
// localLunarEclipseUmbralVisible 本影阶段是否有任何时刻月亮在地平线上;纯半影月食没有本影可看,
// 恒为 true,不把这类站点降级成"仅见半影"。极区掠射时 U1、食甚、U4 三点都可能在地平下而中途
// 短暂露出,所以这里取整个本影区间的高度极值,不能只比三个采样点。
func localLunarEclipseUmbralVisible(info LocalLunarEclipseInfo) bool {
if !info.HasPartial {
return true
}
start, end := info.PartialStart, info.PartialEnd
if start.IsZero() || end.IsZero() || !start.Before(end) {
return false
}
if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) {
return true
}
return localLunarEclipseExtremumAltitude(info, start, end, true) >
localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
const lunarEclipseVisibilityScanSamples = 48
// localLunarEclipseExtremumAltitude 先粗扫定位、再在相邻两格内三分细化地取有限窗口内的月心高度极值。
func localLunarEclipseExtremumAltitude(info LocalLunarEclipseInfo, start, end time.Time, maximum bool) float64 {
if !start.Before(end) {
return lunarAltitude(start, info.Longitude, info.Latitude)
}
step := end.Sub(start) / lunarEclipseVisibilityScanSamples
best := lunarAltitude(start, info.Longitude, info.Latitude)
bestIndex := 0
for index := 1; index <= lunarEclipseVisibilityScanSamples; index++ {
value := lunarAltitude(start.Add(step*time.Duration(index)), info.Longitude, info.Latitude)
if (value > best) == maximum {
best, bestIndex = value, index
}
}
lowIndex, highIndex := bestIndex-1, bestIndex+1
if lowIndex < 0 {
lowIndex = 0
}
if highIndex > lunarEclipseVisibilityScanSamples {
highIndex = lunarEclipseVisibilityScanSamples
}
low := start.Add(step * time.Duration(lowIndex))
high := start.Add(step * time.Duration(highIndex))
for iteration := 0; iteration < 30; iteration++ {
third := high.Sub(low) / 3
first, second := low.Add(third), high.Add(-third)
if (lunarAltitude(first, info.Longitude, info.Latitude) < lunarAltitude(second, info.Longitude, info.Latitude)) == maximum {
low = first
} else {
high = second
}
}
refined := lunarAltitude(low.Add(high.Sub(low)/2), info.Longitude, info.Latitude)
if maximum {
return math.Max(best, refined)
}
return math.Min(best, refined)
}
func localLunarEclipseVisibleDuring(info LocalLunarEclipseInfo, start, end time.Time) bool {
if localLunarEclipseAltitudeVisible(start, info) || localLunarEclipseAltitudeVisible(end, info) {
return true
}
for dayStart, _, _ := lunarEclipseLocalDayBounds(start); !dayStart.After(end); dayStart = nextLunarEclipseLocalDayStart(dayStart) {
_, culminationSeed, _ := lunarEclipseLocalDayBounds(dayStart)
culmination := lunarCulminationTime(culminationSeed, info.Longitude, info.Latitude)
if culmination.Before(start) || culmination.After(end) {
continue
}
if localLunarEclipseAltitudeVisible(culmination, info) {
return true
}
}
return false
return localLunarEclipseExtremumAltitude(info, start, end, true) >
localLunarEclipseVisibilityThreshold(info.Height, info.Latitude)
}
func localLunarEclipseAltitudeVisible(date time.Time, info LocalLunarEclipseInfo) bool {
+111 -2
View File
@@ -234,8 +234,8 @@ func TestLocalLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
defaultInfo := ClosestLocalLunarEclipse(date, lon, lat, height)
chauvenetInfo := ClosestLocalLunarEclipseChauvenet(date, lon, lat, height)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
@@ -341,6 +341,65 @@ func TestLocalPenumbralLunarEclipseKeepsNegativeUmbralMagnitude(t *testing.T) {
}
}
// TestLocalLunarEclipseVisibilityClass 固定站点自己的食内可见性八分类;rise-and-set 与 interrupted
// 是静态地平圈几何推不出来的,penumbra-moonrise / penumbra-moonset 是本影整段在地平线下的细分。
func TestLocalLunarEclipseVisibilityClass(t *testing.T) {
date := time.Date(2029, 1, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
previousDay := time.Date(2028, 12, 31, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
testCases := []struct {
name string
date time.Time
lon float64
lat float64
want LocalLunarEclipseVisibility
}{
{name: "mid latitude stays up", lon: 107.25, lat: 30.25, want: LocalLunarEclipseFull},
{name: "moonset inside eclipse", lon: -161.25, lat: 3.25, want: LocalLunarEclipseMoonset},
{name: "moonrise inside eclipse", lon: 20.25, lat: -2.75, want: LocalLunarEclipseMoonrise},
{name: "south polar lens", lon: 108.75, lat: -62.75, want: LocalLunarEclipseRiseAndSet},
{name: "reported station", lon: 108.729001, lat: -59.937452, want: LocalLunarEclipseRiseAndSet},
{name: "arctic midday dip", lon: -71.25, lat: 64.75, want: LocalLunarEclipseInterrupted},
{name: "far side never up", lon: -178.5, lat: -86.5, want: LocalLunarEclipseInvisible},
// 只看到半影:本影阶段整段在地平线下,月出/月落仍落在食内。
{name: "penumbra only moonrise", lon: -69.25, lat: 61.25, want: LocalLunarEclipsePenumbraMoonrise},
{name: "penumbra only moonset", date: previousDay, lon: -179.25, lat: -61.25, want: LocalLunarEclipsePenumbraMoonset},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
at := testCase.date
if at.IsZero() {
at = date
}
geometric, geometricOK := GeometricLocalLunarEclipseOnDate(at, testCase.lon, testCase.lat, 0)
if !geometricOK {
t.Fatal("expected a geometric lunar eclipse on the local date")
}
if geometric.Visibility != testCase.want {
t.Fatalf("visibility=%s want %s", geometric.Visibility, testCase.want)
}
_, visibleOK := LocalLunarEclipseOnDate(at, testCase.lon, testCase.lat, 0)
if wantVisible := testCase.want != LocalLunarEclipseInvisible; visibleOK != wantVisible {
t.Fatalf("visible filter ok=%v want %v", visibleOK, wantVisible)
}
})
}
}
func TestLocalLunarEclipsePolarWindowScansBetweenContacts(t *testing.T) {
date := time.Date(1800, 4, 9, 0, 0, 0, 0, time.UTC)
info, ok := GeometricLocalLunarEclipseOnDate(date, 121, 82, 0)
if !ok {
t.Fatal("expected a geometric lunar eclipse")
}
if info.Visibility != LocalLunarEclipseRiseAndSet {
t.Fatalf("visibility=%s, want %s", info.Visibility, LocalLunarEclipseRiseAndSet)
}
if _, ok := LocalLunarEclipseOnDate(date, 121, 82, 0); !ok {
t.Fatal("the short above-horizon interval must make the eclipse visible")
}
}
func assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunarEclipseInfo, tolerance time.Duration) {
t.Helper()
if got.Type != want.Type {
@@ -348,3 +407,53 @@ func assertSameLocalLunarEclipse(t *testing.T, name string, got, want LocalLunar
}
assertTimeClose(t, name+".Maximum", got.Maximum, want.Maximum, tolerance)
}
// 纯半影月食没有本影接触,不把任何站点降级成 penumbra-moonrise / penumbra-moonset。
func TestLocalLunarEclipsePenumbralOnlyKeepsOldClasses(t *testing.T) {
date := time.Date(2020, 1, 11, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
info, ok := LunarEclipseOnDate(date)
if !ok || info.HasPartial {
t.Fatalf("expected a purely penumbral eclipse, HasPartial=%v ok=%v", info.HasPartial, ok)
}
seen := map[LocalLunarEclipseVisibility]int{}
for longitude := -179.5; longitude < 180; longitude += 5 {
for latitude := -89.5; latitude < 90; latitude += 5 {
local, found := GeometricLocalLunarEclipseOnDate(date, longitude, latitude, 0)
if !found {
continue
}
seen[local.Visibility]++
}
}
if seen[LocalLunarEclipsePenumbraMoonrise] != 0 || seen[LocalLunarEclipsePenumbraMoonset] != 0 {
t.Fatalf("purely penumbral eclipse produced penumbra-only classes: %v", seen)
}
if seen[LocalLunarEclipseMoonrise] == 0 || seen[LocalLunarEclipseMoonset] == 0 {
t.Fatalf("purely penumbral eclipse lost its moonrise/moonset sites: %v", seen)
}
}
// 极区掠射:U1、食甚、U4 三点都可能在地平下,但本影区间中途仍有短暂窗口高于地平;这类站点确实
// 见到本影,必须留在 moonrise/moonset,只有本影区间极值也在地平下才算"仅见半影"。
func TestLocalLunarEclipseKeepsUmbralGrazingWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
for _, witness := range []struct {
name string
lon, lat float64
want LocalLunarEclipseVisibility
}{
{"本影窗口 +0.0006 度", -91.8, -88.7675, LocalLunarEclipseMoonrise},
{"U1 已在地平上", -91.75, -88.75, LocalLunarEclipseMoonrise},
{"U1 高度 +0.21 度(陡边界)", -75.5, 6.5, LocalLunarEclipseMoonset},
{"本影区间极值 -0.005 度", -90.0, -88.65, LocalLunarEclipsePenumbraMoonset},
} {
local, ok := GeometricLocalLunarEclipseOnDate(date, witness.lon, witness.lat, 0)
if !ok {
t.Fatalf("%s: 本影几何月食缺失", witness.name)
}
if local.Visibility != witness.want {
t.Fatalf("%s (%.4f,%.4f) visibility=%s want %s",
witness.name, witness.lon, witness.lat, local.Visibility, witness.want)
}
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ func lunarEclipseGeocentricPanelAt(date time.Time, calculator func(float64) basi
}
panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(result.Maximum)
panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(result.Maximum)
panel.SunSemidiameterArcsec = basic.SunSemidiameter(result.Maximum)
panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameter(result.Maximum, basic.SolarEclipseSunRadiusStandard)
panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(result.Maximum)
panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio)
panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio)
+2 -2
View File
@@ -205,8 +205,8 @@ func TestLunarEclipseChauvenetRemainsAvailable(t *testing.T) {
defaultInfo := ClosestLunarEclipse(date)
chauvenetInfo := ClosestLunarEclipseChauvenet(date)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285431290, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182811712, 1e-6)
assertFloatClose(t, "Chauvenet.PenumbralMagnitude", chauvenetInfo.PenumbralMagnitude, 2.285436461, 1e-6)
assertFloatClose(t, "Chauvenet.UmbralMagnitude", chauvenetInfo.UmbralMagnitude, 1.182806541, 1e-6)
if !(chauvenetInfo.PenumbralMagnitude > defaultInfo.PenumbralMagnitude) {
t.Fatalf("expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f", chauvenetInfo.PenumbralMagnitude, defaultInfo.PenumbralMagnitude)
+10 -33
View File
@@ -8,57 +8,34 @@ import (
)
func moonSunLoDiff(date time.Time) float64 {
jde := basic.TD2UT(basic.Date2JDE(date.UTC()), true)
jde := basic.UTC2TT(basic.Date2JD(date.UTC()))
sunLo := basic.HSunApparentLo(jde)
moonLo := basic.HMoonApparentLo(jde)
return tools.Limit360(moonLo - sunLo)
}
func solarAltitude(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.SunHeight(jde, lon, lat, float64(loc)/3600.0)
return basic.SunHeight(localJD, lon, lat, float64(loc)/3600.0)
}
func solarCulminationTime(date time.Time, lon float64) time.Time {
jde := basic.Date2JDE(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5
localJD := basic.Date2JD(date.Add(time.Duration(-1*date.Hour())*time.Hour)) + 0.5
_, loc := date.Zone()
timezone := float64(loc) / 3600.0
calcJde := basic.CulminationTime(jde, lon, timezone) - timezone/24.0
return basic.JDE2DateByZone(calcJde, date.Location(), false)
calcJD := basic.CulminationTime(localJD, lon, timezone) - timezone/24.0
return basic.JD2DateByZone(calcJD, date.Location(), false)
}
func lunarAzimuth(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonAzimuth(jde, lon, lat, float64(loc)/3600.0)
return basic.HMoonAzimuth(localJD, lon, lat, float64(loc)/3600.0)
}
func lunarAltitude(date time.Time, lon, lat float64) float64 {
jde := basic.Date2JDE(date)
localJD := basic.Date2JD(date)
_, loc := date.Zone()
return basic.HMoonHeight(jde, lon, lat, float64(loc)/3600.0)
}
func lunarCulminationTime(date time.Time, lon, lat float64) time.Time {
if date.Hour() > 12 {
date = date.Add(-12 * time.Hour)
}
jde := basic.Date2JDE(date)
_, loc := date.Zone()
culmination := basic.JDE2DateByZone(basic.MoonCulminationTime(jde, lon, lat, float64(loc)/3600.0), date.Location(), true)
// MoonCulminationTime returns the nearest root to its internal midnight
// seed, which can land one civil day later when the input is local noon.
// Visibility scans need the root belonging to the supplied local day.
if culmination.Year() != date.Year() || culmination.YearDay() != date.YearDay() {
// Preserve the solved local clock time while moving it onto the
// requested civil date. This also avoids assuming every DST day is
// exactly 24 elapsed hours.
culmination = time.Date(
date.Year(), date.Month(), date.Day(),
culmination.Hour(), culmination.Minute(), culmination.Second(), culmination.Nanosecond(),
date.Location(),
)
}
return culmination
return basic.HMoonHeight(localJD, lon, lat, float64(loc)/3600.0)
}
+47
View File
@@ -0,0 +1,47 @@
package eclipse
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
// 三个 info 都带 provenance:GreatestJDE(力学时)经 TT2UTC 复现民用时刻字段;
// DeltaTSeconds 是 TT−UT1,不能用来做这次换算(会差一个 DUT1)。
func TestEclipseInfoCarriesProvenance(t *testing.T) {
date := time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC)
solar, ok := SolarEclipseOnDate(date)
if !ok {
t.Fatal("缺少 2024-04-08 日食")
}
if solar.GreatestJDE == 0 || solar.DeltaTSeconds == 0 {
t.Fatalf("日食 provenance 未填充: JDE=%v ΔT=%v", solar.GreatestJDE, solar.DeltaTSeconds)
}
if got := solarEclipseTTJDEToTime(solar.GreatestJDE, solar.GreatestEclipse.Location()); !got.Equal(solar.GreatestEclipse) {
t.Errorf("GreatestJDE 未经 TT2UTC 复现食甚时刻: %v vs %v", got, solar.GreatestEclipse)
}
// DUT1 非零时,用 ΔT 换算出来的 UT1 时刻必须与民用时刻不同:两者的差就是 DUT1。
ut1 := basic.JD2DateByZone(solar.GreatestJDE-solar.DeltaTSeconds/86400, solar.GreatestEclipse.Location(), false)
dut1 := (basic.TT2UTC(solar.GreatestJDE)-solar.GreatestJDE)*86400 - solar.DeltaTSeconds
if math.Abs(dut1) > 1e-3 && ut1.Equal(solar.GreatestEclipse) {
t.Errorf("民用时刻字段走了 ΔT(TT−UT1)换算: %v vs %v", ut1, solar.GreatestEclipse)
}
local := ClosestLocalSolarEclipse(date, -96.8, 32.8, 0)
if local.GreatestJDE == 0 || local.DeltaTSeconds == 0 {
t.Fatalf("站心日食 provenance 未填充: JDE=%v ΔT=%v", local.GreatestJDE, local.DeltaTSeconds)
}
if got := solarEclipseTTJDEToTime(local.GreatestJDE, local.GreatestEclipse.Location()); !got.Equal(local.GreatestEclipse) {
t.Errorf("GreatestJDE+ΔT 未复现站心食甚: %v vs %v", got, local.GreatestEclipse)
}
lunar, ok := LunarEclipseOnDate(time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC))
if !ok {
t.Fatal("缺少 2025-09-07 月食")
}
if lunar.GreatestJDE == 0 || lunar.DeltaTSeconds == 0 {
t.Fatalf("月食 provenance 未填充: JDE=%v ΔT=%v", lunar.GreatestJDE, lunar.DeltaTSeconds)
}
}
+3 -3
View File
@@ -83,14 +83,14 @@ func buildSarosAnchorRanges() {
ranges := make(map[int]sarosAnchorRange, len(anchors)+len(overrides))
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, base+index)
headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
headTT := basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
if math.IsNaN(headTT) {
continue
}
ranges[int(anchor.Series)] = sarosAnchorRange{headTT: headTT, count: int(anchor.Count)}
}
for _, override := range overrides {
headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
if math.IsNaN(headTT) {
continue
}
@@ -246,7 +246,7 @@ func matchSarosMagicOverrides(overrides []sarosHeadOverride, ttJDE float64) (Sar
}
func matchSarosMagicCandidate(ttJDE float64, anchor sarosAnchor, memberOffset int) (SarosInfo, float64, bool) {
headTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
headTT := basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
if math.IsNaN(headTT) {
return SarosInfo{}, 0, false
}
+3 -3
View File
@@ -42,7 +42,7 @@ func sarosTestWindows(t *testing.T, phase int) map[int]sarosTestWindow {
windows := make(map[int]sarosTestWindow, len(anchors)+len(overrides))
for index, magic := range anchors {
value := uint32(magic)
headTT := basic.JDECalc(
headTT := basic.JDCalc(
int((value>>sarosTestAnchorYearShift)&sarosTestAnchorYearMask)-sarosTestAnchorYearOffset,
int((value>>sarosTestAnchorMonthShift)&sarosTestAnchorMonthMask),
float64((value>>sarosTestAnchorDayShift)&sarosTestAnchorDayMask),
@@ -56,7 +56,7 @@ func sarosTestWindows(t *testing.T, phase int) map[int]sarosTestWindow {
}
}
for _, override := range overrides {
headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
if math.IsNaN(headTT) {
t.Fatalf("invalid override head for series %d", override.Series)
}
@@ -214,7 +214,7 @@ func TestSarosOverrideWindowsCoverDeclaredMemberRange(t *testing.T) {
}
cycleDays := float64(sarosTestAnchorMemberInterval) * sarosTestSynodicMonthDays
for _, override := range overrides {
headTT := basic.JDECalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
headTT := basic.JDCalc(int(override.HeadYear), int(override.HeadMonth), float64(override.HeadDay))
for _, member := range []int{1, int(override.Count)} {
ttJDE := headTT + float64(member-1-int(override.MemberOffset))*cycleDays
info, ok := sarosTestInfo(ttJDE, phase)
+1 -1
View File
@@ -85,7 +85,7 @@ func sarosNumber(k, phase int) int {
anchors, base = lunarSarosAnchors[:], 1
}
anchor := decodeSarosMagic(anchors[len(anchors)/2], base+len(anchors)/2)
refTT := basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
refTT := basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day))
refK, _ := sarosLunation(refTT, phase)
delta := k - refK
column := (38 * delta) % sarosCycleLunations
+2 -1
View File
@@ -13,7 +13,7 @@ func TestSarosNumberAgainstAllCatalogAnchors(t *testing.T) {
for phase, anchors := range [][]sarosMagic{solarSarosAnchors[:], lunarSarosAnchors[:]} {
for index, magic := range anchors {
anchor := decodeSarosMagic(magic, phase+index)
k, ok := sarosLunation(basic.JDECalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)), phase)
k, ok := sarosLunation(basic.JDCalc(int(anchor.Year), int(anchor.Month), float64(anchor.Day)), phase)
if !ok {
t.Fatalf("invalid catalog anchor: %+v", anchor)
}
@@ -133,6 +133,7 @@ func TestExtendedSarosMatchesLiveSeries(t *testing.T) {
}
func TestExtendedSarosRangeAndInvalidInput(t *testing.T) {
pinSarosWindowPolicy(t)
if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) || sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatal("precomputed range must include both end years")
}
+14
View File
@@ -9,6 +9,8 @@ import (
"sort"
"testing"
"time"
"b612.me/astro/basic"
)
// 默认只校验发布表的新鲜度与金标准;重新生成必须同时给出 -saros-generate 与环境变量。
@@ -20,6 +22,16 @@ var generateSarosTables = flag.Bool("saros-generate", false, "regenerate the ext
const sarosGenerateEnv = "ASTRO_REGENERATE_SAROS_TABLE"
// 扩展表窗口按冻结偏移换算,不随默认政策变化。
const sarosWindowPolicy = basic.TimeScaleFreezeUTCOffset
func pinSarosWindowPolicy(t *testing.T) {
t.Helper()
previous := basic.GetTimeScaleFuturePolicy()
basic.SetTimeScaleFuturePolicy(sarosWindowPolicy)
t.Cleanup(func() { basic.SetTimeScaleFuturePolicy(previous) })
}
func sarosTableGenerationEnabled() bool {
return *generateSarosTables && os.Getenv(sarosGenerateEnv) == "1"
}
@@ -41,6 +53,7 @@ var sarosTableGoldens = []sarosTableGolden{
// 发布表必须与文档化的窗口、生成时的序列总数和若干金标准行一致。
func TestSarosExtensionTableIsFresh(t *testing.T) {
pinSarosWindowPolicy(t)
if sarosExtendedStartTT != timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC)) ||
sarosExtendedEndTT != timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatal("extended table window does not cover -3000..+6000")
@@ -110,6 +123,7 @@ func TestGenerateSarosExtensionTables(t *testing.T) {
if !sarosTableGenerationEnabled() {
t.Skipf("set %s=1 and pass -saros-generate to rewrite saros_table_extended.go", sarosGenerateEnv)
}
pinSarosWindowPolicy(t)
startTT := timeToTTJDE(time.Date(-3000, 1, 1, 0, 0, 0, 0, time.UTC))
endTT := timeToTTJDE(time.Date(6001, 1, 1, 0, 0, 0, 0, time.UTC))
var buf bytes.Buffer
+2 -2
View File
@@ -5,8 +5,8 @@ package eclipse
// UTC astronomical years -3000 through +6000, including both end years.
// Series numbers follow NASA's Saros-Inex rules. Members are computed with
// Split-K solar geometry and the union of Danjon/Chauvenet lunar detections.
const sarosExtendedStartTT = 625308.343822260154
const sarosExtendedEndTT = 3912881.135478473268
const sarosExtendedStartTT = 625308.343824885320
const sarosExtendedEndTT = 3912880.500800740905
var solarSarosExtended = [...]sarosSpan{
{Series: -47, First: -79891, Last: -61605, Member: 1, Count: 83},
+2 -2
View File
@@ -59,8 +59,8 @@ func TestEclipseSearchStepDoesNotSkipPotentialCandidates(t *testing.T) {
}
func eclipseSearchTestCandidates(startYear, years, phaseType int, synodicMonthDays float64) []float64 {
startTT := basic.Date2JDE(time.Date(startYear, 1, 1, 0, 0, 0, 0, time.UTC))
endTT := basic.Date2JDE(time.Date(startYear+years, 1, 1, 0, 0, 0, 0, time.UTC))
startTT := basic.Date2JD(time.Date(startYear, 1, 1, 0, 0, 0, 0, time.UTC))
endTT := basic.Date2JD(time.Date(startYear+years, 1, 1, 0, 0, 0, 0, time.UTC))
candidateTT := basic.CalcMoonSHByJDE(startTT, phaseType)
candidates := make([]float64, 0, years*13)
for candidateTT < endTT {
+82 -16
View File
@@ -26,6 +26,24 @@ const (
SolarEclipseModelNASABulletinSplitK SolarEclipseRadiusModel = "nasa_bulletin_split_k"
)
// SolarEclipseSunRadiusModel 日食太阳半径口径, solar radius convention for solar eclipses.
type SolarEclipseSunRadiusModel = basic.SolarEclipseSunRadiusModel
const (
// SolarEclipseSunRadiusStandard 标准档,1 AU 处 959.639″, standard solar radius for catalogue reproduction.
SolarEclipseSunRadiusStandard = basic.SolarEclipseSunRadiusStandard
// SolarEclipseSunRadiusMeasured 边缘档,1 AU 处 959.95″, measured eclipse solar radius.
SolarEclipseSunRadiusMeasured = basic.SolarEclipseSunRadiusMeasured
)
// SolarEclipseOptions 日食搜索的半径口径, radius conventions for a solar eclipse search.
type SolarEclipseOptions struct {
// RadiusModel 月亮半径模型,非 IAU Single-K 一律按 NASA bulletin Split-K, lunar radius model.
RadiusModel SolarEclipseRadiusModel
// SunRadiusModel 太阳半径口径,零值为标准档, solar radius convention.
SunRadiusModel SolarEclipseSunRadiusModel
}
// SolarEclipseType 全局日食食型, global solar eclipse type.
type SolarEclipseType string
@@ -61,6 +79,8 @@ const (
type SolarEclipseInfo struct {
// Model 日食月亮半径模型, eclipse lunar radius model.
Model SolarEclipseRadiusModel
// SunRadiusModel 日食太阳半径口径, solar radius convention.
SunRadiusModel SolarEclipseSunRadiusModel
// Type 全局食型, global eclipse type.
Type SolarEclipseType
// Centrality 中心性, eclipse centrality.
@@ -81,6 +101,12 @@ type SolarEclipseInfo struct {
CentralBeginOnEarth time.Time
// CentralEndOnEarth 地球范围中心食终, central eclipse ends on Earth.
CentralEndOnEarth time.Time
// GreatestJDE 是食甚的力学时儒略日;与 DeltaTSeconds 配对即可复现上列民用时刻。
// GreatestJDE is the TT Julian ephemeris day of greatest eclipse; with DeltaTSeconds it reproduces the civil times above.
GreatestJDE float64
// DeltaTSeconds 是本次计算实际使用的 TT−UT1,单位秒。
// DeltaTSeconds is the TT−UT1 used by this computation, in seconds.
DeltaTSeconds float64
// Magnitude 全局食分, global eclipse magnitude.
Magnitude float64
@@ -92,8 +118,17 @@ type SolarEclipseInfo struct {
// value catalogues publish; SolarEclipsePath.MaxCentralDuration is the maximum
// along the whole track.
CentralDuration time.Duration
// PathWidthKM 食甚处中心食带宽度, central path width at greatest eclipse.
// PathWidthKM 是食甚处中心食带宽度;非中心食为 0;单侧极限(中心带仅触及地球边缘)时该解析式失效
// 并一并置 0,此时 PathWidthDefined 为 false,NASA 目录该栏印 '-'。
// PathWidthKM is the central path width at greatest eclipse, 0 for a non-central
// event, and 0 when the analytic formula fails at a single-sided limit where the
// band only grazes the Earth's limb; PathWidthDefined is false there and
// catalogues print '-' for this column.
PathWidthKM float64
// PathWidthDefined 表示上面的带宽是否有定义:只有南北两限都存在时才为 true。
// PathWidthDefined reports whether the width above is defined: it is true only
// when both band limits exist.
PathWidthDefined bool
// GreatestLongitude 食甚点经度,东正西负, longitude of greatest eclipse, east positive.
GreatestLongitude float64
@@ -130,6 +165,43 @@ func SolarEclipseOnDateIAUSingleK(date time.Time) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, basic.SolarEclipseIAUSingleK)
}
// SolarEclipseOnDateWithOptions 当地自然日全局日食查询(自定义半径口径) / local-date global solar eclipse query with custom radius conventions.
func SolarEclipseOnDateWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseInfo, bool) {
return solarEclipseOnDate(date, solarEclipseCalculatorFor(options))
}
// LastSolarEclipseWithOptions 上次日食(自定义半径口径) / previous solar eclipse with custom radius conventions.
func LastSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options))
return info
}
// NextSolarEclipseWithOptions 下次日食(自定义半径口径) / next solar eclipse with custom radius conventions.
func NextSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
info, _ := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options))
return info
}
// ClosestSolarEclipseWithOptions 最近一次日食(自定义半径口径) / closest solar eclipse with custom radius conventions.
func ClosestSolarEclipseWithOptions(date time.Time, options SolarEclipseOptions) SolarEclipseInfo {
last, hasLast := searchSolarEclipse(date, -1, true, solarEclipseCalculatorFor(options))
next, hasNext := searchSolarEclipse(date, 1, false, solarEclipseCalculatorFor(options))
return closestSolarEclipse(date, last, hasLast, next, hasNext)
}
func solarEclipseCalculatorFor(options SolarEclipseOptions) solarEclipseCalculator {
basicOptions := basic.SolarEclipseOptions{
RadiusModel: basic.SolarEclipseRadiusModel(options.RadiusModel),
SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel),
}
if basicOptions.RadiusModel != basic.SolarEclipseModelIAUSingleK {
basicOptions.RadiusModel = basic.SolarEclipseModelNASABulletinSplitK
}
return func(seed float64) basic.SolarEclipseResult {
return basic.SolarEclipseWithOptions(seed, basicOptions)
}
}
func solarEclipseOnDate(date time.Time, calculator solarEclipseCalculator) (SolarEclipseInfo, bool) {
location := date.Location()
dayStart, dayMid, dayEnd := solarEclipseLocalDayBounds(date)
@@ -287,6 +359,7 @@ func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.L
saros, hasSaros := solarSarosInfo(result.GreatestEclipse)
return SolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
SunRadiusModel: result.SunRadiusModel,
Type: mapBasicSolarEclipseType(result.Type),
Centrality: mapBasicSolarEclipseCentrality(result.Centrality),
HasSaros: hasSaros,
@@ -296,10 +369,13 @@ func solarEclipseInfoFromBasic(result basic.SolarEclipseResult, location *time.L
PartialEndOnEarth: solarEclipseTTJDEToTime(result.PartialEndOnEarth, location),
CentralBeginOnEarth: solarEclipseTTJDEToTime(result.CentralBeginOnEarth, location),
CentralEndOnEarth: solarEclipseTTJDEToTime(result.CentralEndOnEarth, location),
GreatestJDE: result.GreatestEclipse,
DeltaTSeconds: basic.DeltaT(result.GreatestEclipse, true),
Magnitude: result.Magnitude,
Gamma: result.Gamma,
CentralDuration: solarEclipseDurationFromDays(result.CentralDurationDays),
PathWidthKM: result.PathWidthKM,
PathWidthDefined: result.PathWidthDefined,
GreatestLongitude: result.GreatestLongitude,
GreatestLatitude: result.GreatestLatitude,
HasPartial: result.HasPartial,
@@ -361,25 +437,15 @@ func solarEclipseRange(info SolarEclipseInfo) (time.Time, time.Time, bool) {
}
func solarEclipseTTJDEToTime(ttJDE float64, location *time.Location) time.Time {
return solarEclipseTTJDEToTimeWithDeltaT(ttJDE, basic.DeltaT(ttJDE, true), location)
}
// solarEclipseTTJDEToTimeWithDeltaT 用显式 ΔT 把 TT 换算为时刻:显式 ΔT 的单时刻入口
// 必须走这条路径,否则回填出来的 UT 会落到进程级模型上,与几何使用的 ΔT 不一致。
// solarEclipseTTJDEToTimeWithDeltaT converts TT with an explicit ΔT; the single-instant
// entries must use it so the reported UT matches the ΔT their geometry used.
func solarEclipseTTJDEToTimeWithDeltaT(
ttJDE, deltaTSeconds float64, location *time.Location,
) time.Time {
// 与 lunar 侧一致:0 与 NaN/±Inf 都表示“该时刻不存在”。
// 回填的是民用时刻,偏移必须与正向的 UTC2TT 成对(窗口内即闰秒表,窗口外按未来政策)。
// 用 ΔT(TT−UT1)会让时刻整体偏一个 DUT1,并破坏等时线网格的整刻度对齐。
if ttJDE == 0 || math.IsNaN(ttJDE) || math.IsInf(ttJDE, 0) {
return time.Time{}
}
utcJDE := ttJDE - deltaTSeconds/86400
return basic.JDE2DateByZone(utcJDE, location, false)
return basic.JD2DateByZone(basic.TT2UTC(ttJDE), location, false)
}
func solarEclipseTimeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
utcJD := basic.Date2JD(date.UTC())
return basic.UTC2TT(utcJD)
}
+105
View File
@@ -0,0 +1,105 @@
package eclipse
import "b612.me/astro/basic"
// SolarEclipseBesselianPolynomial 是三次多项式系数,索引 n 对应 t 的 n 次幂,t 为自 T0 起算的 TT 小时数。
// SolarEclipseBesselianPolynomial holds the cubic coefficients; index n multiplies t^n with t in TT hours from T0.
type SolarEclipseBesselianPolynomial = basic.SolarEclipseBesselianPolynomial
// SolarEclipseBesselianElementsOptions 是贝塞尔根数表的生成选项 / options for a Besselian element table.
type SolarEclipseBesselianElementsOptions struct {
// Model 月亮半径模型,零值为 NASA bulletin Split-K / lunar radius model.
Model SolarEclipseRadiusModel
// SunRadiusModel 太阳半径口径,零值为标准档 / solar radius convention.
SunRadiusModel SolarEclipseSunRadiusModel
// DeltaTSeconds 显式 ΔT(秒),<=0 用进程级模型,只改变地球自转相位 / explicit ΔT in seconds.
DeltaTSeconds float64
// ReferenceJDE 多项式参考时刻 T0(TT 儒略日),<=0 取离食甚最近的整 TT 小时 / TT reference instant, the whole hour nearest greatest eclipse.
ReferenceJDE float64
// ValidHours 有效窗口半径(小时),<=0 取 3 / validity window half-width in hours.
ValidHours float64
}
// SolarEclipseBesselianElementsResult 是一次日食的多项式贝塞尔根数及其口径 / polynomial Besselian elements and their conventions.
type SolarEclipseBesselianElementsResult struct {
// T0JDE 多项式参考时刻(TT 儒略日),t = (jde - T0JDE) * 24 / TT reference instant.
T0JDE float64
// ValidHours 有效窗口半径(小时) / validity window half-width.
ValidHours float64
// X 与 Y 是月心在基本面内的坐标,单位地球赤道半径 / fundamental-plane coordinates in equatorial Earth radii.
X, Y SolarEclipseBesselianPolynomial
// D 是影轴赤纬,单位度 / declination of the shadow axis in degrees.
D SolarEclipseBesselianPolynomial
// L1 与 L2 是基本面内的半影、本影半径,单位地球赤道半径;本影为负表示月心尚未越过本影锥顶点。
// L1 and L2 are the penumbral and umbral radii in the fundamental plane, in equatorial Earth radii.
L1, L2 SolarEclipseBesselianPolynomial
// Mu 是影轴格林时角,单位度,窗口内连续、不折回 [0,360)。
//
// 口径与已发布根数表不同:本库的恒星时取自 UT = TT - ΔT,得到真实格林时角;已发布表改用 T0 本身
// 的恒星时,两者相差 ΔT × 15.041067/3600 度。要对表先用 SolarEclipseBesselianMuForPublishedTable。
// Mu is the shadow axis' Greenwich hour angle in degrees, continuous and not folded into [0,360).
Mu SolarEclipseBesselianPolynomial
// TanF1 与 TanF2 是半影、本影锥半顶角正切,本次日食内为常数 / cone half-angle tangents, constant over the eclipse.
TanF1, TanF2 float64
// Gamma 是食甚时刻影轴到地心的距离,单位地球赤道半径 / shadow-axis distance from the Earth's centre.
Gamma float64
// Magnitude 是食甚时刻的全局食分 / global eclipse magnitude at greatest eclipse.
Magnitude float64
// Model、SunRadiusModel、PenumbralK、UmbralK 与 DeltaTSeconds 是决定上述数值的口径 / the conventions behind the numbers above.
Model SolarEclipseRadiusModel
SunRadiusModel SolarEclipseSunRadiusModel
PenumbralK float64
UmbralK float64
DeltaTSeconds float64
}
// SolarEclipseBesselianElements 计算给定近朔时刻附近一次日食的多项式贝塞尔根数,窗口内无日食时返回 false。
// Polynomial Besselian elements for the solar eclipse near the given new-moon instant.
func SolarEclipseBesselianElements(
seedJDE float64, options SolarEclipseBesselianElementsOptions,
) (SolarEclipseBesselianElementsResult, bool) {
result, ok := basic.SolarEclipseBesselianElements(seedJDE, basic.SolarEclipseBesselianElementsOptions{
Model: basic.SolarEclipseRadiusModel(options.Model),
SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel),
DeltaTSeconds: options.DeltaTSeconds,
ReferenceJDE: options.ReferenceJDE,
ValidHours: options.ValidHours,
})
if !ok {
return SolarEclipseBesselianElementsResult{}, false
}
return solarEclipseBesselianElementsFromBasic(result), true
}
// SolarEclipseBesselianMuForPublishedTable 把本库的 Mu 换算成与已发布根数表直接可比的取值。
// 已发布表用 T0 本身的恒星时,本库用 UT = TT - ΔT,两者只差一个常数,因此只有常数项平移。
// SolarEclipseBesselianMuForPublishedTable shifts Mu onto the argument used by published element tables.
func SolarEclipseBesselianMuForPublishedTable(
mu SolarEclipseBesselianPolynomial, deltaTSeconds float64,
) SolarEclipseBesselianPolynomial {
return basic.SolarEclipseBesselianMuForPublishedTable(mu, deltaTSeconds)
}
func solarEclipseBesselianElementsFromBasic(
result basic.SolarEclipseBesselianElementsResult,
) SolarEclipseBesselianElementsResult {
return SolarEclipseBesselianElementsResult{
T0JDE: result.T0JDE,
ValidHours: result.ValidHours,
X: result.X,
Y: result.Y,
D: result.D,
L1: result.L1,
L2: result.L2,
Mu: result.Mu,
TanF1: result.TanF1,
TanF2: result.TanF2,
Gamma: result.Gamma,
Magnitude: result.Magnitude,
Model: mapBasicSolarEclipseModel(result.Model),
SunRadiusModel: result.SunRadiusModel,
PenumbralK: result.PenumbralK,
UmbralK: result.UmbralK,
DeltaTSeconds: result.DeltaTSeconds,
}
}
+62
View File
@@ -0,0 +1,62 @@
package eclipse
import (
"math"
"testing"
"b612.me/astro/basic"
)
// 公开包装层与被包装的 basic 结果逐字段一致,模型枚举按 eclipse 层取值。
func TestSolarEclipseBesselianElementsWrapsBasic(t *testing.T) {
options := SolarEclipseBesselianElementsOptions{
DeltaTSeconds: 70.6,
ReferenceJDE: 2460409.25,
}
result, ok := SolarEclipseBesselianElements(2460409.262835, options)
if !ok {
t.Fatal("2024-04-08 should have a solar eclipse")
}
if result.Model != SolarEclipseModelNASABulletinSplitK {
t.Errorf("model = %q, want %q", result.Model, SolarEclipseModelNASABulletinSplitK)
}
if math.Abs(result.Gamma-0.3431) > 1e-4 || math.Abs(result.Magnitude-1.0566) > 1e-3 {
t.Errorf("gamma/magnitude = %g / %g", result.Gamma, result.Magnitude)
}
if math.Abs(result.TanF1-0.0046683) > 1e-6 || math.Abs(result.TanF2-0.0046450) > 1e-6 {
t.Errorf("tan f = %g / %g", result.TanF1, result.TanF2)
}
published := SolarEclipseBesselianMuForPublishedTable(result.Mu, result.DeltaTSeconds)
if math.Abs(published.At(0)-89.59122) > 1e-4 {
t.Errorf("published mu(0) = %g, want 89.59122", published.At(0))
}
if math.Abs(result.X.At(0)-(-0.318157)) > 2e-4 || math.Abs(result.Y.At(0)-0.219747) > 2e-4 {
t.Errorf("x/y at t0 = %g / %g", result.X.At(0), result.Y.At(0))
}
if result.PenumbralK != basic.SolarEclipsePenumbralK || result.UmbralK != basic.SolarEclipseUmbralK {
t.Errorf("k = %g / %g", result.PenumbralK, result.UmbralK)
}
}
func TestSolarEclipseBesselianElementsWithoutEclipse(t *testing.T) {
if _, ok := SolarEclipseBesselianElements(2460486.5, SolarEclipseBesselianElementsOptions{}); ok {
t.Fatal("this new moon has no solar eclipse")
}
}
func TestSolarEclipseBesselianElementsIAUSingleK(t *testing.T) {
result, ok := SolarEclipseBesselianElements(2460409.262835, SolarEclipseBesselianElementsOptions{
Model: SolarEclipseModelIAUSingleK,
DeltaTSeconds: 70.6,
})
if !ok {
t.Fatal("2024-04-08 should have a solar eclipse")
}
if result.Model != SolarEclipseModelIAUSingleK {
t.Errorf("model = %q, want %q", result.Model, SolarEclipseModelIAUSingleK)
}
if result.UmbralK != result.PenumbralK {
t.Errorf("k2 = %g, want k1 = %g", result.UmbralK, result.PenumbralK)
}
}
+36 -2
View File
@@ -52,6 +52,8 @@ type LocalSolarEclipseContactPoint struct {
type LocalSolarEclipseInfo struct {
// Model 日食月亮半径模型, eclipse lunar radius model.
Model SolarEclipseRadiusModel
// SunRadiusModel 日食太阳半径口径, solar radius convention.
SunRadiusModel SolarEclipseSunRadiusModel
// Type 站心食型, local eclipse type.
Type SolarEclipseType
// HasSaros 存在沙罗序列信息(可能是锚点外推结果), has Saros series metadata (possibly extrapolated).
@@ -64,7 +66,7 @@ type LocalSolarEclipseInfo struct {
Longitude float64
// Latitude 观测点纬度,北正南负, observer latitude, north positive.
Latitude float64
// Height 观测点海拔高度,单位米, observer height in meters.
// Height 观测点椭球高(大地高),单位米;只有正高 H 时须先加大地水准面差距 N, observer ellipsoidal height in meters.
Height float64
// GreatestEclipse 食甚时刻, greatest eclipse.
@@ -77,6 +79,12 @@ type LocalSolarEclipseInfo struct {
CentralStart time.Time
// CentralEnd 中心食终;对全食为生光,对环食为环食终, central eclipse ends.
CentralEnd time.Time
// GreatestJDE 是食甚的力学时儒略日;与 DeltaTSeconds 配对即可复现上列民用时刻。
// GreatestJDE is the TT Julian ephemeris day of greatest eclipse; with DeltaTSeconds it reproduces the civil times above.
GreatestJDE float64
// DeltaTSeconds 是本次计算实际使用的 TT−UT1,单位秒。
// DeltaTSeconds is the TT−UT1 used by this computation, in seconds.
DeltaTSeconds float64
// Magnitude 站心食分, local eclipse magnitude.
Magnitude float64
@@ -142,6 +150,29 @@ func GeometricLocalSolarEclipseOnDateIAUSingleK(date time.Time, lon, lat, height
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseIAUSingleK, localSolarEclipseQueryGeometric)
}
// LocalSolarEclipseOnDateWithOptions 当地站心日食查询(自定义半径口径) / local topocentric solar eclipse query with custom radius conventions.
func LocalSolarEclipseOnDateWithOptions(date time.Time, lon, lat, height float64, options SolarEclipseOptions) (LocalSolarEclipseInfo, bool) {
return localSolarEclipseOnDate(date, lon, lat, height, localSolarEclipseCalculatorFor(options), localSolarEclipseQueryVisible)
}
func localSolarEclipseCalculatorFor(options SolarEclipseOptions) localSolarEclipseCalculator {
basicOptions := basic.SolarEclipseOptions{
RadiusModel: basic.SolarEclipseRadiusModel(options.RadiusModel),
SunRadiusModel: basic.SolarEclipseSunRadiusModel(options.SunRadiusModel),
}
if basicOptions.RadiusModel != basic.SolarEclipseModelIAUSingleK {
basicOptions.RadiusModel = basic.SolarEclipseModelNASABulletinSplitK
}
return localSolarEclipseCalculator{
global: func(seed float64) basic.SolarEclipseResult {
return basic.SolarEclipseWithOptions(seed, basicOptions)
},
local: func(seed, lon, lat, height float64) basic.LocalSolarEclipseResult {
return basic.LocalSolarEclipseWithOptions(seed, lon, lat, height, basicOptions)
},
}
}
func localSolarEclipseOnDate(
date time.Time,
lon, lat, height float64,
@@ -470,6 +501,7 @@ func localSolarEclipseInfoFieldsFromBasic(
saros, hasSaros := solarSarosInfo(result.GreatestEclipse)
return LocalSolarEclipseInfo{
Model: mapBasicSolarEclipseModel(result.Model),
SunRadiusModel: result.SunRadiusModel,
Type: mapBasicSolarEclipseType(result.Type),
HasSaros: hasSaros,
Saros: saros,
@@ -481,6 +513,8 @@ func localSolarEclipseInfoFieldsFromBasic(
PartialEnd: solarEclipseTTJDEToTime(result.PartialEnd, location),
CentralStart: solarEclipseTTJDEToTime(result.CentralStart, location),
CentralEnd: solarEclipseTTJDEToTime(result.CentralEnd, location),
GreatestJDE: result.GreatestEclipse,
DeltaTSeconds: basic.DeltaT(result.GreatestEclipse, true),
Magnitude: result.Magnitude,
Obscuration: result.Obscuration,
Separation: result.Separation,
@@ -502,7 +536,7 @@ func localSolarEclipseContactPointsFromBasic(
if !result.HasPartial {
return nil
}
options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1}
options := basic.LocalSolarEclipseDiagramOptions{StepDays: 1, SunRadiusModel: result.SunRadiusModel}
var diagram basic.LocalSolarEclipseDiagramResult
if result.Model == basic.SolarEclipseModelIAUSingleK {
diagram = basic.LocalSolarEclipseDiagramIAUSingleK(result.GreatestEclipse, lon, lat, height, options)
@@ -0,0 +1,32 @@
package eclipse
import (
"testing"
"time"
)
// 站心偏食口径:IAU Single-K 的半影外半径必须用本模型的单一 k,与 Split-K 不可互换。
func TestLocalSolarEclipseOnDateCarriesModelPenumbralK(t *testing.T) {
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
iau, okIAU := LocalSolarEclipseOnDateIAUSingleK(date, -87.65, 41.85, 0)
split, okSplit := LocalSolarEclipseOnDateNASABulletinSplitK(date, -87.65, 41.85, 0)
if !okIAU || !okSplit {
t.Fatalf("local eclipse missing: %v/%v", okIAU, okSplit)
}
if iau.PartialStart.Equal(split.PartialStart) || iau.PartialEnd.Equal(split.PartialEnd) {
t.Fatalf("partial contacts coincide: %s/%s and %s/%s",
iau.PartialStart, split.PartialStart, iau.PartialEnd, split.PartialEnd)
}
if !(iau.PartialStart.Before(split.PartialStart) && iau.PartialEnd.After(split.PartialEnd)) {
t.Fatalf("IAU contacts %s/%s not wider than Split-K %s/%s",
iau.PartialStart, iau.PartialEnd, split.PartialStart, split.PartialEnd)
}
if !(iau.Magnitude > split.Magnitude && iau.Obscuration > split.Obscuration) {
t.Fatalf("IAU magnitude/obscuration %.9f/%.9f not above Split-K %.9f/%.9f",
iau.Magnitude, iau.Obscuration, split.Magnitude, split.Obscuration)
}
if iau.Model != SolarEclipseModelIAUSingleK || split.Model != SolarEclipseModelNASABulletinSplitK {
t.Fatalf("radius provenance = %v/%v", iau.Model, split.Model)
}
}
+2 -2
View File
@@ -303,8 +303,8 @@ func TestLocalSolarEclipseContactPoints(t *testing.T) {
points[point.Label] = point
}
assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, 226.219228, 1e-3)
assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.137089, 1e-3)
assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.137089, 1e-3)
assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.133602, 1e-3)
assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.133602, 1e-3)
assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, 310.781438, 1e-3)
}
+31 -27
View File
@@ -17,26 +17,20 @@ const (
horizontalParallaxSunRatio = 6378.137 / 696000.0
)
// SolarEclipseGeocentricPanel 汇总 详细版式面板所需的食甚时刻地心量。
// SolarEclipseGeocentricPanel carries the geocentric quantities that detailed panels print.
// SolarEclipseGeocentricPanel 详细面板所需的食甚时刻地心量 / geocentric quantities at greatest eclipse for detailed panels.
type SolarEclipseGeocentricPanel struct {
// Conjunction 是本次朔,即地心视黄经相等的时刻;与 NASA 全球图上的 Geocentric Conjunction 不是同一个量。
// Conjunction is the new moon, the instant of equal geocentric apparent ecliptic longitude. It is not
// the same quantity as the Geocentric Conjunction printed on NASA world maps.
// Conjunction 地心视黄经相等的朔时刻 / new moon at equal geocentric apparent ecliptic longitude.
Conjunction time.Time
ConjunctionJDE float64
// RightAscensionConjunction 是地心视赤经相等的时刻,也就是 NASA 全球图上 Geocentric Conjunction 的口径;
// 2009-07-22 两者相差约 90 s(视黄经相等在 02:34:34 UT,视赤经相等在 02:33:04 UT)。
// RightAscensionConjunction is the instant of equal geocentric apparent right ascension, the quantity NASA
// world maps print as Geocentric Conjunction. For 2009-07-22 the two differ by about 90 s.
// RightAscensionConjunction 地心视赤经相等的时刻,与朔的时差不固定 / equal geocentric apparent right ascension, with a variable gap from new moon.
RightAscensionConjunction time.Time
RightAscensionConjunctionJDE float64
// RightAscensionConjunctionJD 是同一时刻的世界时儒略日,NASA 全球图上印的就是它。
// RightAscensionConjunctionJD is the universal-time Julian day of that instant, the value NASA maps print.
// RightAscensionConjunctionJD 同一合时刻的 UT1 儒略日 / UT1 Julian day of the same conjunction.
RightAscensionConjunctionJD float64
// DeltaTSeconds 是食甚时刻实际使用的 ΔT。
// DeltaTSeconds is the ΔT used at greatest eclipse.
// DeltaTSeconds 食甚时刻实际使用的 ΔT / ΔT used at greatest eclipse.
DeltaTSeconds float64
// SunRadiusModel SunSemidiameterArcsec 所用的太阳半径口径 / solar radius convention behind SunSemidiameterArcsec.
SunRadiusModel SolarEclipseSunRadiusModel
SunRightAscensionDeg float64
SunDeclinationDeg float64
@@ -54,14 +48,19 @@ type SolarEclipseGeocentricPanel struct {
BrownLunationNumber int
// PenumbralK 与 UmbralK 是月地半径比 k1/k2。
// PenumbralK and UmbralK are the Moon-to-Earth radius ratios k1 and k2.
PenumbralK float64
UmbralK float64
// BodyShiftLongitudeArcsec 与 BodyShiftLatitudeArcsec 是星历表里的 Δl/Δb;本库模型不做月面位置平移,恒为零。
// BodyShiftLongitudeArcsec and BodyShiftLatitudeArcsec are the Δl/Δb of published element tables;
// this model shifts nothing on the lunar surface, so both stay zero.
BodyShiftLongitudeArcsec float64
BodyShiftLatitudeArcsec float64
// Ephemeris 是所用模型名称。
// Ephemeris names the model in use.
Ephemeris string
// SingleK 表示使用的是 IAU Single-K(k1 同时用于半影与本影)。
// SingleK reports the IAU Single-K convention, where k1 serves both the penumbra and the umbra.
SingleK bool
}
@@ -74,29 +73,33 @@ func horizontalParallaxArcsec(semidiameterArcsec, radiusRatio float64) float64 {
// SolarEclipseGeocentricPanelAt 计算给定日食在食甚时刻的地心量面板。
// SolarEclipseGeocentricPanelAt computes the geocentric panel of one eclipse at greatest eclipse.
func SolarEclipseGeocentricPanelAt(date time.Time) (SolarEclipseGeocentricPanel, bool) {
info, ok := SolarEclipseOnDateNASABulletinSplitK(date)
if !ok {
return SolarEclipseGeocentricPanel{}, false
}
panel := solarEclipseGeocentricPanelAt(info)
panel.Ephemeris = "NASA bulletin Split-K"
panel.PenumbralK = basic.SolarEclipsePenumbralK
panel.UmbralK = basic.SolarEclipseUmbralK
return panel, true
return SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelNASABulletinSplitK})
}
// SolarEclipseGeocentricPanelIAUSingleK 使用 IAU Single-K 计算地心量面板。
// SolarEclipseGeocentricPanelIAUSingleK computes the geocentric panel with the IAU Single-K model.
func SolarEclipseGeocentricPanelIAUSingleK(date time.Time) (SolarEclipseGeocentricPanel, bool) {
info, ok := SolarEclipseOnDateIAUSingleK(date)
return SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{RadiusModel: SolarEclipseModelIAUSingleK})
}
// SolarEclipseGeocentricPanelWithOptions 使用自定义半径口径计算食甚时刻的地心量面板。
// SolarEclipseGeocentricPanelWithOptions computes the geocentric panel at greatest eclipse with custom radius conventions.
func SolarEclipseGeocentricPanelWithOptions(date time.Time, options SolarEclipseOptions) (SolarEclipseGeocentricPanel, bool) {
info, ok := SolarEclipseOnDateWithOptions(date, options)
if !ok {
return SolarEclipseGeocentricPanel{}, false
}
panel := solarEclipseGeocentricPanelAt(info)
panel.Ephemeris = "IAU Single-K"
if info.Model == SolarEclipseModelIAUSingleK {
panel.Ephemeris = "IAU Single-K"
panel.PenumbralK = basic.SolarEclipseIAUSingleRadiusK
panel.UmbralK = basic.SolarEclipseIAUSingleRadiusK
panel.SingleK = true
return panel, true
}
panel.Ephemeris = "NASA bulletin Split-K"
panel.PenumbralK = basic.SolarEclipsePenumbralK
panel.UmbralK = basic.SolarEclipsePenumbralK
panel.SingleK = true
panel.UmbralK = basic.SolarEclipseUmbralK
return panel, true
}
@@ -114,7 +117,8 @@ func solarEclipseGeocentricPanelAt(info SolarEclipseInfo) SolarEclipseGeocentric
}
panel.SunRightAscensionDeg, panel.SunDeclinationDeg = basic.SunApparentRaDec(tt)
panel.MoonRightAscensionDeg, panel.MoonDeclinationDeg = basic.HMoonTrueRaDec(tt)
panel.SunSemidiameterArcsec = basic.SunSemidiameter(tt)
panel.SunRadiusModel = info.SunRadiusModel
panel.SunSemidiameterArcsec = basic.SolarEclipseSunSemidiameter(tt, info.SunRadiusModel)
panel.MoonSemidiameterArcsec = basic.MoonSemidiameter(tt)
panel.SunParallaxArcsec = horizontalParallaxArcsec(panel.SunSemidiameterArcsec, horizontalParallaxSunRatio)
panel.MoonParallaxArcsec = horizontalParallaxArcsec(panel.MoonSemidiameterArcsec, horizontalParallaxMoonRatio)
+37 -2
View File
@@ -62,8 +62,43 @@ func TestSolarEclipseGeocentricPanelMatchesNASABulletin(t *testing.T) {
t.Fatalf("right-ascension conjunction %s differs from NASA by %.1f s",
panel.RightAscensionConjunction.Format("15:04:05.0"), delta)
}
// 朔(视黄经相等)比它晚约 90 s,两者不是同一个量。
// 该时差只约束本事件,不代表两种合时刻的普遍关系。
if delta := panel.Conjunction.Sub(panel.RightAscensionConjunction).Seconds(); delta < 60 || delta > 120 {
t.Fatalf("new moon is %.1f s after the right-ascension conjunction, want about 90 s", delta)
t.Fatalf("for 2009-07-22 the new moon is %.1f s after the right-ascension conjunction (event-specific, want 60..120 s)", delta)
}
}
// 两种合时刻的差值随几何变化,符号也会改变。
func TestGeocentricConjunctionGapIsNotConstant(t *testing.T) {
cases := []struct {
date string
want float64
}{
{"2004-04-19", -3090.6},
{"2007-09-11", 3529.6},
{"2009-07-22", -90.2},
{"2025-09-21", 3368.4},
}
minGap, maxGap := math.Inf(1), math.Inf(-1)
for _, tc := range cases {
date, err := time.Parse("2006-01-02", tc.date)
if err != nil {
t.Fatal(err)
}
panel, ok := SolarEclipseGeocentricPanelAt(date.Add(12 * time.Hour))
if !ok {
t.Fatalf("%s 不是日食日", tc.date)
}
gap := panel.RightAscensionConjunction.Sub(panel.Conjunction).Seconds()
if math.Abs(gap-tc.want) > 30 {
t.Errorf("%s 两者相差 %.1f s, want %.1f±30 s", tc.date, gap, tc.want)
}
minGap, maxGap = math.Min(minGap, gap), math.Max(maxGap, gap)
}
if minGap > -1200 || maxGap < 1200 {
t.Errorf("样本应同时出现提前与推后超过 20 分钟的事件,got %.1f … %.1f s", minGap, maxGap)
}
if maxGap-minGap < 3000 {
t.Errorf("样本跨度 %.1f s,不足以说明该差值不是常数", maxGap-minGap)
}
}
+7
View File
@@ -44,6 +44,12 @@ type SolarEclipsePathPoint struct {
type SolarEclipsePath struct {
// Eclipse 是对应的全局日食信息, related global solar eclipse information.
Eclipse SolarEclipseInfo
// PathWidthDefined 表示本结果的带宽是否有定义:两限存在且上下两条限界线都非空时才为 true;
// 为 false 时 Eclipse.PathWidthKM 与 Greatest.WidthKM 都是 0。
// PathWidthDefined reports whether this result has a defined band width: both
// limits must exist and both limit lines must be non-empty. When it is false,
// Eclipse.PathWidthKM and Greatest.WidthKM are 0.
PathWidthDefined bool
// Greatest 是食甚点/最佳观测点, greatest eclipse point.
Greatest SolarEclipsePathPoint
// CenterLine 是中心线, central line.
@@ -333,6 +339,7 @@ func solarEclipseCentralPath(
path := SolarEclipsePath{
Eclipse: solarEclipseInfoFromBasic(result.Eclipse, location),
PathWidthDefined: result.PathWidthDefined,
Greatest: solarEclipsePathPointFromBasic(result.Greatest, location),
CenterLine: solarEclipsePathPointsFromBasic(result.CenterLine, location),
NorthernLimit: solarEclipsePathPointsFromBasic(result.NorthernLimit, location),
+55
View File
@@ -0,0 +1,55 @@
package eclipse
import (
"math"
"testing"
"time"
)
// basic 与 eclipse 两层必须给出同一个带宽口径标志:单侧极限事件带宽无定义且值恒为 0。
func TestSolarEclipsePathCarriesPathWidthDefinition(t *testing.T) {
testCases := []struct {
name string
date time.Time
defined bool
widthKM float64
}{
{name: "-1404-01-07 single-sided limit", date: time.Date(-1404, 1, 7, 0, 0, 0, 0, time.UTC)},
{name: "2003-05-31 single-sided limit with limit lines", date: time.Date(2003, 5, 31, 0, 0, 0, 0, time.UTC)},
{name: "2024-04-08 total", date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), defined: true, widthKM: 198.6161},
{name: "2010-01-15 annular", date: time.Date(2010, 1, 15, 0, 0, 0, 0, time.UTC), defined: true, widthKM: 335.0298},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := ClosestSolarEclipse(tc.date)
if info.PathWidthDefined != tc.defined {
t.Fatalf("info.PathWidthDefined=%v want %v", info.PathWidthDefined, tc.defined)
}
if !tc.defined && info.PathWidthKM != 0 {
t.Fatalf("undefined info width %.4f", info.PathWidthKM)
}
path, ok := SolarEclipseCentralPath(tc.date, SolarEclipsePathOptions{})
if !ok {
t.Fatalf("no central path")
}
if path.PathWidthDefined != tc.defined || path.Eclipse.PathWidthDefined != tc.defined {
t.Fatalf("defined=%v eclipse=%v want %v", path.PathWidthDefined, path.Eclipse.PathWidthDefined, tc.defined)
}
if !tc.defined {
if path.Eclipse.PathWidthKM != 0 || path.Greatest.WidthKM != 0 {
t.Fatalf("undefined width leaks: eclipse=%.4f greatest=%.4f",
path.Eclipse.PathWidthKM, path.Greatest.WidthKM)
}
return
}
if math.Abs(path.Eclipse.PathWidthKM-tc.widthKM) > 0.01 {
t.Fatalf("path width %.4f want %.4f", path.Eclipse.PathWidthKM, tc.widthKM)
}
if path.Eclipse.PathWidthKM > 0 && (len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0) {
t.Fatalf("width %.4f km with limits %d/%d", path.Eclipse.PathWidthKM,
len(path.NorthernLimit), len(path.SouthernLimit))
}
})
}
}
+71
View File
@@ -0,0 +1,71 @@
package eclipse
import (
"math"
"testing"
"time"
"b612.me/astro/basic"
)
// 面板与选项契约:面板回报的太阳视半径必须与它声明的口径、以及求解器实际使用的半径一致。
func TestSolarEclipsePanelSunRadiusMatchesConvention(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
info, ok := SolarEclipseOnDate(date)
if !ok {
t.Fatal("2024-04-08 eclipse missing")
}
panel, ok := SolarEclipseGeocentricPanelAt(date)
if !ok {
t.Fatal("panel missing")
}
if panel.SunRadiusModel != SolarEclipseSunRadiusStandard {
t.Fatalf("panel sun radius model = %q", panel.SunRadiusModel)
}
tt := solarEclipseTimeToTTJDE(info.GreatestEclipse)
want := basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusStandard)
if math.Abs(panel.SunSemidiameterArcsec-want) > 1e-9 {
t.Fatalf("panel sun S.D. = %.6f, want %.6f", panel.SunSemidiameterArcsec, want)
}
// 通用名义半径(IAU 2015,695700 km)比食几何标准档小约 0.4″,面板不应再跟着它走。
if nominal := basic.SunSemidiameter(tt); math.Abs(panel.SunSemidiameterArcsec-nominal) < 0.3 {
t.Fatalf("panel sun S.D. %.6f still tracks the nominal radius %.6f", panel.SunSemidiameterArcsec, nominal)
}
}
func TestSolarEclipseOptionsSelectSunRadius(t *testing.T) {
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
standard, ok := SolarEclipseOnDateWithOptions(date, SolarEclipseOptions{})
if !ok {
t.Fatal("standard eclipse missing")
}
measured, ok := SolarEclipseOnDateWithOptions(date, SolarEclipseOptions{SunRadiusModel: SolarEclipseSunRadiusMeasured})
if !ok {
t.Fatal("measured eclipse missing")
}
if standard.SunRadiusModel != SolarEclipseSunRadiusStandard || measured.SunRadiusModel != SolarEclipseSunRadiusMeasured {
t.Fatalf("provenance = %q / %q", standard.SunRadiusModel, measured.SunRadiusModel)
}
if standard.Model != SolarEclipseModelNASABulletinSplitK {
t.Fatalf("zero options resolved the k model to %q", standard.Model)
}
if !(measured.PathWidthKM < standard.PathWidthKM) {
t.Fatalf("path width %.3f >= %.3f", measured.PathWidthKM, standard.PathWidthKM)
}
panel, ok := SolarEclipseGeocentricPanelWithOptions(date, SolarEclipseOptions{SunRadiusModel: SolarEclipseSunRadiusMeasured})
if !ok {
t.Fatal("measured panel missing")
}
if panel.SunRadiusModel != SolarEclipseSunRadiusMeasured {
t.Fatalf("panel sun radius model = %q", panel.SunRadiusModel)
}
if panel.PenumbralK != basic.SolarEclipsePenumbralK || panel.UmbralK != basic.SolarEclipseUmbralK {
t.Fatalf("split panel k = %.7f/%.7f", panel.PenumbralK, panel.UmbralK)
}
if tt := solarEclipseTimeToTTJDE(measured.GreatestEclipse); math.Abs(
panel.SunSemidiameterArcsec-basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusMeasured),
) > 1e-9 {
t.Fatalf("measured panel sun S.D. = %.6f", panel.SunSemidiameterArcsec)
}
}
+11 -9
View File
@@ -56,7 +56,9 @@ func (topology SolarEclipseShadowTopology) Signature() string {
// SolarEclipseShadowInstant 某瞬时的全球本影或半影足迹 / instantaneous shadow footprint.
type SolarEclipseShadowInstant struct {
// Time 记录对应的时刻:UTC 入口为调用方所给,TT 入口按句柄 ΔT 换算 / instant this record describes.
// Time 记录对应的时刻:UTC 入口为调用方所给,TT 入口回填民用时刻(TT2UTC),与句柄 ΔT 无关。
// Time is the instant this record describes: the caller's value for the UTC entry, and the civil
// time recovered with TT2UTC for the TT entry, independently of the handle's ΔT.
Time time.Time
// JDE 几何使用的力学时儒略日,ΔT 不改变它 / TT instant used by the geometry.
JDE float64
@@ -82,6 +84,8 @@ func (instant SolarEclipseShadowInstant) Empty() bool {
// SolarEclipseStationState 某瞬时的站心日月几何 / topocentric geometry at one instant.
type SolarEclipseStationState struct {
// Time 是该状态对应的时刻。
// Time is the instant this state describes.
Time time.Time
// JDE 本次计算使用的力学时儒略日 / TT instant used.
JDE float64
@@ -106,7 +110,7 @@ type SolarEclipseStationState struct {
HasAnnularPhase bool
// CentralPhaseType 中心食类型,非中心食为 SolarEclipseNone / central phase kind.
CentralPhaseType SolarEclipseType
// Visible 太阳中心在地平线上,海拔用俯仰角修正 / Sun center above the horizon.
// Visible 太阳中心在地平线上,高度用俯仰角修正 / Sun center above the horizon.
Visible bool
}
@@ -131,11 +135,9 @@ func NewSolarEclipseShadowSolver(options SolarEclipseShadowSolverOptions) *Solar
}
func (solver *SolarEclipseShadowSolver) ttJDE(value time.Time) float64 {
utJDE := basic.Date2JDE(value.UTC())
if solver.options.DeltaTSeconds > 0 {
return utJDE + solver.options.DeltaTSeconds/86400
}
return basic.TD2UT(utJDE, true)
// 入参是民用时刻:TT = UTC + (TT−UTC),与闰秒表一致。显式 ΔT 是 TT−UT1,
// 只改自转相位,不能掺进这条换算,否则报告值与实用偏移会差一个 DUT1。
return basic.UTC2TT(basic.Date2JD(value.UTC()))
}
// ShadowAt 给定 UTC 时刻的阴影足迹;不在地球上时返回 (零值, false) / footprint at one UTC instant.
@@ -164,7 +166,7 @@ func solarEclipseShadowInstantFromBasic(
) SolarEclipseShadowInstant {
location := value.Location()
if value.IsZero() {
value = solarEclipseTTJDEToTimeWithDeltaT(instant.JDE, instant.DeltaTSeconds, time.UTC)
value = solarEclipseTTJDEToTime(instant.JDE, time.UTC)
location = time.UTC
}
return SolarEclipseShadowInstant{
@@ -202,7 +204,7 @@ func (solver *SolarEclipseShadowSolver) StationStateAtJDE(
}
state := solver.inner.StationStateAtJDE(jdeTT, lon, lat, height)
result := solarEclipseStationStateFromBasic(time.Time{}, state)
result.Time = solarEclipseTTJDEToTimeWithDeltaT(jdeTT, state.DeltaTSeconds, time.UTC)
result.Time = solarEclipseTTJDEToTime(jdeTT, time.UTC)
return result
}
+12 -4
View File
@@ -140,8 +140,9 @@ func (solver *SolarEclipseShadowSolver) stationDeltaT(value time.Time) float64 {
return state.DeltaTSeconds
}
func TestSolarEclipseShadowTTEntryUsesScopedDeltaTForItsTime(t *testing.T) {
// 显式 ΔT 下:TT 入口回填的 UT 必须是 TT − 句柄 ΔT,闭合弧与 closure 取自同一时刻。
func TestSolarEclipseShadowTTEntryReportsCivilTime(t *testing.T) {
// 句柄 ΔT 只改地球自转相位:TT 入口回填的必须是民用时刻(TT2UTC),
// 而不是 TT − ΔT —— 后者是 UT1,与民用时刻差一个 DUT1。
override := 3666.18
value := time.Date(2009, time.July, 22, 0, 53, 0, 0, time.UTC)
model := NewSolarEclipseShadowSolver(SolarEclipseShadowSolverOptions{})
@@ -151,13 +152,20 @@ func TestSolarEclipseShadowTTEntryUsesScopedDeltaTForItsTime(t *testing.T) {
if !ok {
t.Fatal("expected a footprint at the shifted instant")
}
want := solarEclipseTTJDEToTimeWithDeltaT(jde, override, time.UTC)
want := solarEclipseTTJDEToTime(jde, time.UTC)
if !instant.Time.Equal(want) {
t.Fatalf("TT entry time=%v, want %v (TT − %.2f s)", instant.Time.UTC(), want.UTC(), override)
t.Fatalf("TT entry time=%v, want the civil time %v", instant.Time.UTC(), want.UTC())
}
if instant.DeltaTSeconds != override {
t.Fatalf("reported ΔT=%.6f, want %.2f", instant.DeltaTSeconds, override)
}
if shift := instant.Time.Sub(value); shift > time.Millisecond || shift < -time.Millisecond {
t.Fatalf("TT entry time=%v, want the civil instant %v", instant.Time.UTC(), value)
}
state := solver.StationStateAtJDE(jde, -96.8, 32.8, 0)
if !state.Time.Equal(want) {
t.Fatalf("station TT entry time=%v, want the civil time %v", state.Time.UTC(), want.UTC())
}
}
func TestSolarEclipseShadowBetweenSurvivesLongWindows(t *testing.T) {
+2 -2
View File
@@ -19,9 +19,9 @@ func TestSolarEclipseGeocentricPanelModelsContract(t *testing.T) {
if !singleK.SingleK || singleK.Ephemeris != "IAU Single-K" {
t.Fatalf("IAU panel SingleK=%v ephemeris=%q", singleK.SingleK, singleK.Ephemeris)
}
if singleK.PenumbralK != singleK.UmbralK || singleK.PenumbralK != basic.SolarEclipsePenumbralK {
if singleK.PenumbralK != singleK.UmbralK || singleK.PenumbralK != basic.SolarEclipseIAUSingleRadiusK {
t.Fatalf("IAU panel k1=%.7f k2=%.7f, want the single k %.7f",
singleK.PenumbralK, singleK.UmbralK, basic.SolarEclipsePenumbralK)
singleK.PenumbralK, singleK.UmbralK, basic.SolarEclipseIAUSingleRadiusK)
}
splitK, ok := SolarEclipseGeocentricPanelAt(date)
if !ok {
+232
View File
@@ -0,0 +1,232 @@
package svg
import (
"encoding/xml"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,站心图的页脚各行必须等行距。
// 这两条都是排版不变量,与具体事件无关,因此在这里按图种各取一个样本固定下来。
const chartBottomMarginMinimum = 6.0
type chartTextElement struct {
x float64
y float64
fontSize float64
fill string
value string
}
func chartTextElements(t *testing.T, document string) []chartTextElement {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(document))
elements := []chartTextElement{}
stack := []string{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("chart is not valid XML: %v", err)
}
switch typed := token.(type) {
case xml.StartElement:
stack = append(stack, typed.Name.Local)
if typed.Name.Local != "text" {
continue
}
element := chartTextElement{}
for _, attribute := range typed.Attr {
switch attribute.Name.Local {
case "x":
element.x, _ = strconv.ParseFloat(attribute.Value, 64)
case "y":
element.y, _ = strconv.ParseFloat(attribute.Value, 64)
case "font-size":
element.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "fill":
element.fill = attribute.Value
}
}
elements = append(elements, element)
case xml.EndElement:
stack = stack[:len(stack)-1]
case xml.CharData:
if len(stack) > 0 && stack[len(stack)-1] == "text" && len(elements) > 0 {
elements[len(elements)-1].value += string(typed)
}
}
}
return elements
}
func chartBottomMarginViolations(t *testing.T, document string, height int) []string {
t.Helper()
violations := []string{}
for _, element := range chartTextElements(t, document) {
if strings.TrimSpace(element.value) == "" {
continue
}
_, below := svgchart.EstimatedTextExtents(element.fontSize)
if margin := float64(height) - (element.y + below); margin < chartBottomMarginMinimum {
violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", element.value, margin, chartBottomMarginMinimum))
}
}
return violations
}
func chartFooterCases(t *testing.T) []struct {
name string
height int
document string
} {
t.Helper()
cst := time.FixedZone("CST", 8*3600)
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
long := footerFitLongText("footer ")
cases := []struct {
name string
height int
document string
}{}
add := func(name string, height int, document string, ok bool) {
if !ok {
t.Fatalf("%s: chart missing", name)
}
cases = append(cases, struct {
name string
height int
document string
}{name, height, document})
}
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
add("站心日食", 692, solar, ok)
solarLong, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 640, Height: 520, Location: cst, FooterNote: long})
add("站心日食 窄画布长说明", 520, solarLong, ok)
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
add("站心月食", 640, lunar, ok)
lunarLong, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 640, Height: 520, Language: "zh", Location: cst, FooterNote: long})
add("站心月食 窄画布长说明", 520, lunarLong, ok)
detailed, ok := LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1000, Language: "zh", Location: cst, FooterNote: long})
add("月食详图 长说明", 1000, detailed, ok)
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
add("月食地图 长说明", 640, mapChart, ok)
solarMap, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC, FooterNote: long})
add("日食地图 长说明", 640, solarMap, ok)
return cases
}
func TestChartTextKeepsBottomMargin(t *testing.T) {
for _, tc := range chartFooterCases(t) {
if violations := chartBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 {
t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0])
}
}
}
func TestLocalDiagramFooterLinesAreEvenlySpaced(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := []struct {
name string
height int
document string
}{}
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
if !ok {
t.Fatal("站心日食:缺图")
}
cases = append(cases, struct {
name string
height int
document string
}{"站心日食", 692, solar})
lunar, ok := LunarEclipseSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
if !ok {
t.Fatal("站心月食:缺图")
}
cases = append(cases, struct {
name string
height int
document string
}{"站心月食", 640, lunar})
for _, tc := range cases {
baselines := []float64{}
for _, element := range chartTextElements(t, tc.document) {
if element.fontSize != 12 || (element.fill != "#333" && element.fill != "#555") {
continue
}
if strings.TrimSpace(element.value) == "" {
continue
}
baselines = append(baselines, element.y)
}
if len(baselines) < 3 {
t.Fatalf("%s:页脚只有 %d 行,样本太少", tc.name, len(baselines))
}
sort.Float64s(baselines)
step := svgchart.FooterLineHeight(12)
for index := 1; index < len(baselines); index++ {
if gap := baselines[index] - baselines[index-1]; math.Abs(gap-step) > 0.01 {
t.Fatalf("%s:页脚第 %d 行与上一行间距 %.3f,应为 %.1f(基线 %v)", tc.name, index+1, gap, step, baselines)
}
}
if margin := float64(tc.height) - baselines[len(baselines)-1]; math.Abs(margin-localDiagramFooterBottomPadding) > 0.01 {
t.Fatalf("%s:页脚末行基线距底边 %.3f,应为 %.1f(须落在图框内)", tc.name, margin, localDiagramFooterBottomPadding)
}
}
}
// 页脚时标声明契约:调用方给超长自定义说明时,时标声明仍须留在页脚里(截断可以吃掉说明,不能吃掉声明)。
func TestChartFooterKeepsTimeScaleDeclaration(t *testing.T) {
long := footerFitLongText("scale ")
cst := time.FixedZone("CST", 8*3600)
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
cases := []struct {
name string
document string
ok bool
}{}
solar, ok := LocalSolarEclipseSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 118.0894, 24.4798, 0,
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"站心日食", solar, ok})
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"站心月食", lunar, ok})
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"月食地图", mapChart, ok})
for _, tc := range cases {
if !tc.ok {
t.Fatalf("%s:缺图", tc.name)
}
if !strings.Contains(tc.document, "显示时区") && !strings.Contains(tc.document, "图中时刻为 UTC") {
t.Errorf("%s:超长说明把时标声明挤掉了", tc.name)
}
}
}
+68 -26
View File
@@ -1,6 +1,7 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
@@ -66,6 +68,11 @@ type LunarEclipseSVGOptions struct {
// Location 是图中显示时刻的时区;nil 时使用 UTC+8。
// Location is the display timezone for chart times; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
}
type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult
@@ -149,6 +156,12 @@ func lunarEclipseSVG(
calculator lunarEclipseSVGCalculator,
finder lunarEclipseSVGFinder,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
@@ -162,7 +175,7 @@ func lunarEclipseSVG(
info.HasSaros = coreInfo.HasSaros
info.Saros = coreInfo.Saros
}
return renderLunarEclipseSVG(info, diagram, options), true
return renderLunarEclipseSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, diagram, options), true
}
func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions {
@@ -319,11 +332,21 @@ func writeLunarEclipseEventLabels(
func renderLunarEclipseSVG(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
) string {
headerTexts := lunarEclipseSVGHeaderTexts(info, options)
layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts))
headerTexts := lunarEclipseSVGHeaderTexts(info, geometry, options)
headerBottom := lunarEclipseSVGHeaderBottom(headerTexts)
direction := lunarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, float64(options.Width)-80, 12)
}
footerLines := lunarEclipseSVGFooterLines(direction, info, options, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), float64(options.Height), 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
// 页脚高度按实际行数算,再留一点与图区的空,说明文字不会顶到画布底边。
layout := lunarEclipseSVGLayoutFor(diagram, options, headerBottom, footerOccupied+10)
title := lunarEclipseSVGTitleText(info, options)
var b strings.Builder
@@ -351,7 +374,7 @@ func renderLunarEclipseSVG(
writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels)
writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY)
writeLunarEclipseFooter(&b, info, options, layout)
writeLunarEclipseFooter(&b, footerLines, footerBaselines)
b.WriteString(`</svg>`)
return b.String()
@@ -360,7 +383,7 @@ func renderLunarEclipseSVG(
func lunarEclipseSVGLayoutFor(
diagram basic.LunarEclipseDiagramResult,
options LunarEclipseSVGOptions,
headerBottom float64,
headerBottom, footerHeight float64,
) lunarEclipseSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
@@ -372,7 +395,7 @@ func lunarEclipseSVGLayoutFor(
diagramRight = width - margin
}
topReserved := math.Max(166, headerBottom+24)
bottomReserved := 82.0
bottomReserved := footerHeight
extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72
scale := math.Min((diagramRight-diagramLeft)/(2*extent), (height-topReserved-bottomReserved)/(2*extent))
if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) {
@@ -413,11 +436,15 @@ func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOpti
return lunarEclipseSVGTitle(info, options.Language)
}
func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string {
func lunarEclipseSVGHeaderTexts(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
options LunarEclipseSVGOptions,
) []string {
lines := []string{
lunarEclipseSVGSummaryText(info, options),
lunarEclipseSVGMaximumTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(info, options),
lunarEclipseSVGCoordinatesTextValue(geometry, options),
lunarEclipseSVGDurationTextValue(info, options),
lunarEclipseSVGMetaTextValue(info, options),
}
@@ -484,6 +511,10 @@ func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string {
}
func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string {
return lunarEclipseSVGFooterNoteTextBase(options)
}
func lunarEclipseSVGFooterNoteTextBase(options LunarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -997,29 +1028,40 @@ func writeLunarEclipseContacts(
}
}
func writeLunarEclipseFooter(
b *strings.Builder,
info LunarEclipseInfo,
options LunarEclipseSVGOptions,
layout lunarEclipseSVGLayout,
) {
_ = info
direction := lunarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, layout.width-80, 12)
// lunarEclipseSVGFooterLines 返回方向行在前的页脚各行:行数上限由图区下限与画布高度决定。
func lunarEclipseSVGFooterLines(direction string, info LunarEclipseInfo, options LunarEclipseSVGOptions, headerBottom float64) []string {
const (
fontSize = 12.0
diagramFloor = 160.0
stageGap = 10.0
)
height := float64(options.Height)
lineHeight := svgchart.FooterLineHeight(fontSize)
padding := localDiagramFooterBottomPadding
maxLines := svgchart.TextLineLimit(fontSize, lineHeight, height-headerBottom-diagramFloor-stageGap-padding-lineHeight)
if cap := int(height * 0.20 / lineHeight); maxLines > cap {
maxLines = cap
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-54, html.EscapeString(direction))
// 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。
if maxLines < 1 {
maxLines = 1
}
note := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
lines := []string{lunarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
maxWidth := layout.width - 80
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12,
svgchart.BaselineLineLimit(12, 15, layout.height-34, layout.height-4))
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-80, fontSize)
}
lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-80, fontSize, maxLines)
return append([]string{direction}, lines...)
}
func writeLunarEclipseFooter(b *strings.Builder, lines []string, baselines []float64) {
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, layout.height-34+float64(index)*15, html.EscapeString(line))
fill := "#555"
if index == 0 {
fill = "#333"
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, baselines[index], fill, html.EscapeString(line))
}
}
+41 -22
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgasset"
@@ -33,9 +35,19 @@ type LunarEclipseDetailedSVGOptions struct {
// Location 控制显示时刻的时区;nil 使用 UTC+8。
// Location controls the display timezone; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false)。nil Location 在 UTC 下默认 UTC+8,UT1 下强制为 UTC。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels and requires
// Location to be nil or UTC (otherwise the renderer returns false).
TimeScale astro.TimeScale
// Step 是月心路径采样步长;<=0 时使用 5 分钟。
// Step is the Moon-center path sampling step; values <= 0 use five minutes.
Step time.Duration
// DisablePenumbralPhase 关闭底图可见性分区里的半影阶段,含义与 LunarEclipseMapSVGOptions 的同名字段一致;
// 零值即画半影。
// DisablePenumbralPhase turns the penumbral phase off in the base map partition, with the same
// meaning as the LunarEclipseMapSVGOptions field of that name; the zero value draws it.
DisablePenumbralPhase bool
// Title 与 FooterNote 为空时自动生成。
// Empty Title and FooterNote use automatic text.
Title string
@@ -98,6 +110,12 @@ func lunarEclipseDetailedSVG(
finder lunarEclipseSVGFinder,
model eclipsecore.LunarEclipseShadowModel,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseDetailedSVGOptions(options)
diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{
StepDays: durationToDays(options.Step),
@@ -115,7 +133,8 @@ func lunarEclipseDetailedSVG(
return "", false
}
return renderLunarEclipseDetailedSVG(
info, diagram, geometry(diagram.Eclipse.Maximum), timeToTTJDE(date), options, model,
lunarEclipseDisplayInfo(info, options.TimeScale), info,
diagram, geometry(diagram.Eclipse.Maximum), diagram.Eclipse.Maximum, options, model,
), true
}
@@ -189,7 +208,7 @@ func validLunarEclipseDetailedSize(width, height int, info LunarEclipseInfo, opt
if (w-2*margin-2*columnGap)/3 < lunarEclipseDetailedMinColumnWidth {
return false
}
view := lunarEclipseDetailedViewFor(options)
view := lunarEclipseDetailedViewFor(info, options)
if solarEclipsePanelsOverlap(lunarEclipseDetailedPanelCells(view, info, options)) {
return false
}
@@ -275,7 +294,7 @@ func lunarEclipseDetailedPanelCells(
}
}
func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView {
func lunarEclipseDetailedViewFor(info LunarEclipseInfo, options LunarEclipseDetailedSVGOptions) lunarEclipseDetailedView {
width, height := float64(options.Width), float64(options.Height)
margin := math.Max(30, math.Min(52, width*0.044))
view := lunarEclipseDetailedView{
@@ -304,7 +323,8 @@ func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEc
Projection: svgmap.ProjectionEquirectangular,
}
view.legendY = mapTop + mapHeight + lunarEclipseDetailedLegendGap
view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language))
view.legendRows = len(lunarEclipseMapLegendRows(view.mapFrame, width, options.Language,
lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase)))
if view.legendRows < 1 {
view.legendRows = 1
}
@@ -319,13 +339,14 @@ func lunarEclipseDetailedViewFor(options LunarEclipseDetailedSVGOptions) lunarEc
func renderLunarEclipseDetailedSVG(
info LunarEclipseInfo,
geometry LunarEclipseInfo,
diagram basic.LunarEclipseDiagramResult,
shadow basic.LunarEclipseShadowGeometry,
tt float64,
options LunarEclipseDetailedSVGOptions,
model eclipsecore.LunarEclipseShadowModel,
) string {
view := lunarEclipseDetailedViewFor(options)
view := lunarEclipseDetailedViewFor(info, options)
english := options.Language == lunarEclipseSVGLanguageEnglish
title := options.Title
if title == "" {
@@ -353,13 +374,14 @@ func renderLunarEclipseDetailedSVG(
diagramTop: view.diagramTop, diagramEnd: view.diagramBottom,
diagramLeft: view.margin, diagramRight: view.width - view.margin,
}
penumbralBands := lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase)
// 不动的元素先占位:数据块、底图、图例与页脚;示意图标注只在示意图带内选位置。
labels := &svgchart.LabelTable{}
for _, cell := range lunarEclipseDetailedPanelCells(view, info, options) {
labels.Reserve(cell.box.X, cell.box.Y, cell.box.Width, cell.box.Height)
}
labels.Reserve(view.mapFrame.X, view.mapFrame.Y, view.mapFrame.Width, view.mapFrame.Height)
for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language) {
for _, row := range lunarEclipseMapLegendRows(view.mapFrame, view.width, options.Language, penumbralBands) {
for _, item := range row {
labels.ReserveText(item.x, item.y, lunarEclipseDetailedMapLegendFontSize, item.text, "start")
}
@@ -372,7 +394,7 @@ func renderLunarEclipseDetailedSVG(
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
b.WriteString(`<defs>`)
b.WriteString(svgasset.MoonFaceSymbol())
writeLunarEclipseMapDefinitions(&b, view.mapFrame, info)
writeLunarEclipseMapDefinitions(&b, view.mapFrame, geometry, penumbralBands)
b.WriteString(`</defs>`)
b.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&b, `<rect x="22" y="18" width="%.3f" height="%.3f" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
@@ -388,9 +410,9 @@ func renderLunarEclipseDetailedSVG(
writeLunarEclipseDetailedGeocentricBlocks(&b, labels, tt, view, options)
writeLunarEclipseDiagram(&b, layout, diagram, options.Language, labels)
writeLunarEclipseDetailedPanels(&b, info, shadow, scale, view, options)
writeLunarEclipseMapRegions(&b, view.mapFrame, info)
writeLunarEclipseMapRegions(&b, view.mapFrame, geometry, penumbralBands)
view.mapFrame.WriteFrame(&b)
writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language)
writeLunarEclipseMapLegend(&b, view.mapFrame, view.width, options.Language, penumbralBands)
writeLunarEclipseDetailedFooter(&b, view, options, model)
b.WriteString(`</svg>`)
return b.String()
@@ -435,16 +457,7 @@ func writeLunarEclipseDetailedSummary(
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#293235" font-family="Arial, sans-serif" font-size="12.5" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+float64(index)*22, html.EscapeString(line))
}
zoneNote := "图中时刻为 UT"
if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 {
zoneNote = fmt.Sprintf("图中时刻为 %s(UT%s)", zone, formatZoneOffset(offset))
}
if english {
zoneNote = "All times are UT"
if offset := zoneOffsetSeconds(info.Maximum.In(options.Location)); offset != 0 {
zoneNote = fmt.Sprintf("All times are %s (UT%s)", zone, formatZoneOffset(offset))
}
}
zoneNote := timenote.Scale(options.TimeScale, info.Maximum, options.Location, options.Language)
labels.ReserveText(view.width/2, view.metaY+4*22, 11, zoneNote, "middle")
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
view.width/2, view.metaY+4*22, html.EscapeString(zoneNote))
@@ -479,7 +492,7 @@ func writeLunarEclipseDetailedGeocentricBlocks(
}
sunRa, sunDec := basic.SunApparentRaDec(tt)
moonRa, moonDec := basic.HMoonTrueRaDec(tt)
sunSd := basic.SunSemidiameter(tt)
sunSd := basic.SolarEclipseSunSemidiameter(tt, basic.SolarEclipseSunRadiusStandard)
moonSd := basic.MoonSemidiameter(tt)
blocks := [2][]svgchart.PanelRow{
{
@@ -603,6 +616,10 @@ func writeLunarEclipseArcMinuteScaleBar(
// lunarEclipseDetailedFooterText 是页脚说明:默认模型会在极浅半影上回退 Chauvenet,必须写出实际用的那套。
func lunarEclipseDetailedFooterText(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string {
return lunarEclipseDetailedFooterTextBase(options, model)
}
func lunarEclipseDetailedFooterTextBase(options LunarEclipseDetailedSVGOptions, model eclipsecore.LunarEclipseShadowModel) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -623,10 +640,12 @@ func writeLunarEclipseDetailedFooter(
lines := svgchart.TruncateTextLines(
svgchart.WrapText(lunarEclipseDetailedFooterText(options, model), maxWidth, lunarEclipseDetailedFooterFontSize),
maxWidth, lunarEclipseDetailedFooterFontSize,
svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, 15, view.footerY, view.height-4))
svgchart.BaselineLineLimit(lunarEclipseDetailedFooterFontSize, svgchart.FooterLineHeight(lunarEclipseDetailedFooterFontSize),
view.footerY, view.height-svgchart.FooterBottomPadding(lunarEclipseDetailedFooterFontSize)))
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="%.0f">%s</text>`,
view.margin, view.footerY+float64(index)*15, lunarEclipseDetailedFooterFontSize, html.EscapeString(line))
view.margin, view.footerY+float64(index)*svgchart.FooterLineHeight(lunarEclipseDetailedFooterFontSize),
lunarEclipseDetailedFooterFontSize, html.EscapeString(line))
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestLunarEclipseDetailedSVGCombinesBothCharts(t *testing.T) {
// 地影几何要与 NASA 月食图逐项吻合:2009 之外的 2025-03-14 全食,Gamma 0.3481、P./U. Radius 1.1899/0.6537。
func TestLunarEclipseShadowGeometryMatchesNASABulletin(t *testing.T) {
date := time.Date(2025, time.March, 14, 0, 0, 0, 0, time.UTC)
result := basic.LunarEclipse(basic.TD2UT(basic.Date2JDE(date), true))
result := basic.LunarEclipse(basic.UTC2TT(basic.Date2JD(date)))
geometry := basic.LunarEclipseShadowGeometryAt(result.Maximum)
t.Logf("gamma=%.4f P.Radius=%.4f U.Radius=%.4f umbral=%.4f penumbral=%.4f",
geometry.Gamma, geometry.PenumbralRadiusDegrees, geometry.UmbralRadiusDegrees,
@@ -0,0 +1,75 @@
package svg
import (
"html"
"math"
"regexp"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 详细版"食甚时的月亮(地心坐标)"块必须用食甚时刻的力学时求值,与 NASA 月食图同一时刻;
// 输入日期的时刻(当天 0 时或 12 时)会让赤经赤纬整体偏掉。
func TestLunarEclipseDetailedGeocentricBlockUsesGreatestEclipse(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := []struct {
name string
date time.Time
ra string
dec string
}{
// 对应 NASA 图上的 Moon at Greatest Eclipse:2026-03-03 为 10h56m15.0s / +06°24'05.2",
// 2025-03-14 为 11h38m23.0s / +02°40'54.6",与本库的差在 0.3 角秒内。
{"2026-03-03", time.Date(2026, 3, 3, 0, 0, 0, 0, cst), "10h56m15.1s", "+06°24&#39;05.2&#34;"},
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40&#39;54.3&#34;"},
}
for _, want := range cases {
doc, ok := LunarEclipseDetailedSVG(want.date, LunarEclipseDetailedSVGOptions{
Language: "zh", Location: cst})
if !ok {
t.Fatalf("%s: missing chart", want.name)
}
ra, dec := lunarGeocentricMoonBlock(t, doc)
if ra != want.ra || dec != want.dec {
t.Errorf("%s: 食甚坐标 RA=%s Dec=%s, want RA=%s Dec=%s", want.name, ra, dec, want.ra, want.dec)
}
info, ok := eclipsecore.LunarEclipseOnDate(want.date)
if !ok {
t.Fatalf("%s: missing eclipse", want.name)
}
maximumRA, maximumDec := basic.HMoonTrueRaDec(timeToTTJDE(info.Maximum))
if got, wantValue := ra, formatSolarEclipseRA(maximumRA); got != wantValue {
t.Errorf("%s: RA=%s 不是食甚时刻的 %s", want.name, got, wantValue)
}
if got, wantValue := dec, html.EscapeString(formatSolarEclipseDec(maximumDec)); got != wantValue {
t.Errorf("%s: Dec=%s 不是食甚时刻的 %s", want.name, got, wantValue)
}
inputRA, inputDec := basic.HMoonTrueRaDec(timeToTTJDE(want.date))
if math.Abs(maximumRA-inputRA) < 1 {
t.Fatalf("%s: 该用例区分不出输入日期与食甚时刻", want.name)
}
if dec == html.EscapeString(formatSolarEclipseDec(inputDec)) {
t.Errorf("%s: Dec 仍等于输入日期时刻的值 %s", want.name, formatSolarEclipseDec(inputDec))
}
}
}
func lunarGeocentricMoonBlock(t *testing.T, doc string) (string, string) {
t.Helper()
block := regexp.MustCompile(`食甚时的月亮(地心坐标)(.*?)</g>`).FindStringSubmatch(doc)
if block == nil {
t.Fatal("geocentric Moon block not found")
}
value := func(label string) string {
pattern := regexp.MustCompile(regexp.QuoteMeta(label) + `</text><text[^>]*text-anchor="end">([^<]*)</text>`)
match := pattern.FindStringSubmatch(block[1])
if match == nil {
t.Fatalf("row %s not found in the geocentric Moon block", label)
}
return match[1]
}
return value("赤经 R.A."), value("赤纬 Dec.")
}
+253 -21
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/lunarhorizon"
@@ -32,9 +34,20 @@ type LunarEclipseMapSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false.
Projection EclipseMapProjection
// DisablePenumbralPhase 关闭半影阶段,回到只按 P1/P4 分区的四类可见区:零值画半影(补画 U1–U4
// 地平边界、单独着色只看得见半影的月出与月落带「半影月出」「半影月落」,并在时刻行里标出本影接触)。
// DisablePenumbralPhase falls back to the four-way P1/P4-only partition: the zero value draws the
// penumbral phase, adding the U1-U4 horizons, the penumbra-only moonrise and moonset bands as
// separate legend entries, and the umbral contact times.
DisablePenumbralPhase bool
// 空文本字段使用本地化的自动标签。
// Empty text fields use localized automatic labels.
Title string
@@ -71,12 +84,18 @@ func lunarEclipseMapSVG(
if !ok || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
return "", false
}
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLunarEclipseMapSVGOptions(date, options)
projection := internalEclipseMapProjection(options.Projection)
if projection == "" {
projection = svgmap.ProjectionEquirectangular
}
return renderLunarEclipseMapSVG(info, options, projection), true
return renderLunarEclipseMapSVG(lunarEclipseDisplayInfo(info, options.TimeScale), info, options, projection), true
}
func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapSVGOptions) LunarEclipseMapSVGOptions {
@@ -99,10 +118,15 @@ func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapS
func renderLunarEclipseMapSVG(
info eclipsecore.LunarEclipseInfo,
geometry eclipsecore.LunarEclipseInfo,
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
) string {
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92)
if projection == svgmap.ProjectionOrthographic {
// 正射视点取食甚时刻的月下点:视点固定 (0°,0°) 会把半个可见半球让给与本次月食无关的区域。
frame.CenterLongitude, frame.CenterLatitude = lunarEclipseOrthographicCenter(geometry.Maximum)
}
title := options.Title
if title == "" {
date := info.Maximum.In(options.Location).Format("2006-01-02")
@@ -116,8 +140,9 @@ func renderLunarEclipseMapSVG(
var builder strings.Builder
fmt.Fprintf(&builder, `<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))
penumbralBands := lunarEclipseMapDrawsPenumbralBands(info, !options.DisablePenumbralPhase)
builder.WriteString(`<defs>`)
writeLunarEclipseMapDefinitions(&builder, frame, info)
writeLunarEclipseMapDefinitions(&builder, frame, geometry, penumbralBands)
builder.WriteString(`</defs>`)
builder.WriteString(`<rect width="100%" height="100%" fill="#efefed"/>`)
fmt.Fprintf(&builder, `<rect x="22" y="18" width="%d" height="%d" fill="#ffffff" stroke="#c9c9c6" stroke-width="1.2"/>`,
@@ -130,17 +155,28 @@ func renderLunarEclipseMapSVG(
float64(options.Width)/2, html.EscapeString(titleText))
writeLunarEclipseMapSummary(&builder, info, options)
writeLunarEclipseMapRegions(&builder, frame, info)
writeLunarEclipseMapRegions(&builder, frame, geometry, penumbralBands)
frame.WriteFrame(&builder)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language)
writeLunarEclipseMapFooter(&builder, frame, options, projection)
legendRows := lunarEclipseMapLegendRows(frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapLegend(&builder, frame, float64(options.Width), options.Language, penumbralBands)
writeLunarEclipseMapFooter(&builder, frame, options, projection, info.Maximum, len(legendRows))
builder.WriteString(`</svg>`)
return builder.String()
}
// lunarEclipseMapDrawsPenumbralBands 只在该场月食确有本影阶段且开关打开时才区分半影带。
func lunarEclipseMapDrawsPenumbralBands(info eclipsecore.LunarEclipseInfo, includePenumbral bool) bool {
return includePenumbral && info.HasPartial
}
// 可见性由 P1、食甚、P4 三刻的月球地平状态共同界定:全程可见要求三刻都在地平上,
// 只在食甚前后露出地平的地点仍看得见本影食,算带食月落;三刻都不见才算不可见。
func writeLunarEclipseVisibilityMasks(builder *strings.Builder, frame svgmap.Frame) {
writeLunarEclipseVisibilityMask(builder, "lunar-not-visible-mask", frame,
"lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-p4-mask", frame,
"lunar-visible-p1-shape", "lunar-visible-p4-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-maximum-mask", frame, "lunar-visible-maximum-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p4-mask", frame, "lunar-visible-p4-shape")
writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-mask", frame, "lunar-visible-p1-shape")
}
@@ -180,7 +216,7 @@ func eclipseMapFrame(width, height int, projection svgmap.Projection, top, botto
}
func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) {
points := basic.MoonHorizon(basic.Date2JDE(value.UTC()), 360)
points := basic.MoonHorizon(basic.Date2JD(value.UTC()), 360)
boundary := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]}
@@ -204,6 +240,12 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []
}
boundary = lunarhorizon.RefineWithin(boundary, value, tolerance)
}
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return lunarEclipseGeoPolygonPath(boundary, frame), closedBoundary
}
// lunarEclipseGeoPolygonPath 把经纬度环投影成 SVG 面路径,反经线与画布边缘按共享片段切开。
func lunarEclipseGeoPolygonPath(boundary []svgmap.GeoPoint, frame svgmap.Frame) string {
var builder strings.Builder
for _, fragment := range svgmap.PolygonFragments(boundary, frame.Clip()) {
if frame.IsPolar() {
@@ -211,8 +253,7 @@ func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []
}
appendEclipseMapPolygonPathConsistent(&builder, frame, fragment)
}
closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0])
return builder.String(), closedBoundary
return builder.String()
}
func lunarEclipsePolarHorizonClosure(points []svgmap.GeoPoint) []svgmap.GeoPoint {
@@ -313,14 +354,44 @@ func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.Luna
}
fmt.Fprintf(builder, `<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(text))
if line := lunarEclipseMapUmbralContactLine(info, options); line != "" {
fmt.Fprintf(builder, `<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(line))
}
}
// lunarEclipseMapUmbralContactLine 给出本影接触时刻行;开关关闭或没有本影阶段时返回空串。
func lunarEclipseMapUmbralContactLine(info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions) string {
if options.DisablePenumbralPhase || !info.HasPartial {
return ""
}
parts := []string{"U1 " + info.PartialStart.In(options.Location).Format("15:04:05")}
if info.HasTotal {
parts = append(parts,
"U2 "+info.TotalStart.In(options.Location).Format("15:04:05"),
"U3 "+info.TotalEnd.In(options.Location).Format("15:04:05"))
}
parts = append(parts, "U4 "+info.PartialEnd.In(options.Location).Format("15:04:05"))
prefix := "本影阶段 "
if options.Language == "en" {
prefix = "Umbral phases "
}
return prefix + strings.Join(parts, " | ")
}
// lunarEclipseMapLegendRows 给出可见性图例的标注位次:每行按实测文本宽度排布并居中在地图框下方。
func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string) [][]solarEclipseCardinalLabel {
func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language string, penumbralBands bool) [][]solarEclipseCardinalLabel {
labels := []string{"全程可见", "带食月出", "带食月落", "不可见"}
if language == "en" {
labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"}
}
if penumbralBands {
moonrise, moonset := "半影月出", "半影月落"
if language == "en" {
moonrise, moonset = "Penumbra moonrise", "Penumbra moonset"
}
labels = []string{labels[0], labels[1], moonrise, labels[2], moonset, labels[3]}
}
width := func(text string) float64 {
return lunarEclipseDetailedMapLegendIconWidth +
svgchart.EstimatedTextWidth(text, lunarEclipseDetailedMapLegendFontSize)
@@ -378,10 +449,13 @@ func lunarEclipseMapLegendRows(frame svgmap.Frame, canvasWidth float64, language
return rows
}
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string) {
func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, canvasWidth float64, language string, penumbralBands bool) {
colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"}
if penumbralBands {
colors = []string{"#5e846d", "#4e9da0", "#7488cc", "#e2aa4b", "#ab7fb5", "#747b7d"}
}
index := 0
for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language) {
for _, row := range lunarEclipseMapLegendRows(frame, canvasWidth, language, penumbralBands) {
for _, entry := range row {
color := colors[0]
if index < len(colors) {
@@ -401,6 +475,8 @@ func writeLunarEclipseMapFooter(
frame svgmap.Frame,
options LunarEclipseMapSVGOptions,
projection svgmap.Projection,
instant time.Time,
legendRows int,
) {
text := options.FooterNote
if text == "" {
@@ -410,17 +486,28 @@ func writeLunarEclipseMapFooter(
text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。"
}
}
baseline := float64(options.Height) - 38
// 页脚末行要落在白色图框内:图框在画布内缩 18px,11px 字的末行基线留 26px(框线 + 间隙 + 字下伸部)。
const footerBottomPadding = 28.0
// 首行基线再让出一个字下伸部,末行才刚好落在留白之上(BaselineLineLimit 按行下沿计行)。
baseline := float64(options.Height) - footerBottomPadding - svgchart.FooterLineHeight(11) - 4
// 图例换成多行时页脚要跟着下移,否则窄画布上两行文字会叠在一起;下行仍留在图框内。
if legendRows > 1 {
if shifted := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 15; shifted > baseline {
baseline = math.Min(shifted, float64(options.Height)-footerBottomPadding)
}
}
// 默认说明是单行;调用方文本按图框宽度折行,行数按画布底边截断,首行位置不变。
note := timenote.Scale(options.TimeScale, instant, options.Location, options.Language)
lines := []string{text}
if options.FooterNote != "" {
maxWidth := float64(options.Width) - frame.X - 24
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 11), maxWidth, 11,
svgchart.BaselineLineLimit(11, 15, baseline, float64(options.Height)-4))
lines = svgchart.WrapText(options.FooterNote, float64(options.Width)-frame.X-24, 11)
}
lines = svgchart.FooterLinesWithScale(lines, note, float64(options.Width)-frame.X-24, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline,
float64(options.Height)-footerBottomPadding))
for index, line := range lines {
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, baseline+float64(index)*15, html.EscapeString(line))
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
}
}
@@ -432,9 +519,17 @@ func normalizeDegree180(value float64) float64 {
return value - 180
}
// lunarEclipseOrthographicCenter 给出月食正射图的视点:食甚时刻的月下点(月球位于天顶的地点)。
func lunarEclipseOrthographicCenter(at time.Time) (float64, float64) {
jd := basic.Date2JD(at.UTC())
tt := basic.UTC2TT(jd)
ra, dec := basic.HMoonTrueRaDec(tt)
return normalizeDegree180(ra - basic.ApparentSiderealTime(basic.TT2UT1(tt))*15), dec
}
// writeLunarEclipseMapDefinitions 输出四类可见性区域所需的形状与掩膜定义。
// 独立全球图和 详细版式组合图共用,形状按传入的图框重建。
func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo, penumbralBands bool) {
b.WriteString(frame.ClipDefinition("lunar-map-clip"))
startPath, _ := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
endPath, _ := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
@@ -445,10 +540,96 @@ func writeLunarEclipseMapDefinitions(b *strings.Builder, frame svgmap.Frame, inf
b.WriteString(`<clipPath id="lunar-visible-p4"><use href="#lunar-visible-p4-shape"/></clipPath>`)
b.WriteString(`<clipPath id="lunar-visible-maximum"><use href="#lunar-visible-maximum-shape"/></clipPath>`)
writeLunarEclipseVisibilityMasks(b, frame)
if penumbralBands {
writeLunarEclipseUmbralVisibilityShapes(b, frame, info)
}
}
// writeLunarEclipseMapRegions 画底图与四类可见性区域,末尾补两条地平边界线。
func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
// writeLunarEclipseUmbralVisibilityShapes 输出 U1、U4 的月球可见半球形状,以及"本影阶段任意时刻
// 在地平上"的取样半球、裁剪路径与排除掩膜;底带取并集内部,半影带取外部。
func writeLunarEclipseUmbralVisibilityShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
u1Path, _ := lunarEclipseVisibilityPath(info.PartialStart, frame)
u4Path, _ := lunarEclipseVisibilityPath(info.PartialEnd, frame)
fmt.Fprintf(b, `<path id="lunar-visible-u1-shape" d="%s"/>`, u1Path)
fmt.Fprintf(b, `<path id="lunar-visible-u4-shape" d="%s"/>`, u4Path)
b.WriteString(`<clipPath id="lunar-visible-u1"><use href="#lunar-visible-u1-shape"/></clipPath>`)
b.WriteString(`<clipPath id="lunar-visible-u4"><use href="#lunar-visible-u4-shape"/></clipPath>`)
writeLunarEclipseUmbralVisibleShapes(b, frame, info)
}
// writeLunarEclipseUmbralVisibleShapes 输出"本影阶段任意时刻在地平上"的取样半球:本影区间按固定档位
// 取样,每一刻的可见半球都是一个精确的圆盘(按经纬度插值会把近乎子午线方向的边界切进真实区域——
// 实测 1932-03-22 西经 75.5° 一带上边界 0.5° 经度内从 +19.9° 掉到 −27.2°,1° 列的弦会切掉十几度)。
// 这些形状同时用于底带的裁剪(取并集)与半影带的掩膜(取补集),两侧严丝合缝。
func writeLunarEclipseUmbralVisibleShapes(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
instants := lunarEclipseUmbralVisibilityInstants(info)
if len(instants) == 0 {
// 兜底:算不出本影可见区时至少挖掉两端接触(宁可少扣,也不让底带无裁剪地铺满)。
b.WriteString(`<clipPath id="lunar-visible-umbral"><use href="#lunar-visible-u1-shape"/><use href="#lunar-visible-u4-shape"/></clipPath>`)
writeLunarEclipseVisibilityMask(b, "lunar-not-umbral-visible-mask", frame,
"lunar-visible-u1-shape", "lunar-visible-u4-shape")
return
}
// 两端复用已经写好的精细半球形状,中间的取样用粗轮廓:单个圆盘在球面上的偏差约 0.035°,
// 在 1200 像素宽的世界图上不到 0.1 像素,而每个形状能省下三分之二的字节。
ids := []string{"lunar-visible-u1-shape"}
for index := 1; index < len(instants)-1; index++ {
id := fmt.Sprintf("lunar-visible-umbral-%d", index)
fmt.Fprintf(b, `<path id="%s" d="%s"/>`, id, lunarEclipseMaskHorizonPath(instants[index], frame))
ids = append(ids, id)
}
ids = append(ids, "lunar-visible-u4-shape")
var clip strings.Builder
clip.WriteString(`<clipPath id="lunar-visible-umbral">`)
for _, id := range ids {
fmt.Fprintf(&clip, `<use href="#%s"/>`, id)
}
clip.WriteString(`</clipPath>`)
b.WriteString(clip.String())
writeLunarEclipseVisibilityMask(b, "lunar-not-umbral-visible-mask", frame, ids...)
}
// lunarEclipseMaskHorizonPath 掩膜用的可见半球轮廓:90 点基础圆,等距圆柱投影下再按 0.2° 容差
// 细化,避免极区一段地平弧投影后横跨近 180° 经度、弦切出地平线以外的假可见帽。
func lunarEclipseMaskHorizonPath(value time.Time, frame svgmap.Frame) string {
points := basic.MoonHorizon(basic.Date2JD(value.UTC()), 90)
boundary := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
boundary[index] = svgmap.GeoPoint{Longitude: point[0], Latitude: point[1]}
}
if frame.Projection == svgmap.ProjectionEquirectangular {
boundary = lunarhorizon.RefineWithin(boundary, value, 0.2)
}
return lunarEclipseGeoPolygonPath(boundary, frame)
}
// lunarEclipseUmbralVisibilityInstants 本影区间按 15 分钟档位取样,至少两端、至多 12 刻:中间取样用
// 粗轮廓,圆盘分辨率约 0.035°,合起来残余的掠射窗口深度约 0.05°(约 0.15 像素);要逐点精确判定
// 请用站点 API 或 GeoJSON。
func lunarEclipseUmbralVisibilityInstants(info eclipsecore.LunarEclipseInfo) []time.Time {
const (
step = 15 * time.Minute
limit = 12
)
duration := info.PartialEnd.Sub(info.PartialStart)
if duration <= 0 {
return nil
}
count := int(duration/step) + 1
if count < 2 {
count = 2
}
if count > limit {
count = limit
}
instants := make([]time.Time, count)
for index := range instants {
instants[index] = info.PartialStart.Add(duration * time.Duration(index) / time.Duration(count-1))
}
return instants
}
func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo, penumbralBands bool) {
_, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame)
_, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame)
frame.WriteOcean(b)
@@ -456,10 +637,61 @@ func writeLunarEclipseMapRegions(b *strings.Builder, frame svgmap.Frame, info ec
frame.WriteLand(b, "lunar-map-clip")
fmt.Fprintf(b, `<g class="lunar-visibility-regions" data-source="%s" clip-path="url(#lunar-map-clip)">`, eclipseMapSourceLunarVisibilityRegions)
writeLunarEclipseUnavailableRegion(b, frame)
b.WriteString(`<use class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/>`)
b.WriteString(`<use class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#4e9da0" fill-opacity="0.34"/>`)
writeLunarEclipseBaseRegion(b, penumbralBands, "lunar-visible-umbral",
`<use class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/>`)
writeLunarEclipseBaseRegion(b, penumbralBands, "lunar-visible-umbral",
`<use class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#4e9da0" fill-opacity="0.34"/>`)
b.WriteString(`<g clip-path="url(#lunar-visible-p4)"><g clip-path="url(#lunar-visible-maximum)"><use class="entire-eclipse-region" href="#lunar-visible-p1-shape" fill="#5e846d" fill-opacity="0.34"/></g></g>`)
// 两端可见、食甚却在地平下的高纬月落-月出带:两端可见推不出全程可见,归入带食月落。
b.WriteString(`<g clip-path="url(#lunar-visible-p4)"><g mask="url(#lunar-not-maximum-mask)"><use class="visible-at-p1-below-maximum moonset-region" href="#lunar-visible-p1-shape" fill="#e2aa4b" fill-opacity="0.34"/></g></g>`)
// 两端都在地平下、只有食甚前后在地平上的极区透镜:月落必然落在食甚之后,与带食月落同类着色。
b.WriteString(`<g mask="url(#lunar-not-p1-p4-mask)"><use class="eclipse-at-maximum moonset-region" href="#lunar-visible-maximum-shape" fill="#e2aa4b" fill-opacity="0.34"/></g>`)
if penumbralBands {
writeLunarEclipsePenumbralRegions(b)
}
b.WriteString(`</g>`)
writeEclipseMapGeoLine(b, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries)
writeEclipseMapGeoLine(b, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries)
if penumbralBands {
writeLunarEclipseUmbralHorizonLines(b, frame, info)
}
}
// writeLunarEclipseBaseRegion 画一条月出/月落底带;半影带开启时只保留"本影阶段真的能看见"的部分,
// 半影段交给半影带:两条带用同一区域的正反面裁切,既不叠色也不留缝。
func writeLunarEclipseBaseRegion(b *strings.Builder, penumbralBands bool, clip, element string) {
if penumbralBands {
fmt.Fprintf(b, `<g clip-path="url(#%s)">%s</g>`, clip, element)
return
}
b.WriteString(element)
}
// writeLunarEclipsePenumbralRegions 只着色只看得见半影的两条带(月落侧与月出侧):主端在地平上,
// 本影阶段整段在地平下,再扣掉另一端的可见区(极区下中天会让两端同时可见)。
func writeLunarEclipsePenumbralRegions(b *strings.Builder) {
b.WriteString(`<g mask="url(#lunar-not-umbral-visible-mask)"><use class="penumbra-only-region penumbra-moonset-region" mask="url(#lunar-not-p4-mask)" href="#lunar-visible-p1-shape" fill="#ab7fb5" fill-opacity="0.34"/></g>`)
b.WriteString(`<g mask="url(#lunar-not-umbral-visible-mask)"><use class="penumbra-only-region penumbra-moonrise-region" mask="url(#lunar-not-p1-mask)" href="#lunar-visible-p4-shape" fill="#7488cc" fill-opacity="0.34"/></g>`)
}
// writeLunarEclipseUmbralHorizonLines 补画本影接触点的地平边界,边界之间就是各阶段被月出月落切掉的区间。
func writeLunarEclipseUmbralHorizonLines(b *strings.Builder, frame svgmap.Frame, info eclipsecore.LunarEclipseInfo) {
type horizonContact struct {
class string
at time.Time
}
contacts := []horizonContact{{"u1-horizon", info.PartialStart}}
if info.HasTotal {
contacts = append(contacts, horizonContact{"u2-horizon", info.TotalStart}, horizonContact{"u3-horizon", info.TotalEnd})
}
contacts = append(contacts, horizonContact{"u4-horizon", info.PartialEnd})
for _, contact := range contacts {
if contact.at.IsZero() {
continue
}
_, boundary := lunarEclipseVisibilityPath(contact.at, frame)
writeEclipseMapGeoLine(b, frame, boundary, contact.class, "#5f4b8b", 1.1, "3 3", "lunar-map-clip", eclipseMapSourceLunarHorizonBoundaries)
}
}
// lunarEclipseInfoUT1Labels 把民用时刻换成同一物理时刻的 UT1 时刻。零值 time.Time 表示该阶段
+149
View File
@@ -0,0 +1,149 @@
package svg
import (
"fmt"
"math"
"regexp"
"strconv"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
// 月食正射图的两条契约:
// 1. 视点取食甚时刻的月下点("食甚可见半球"的边界就是圆盘边缘),否则视点固定在 (0°,0°),
// 会把半个可见半球让给与本次月食无关的区域;
// 2. 去掉圆盘边缘 2 px 内的固有亚像素带后,四类分区的着色必须与三刻地平几何一致。
func TestLunarEclipseMapSVGOrthographicCentering(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
// 本用例核对投影几何与四类可见区的物理判据,半影带另有覆盖性用例。
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC,
Projection: EclipseMapProjectionOrthographic, DisablePenumbralPhase: true,
})
if !ok {
t.Fatalf("%s: missing orthographic map", date.Format("2006-01-02"))
}
centreX, centreY, radius := lunarOrthographicDisc(t, doc)
partition := lunarMapPartitionOf(t, doc)
// 食甚可见半球的边界是视界大圆:它的投影环应当以圆盘中心为圆心。
ringCentroidX, ringCentroidY, ok := lunarOrthographicRingCentroid(partition, "lunar-visible-maximum-shape")
if !ok {
t.Fatalf("%s: no maximum-visible ring", date.Format("2006-01-02"))
}
if offset := math.Hypot(ringCentroidX-centreX, ringCentroidY-centreY); offset > 1.5 {
t.Errorf("%s: greatest-visible ring is %.3f px off the disc centre", date.Format("2006-01-02"), offset)
}
if err := lunarOrthographicInteriorMatchesPhysics(t, date, doc, partition, centreX, centreY, radius); err != nil {
t.Errorf("%s: %v", date.Format("2006-01-02"), err)
}
}
}
// lunarOrthographicDisc 从图内读出圆盘圆心与半径。
func lunarOrthographicDisc(t *testing.T, doc string) (float64, float64, float64) {
t.Helper()
match := regexp.MustCompile(`<circle class="map-frame" cx="([-0-9.]+)" cy="([-0-9.]+)" r="([-0-9.]+)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatal("disc frame not found")
}
x, _ := strconv.ParseFloat(match[1], 64)
y, _ := strconv.ParseFloat(match[2], 64)
radius, _ := strconv.ParseFloat(match[3], 64)
return x, y, radius
}
// lunarOrthographicRingCentroid 给出投影环顶点的均值,用作"该环是否为以盘心为圆心的圆"的判据。
func lunarOrthographicRingCentroid(partition lunarPartition, shape string) (float64, float64, bool) {
rings := partition.shapes[shape]
if len(rings) == 0 || len(rings[0]) == 0 {
return 0, 0, false
}
sumX, sumY := 0.0, 0.0
for _, vertex := range rings[0] {
sumX += vertex[0]
sumY += vertex[1]
}
return sumX / float64(len(rings[0])), sumY / float64(len(rings[0])), true
}
// lunarOrthographicInteriorMatchesPhysics 逐格比对物理分类与着色,只豁免盘缘 2 px 内的固有亚像素带:
// 越靠近视界,地平线在屏幕上越被压扁,整条边界都缩进不到一个像素,任何多边形表示在那里都无分辨率可言。
func lunarOrthographicInteriorMatchesPhysics(
t *testing.T,
date time.Time,
doc string,
partition lunarPartition,
centreX, centreY, radius float64,
) error {
t.Helper()
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatal("missing eclipse")
}
frame := eclipseMapFrame(960, 640, svgmap.ProjectionOrthographic, 142, 92)
frame.CenterLongitude, frame.CenterLatitude = lunarEclipseOrthographicCenter(info.Maximum)
counts := map[string]int{}
mismatches := 0
var first string
for longitude := -179.5; longitude < 180; longitude += 1 {
for latitude := -89.5; latitude < 90; latitude += 1 {
x, y, projectable := frame.Project(longitude, latitude)
if !projectable {
continue
}
if math.Hypot(x-centreX, y-centreY) > radius-2 {
continue
}
h1 := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude)
hm := lunarPartitionAltitude(info.Maximum, longitude, latitude)
h4 := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude)
if math.Abs(h1) < 0.05 || math.Abs(hm) < 0.05 || math.Abs(h4) < 0.05 {
continue
}
expected := lunarOrthographicExpectedClass(h1, hm, h4)
category, _ := partition.painted(x, y)
counts[expected]++
if category != expected {
mismatches++
if first == "" {
first = fmt.Sprintf("lon=%s lat=%s h=(%s,%s,%s) expected=%s got=%s",
lunarPartitionFormat(longitude), lunarPartitionFormat(latitude),
lunarPartitionFormat(h1), lunarPartitionFormat(hm), lunarPartitionFormat(h4),
expected, category)
}
}
}
}
for _, class := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset, lunarPartitionCategoryMoonrise} {
if counts[class] == 0 {
t.Errorf("orthographic disc has no %s cell away from the rim", class)
}
}
if mismatches != 0 {
return fmt.Errorf("%d interior cells carry the wrong class, first %s", mismatches, first)
}
return nil
}
func lunarOrthographicExpectedClass(h1, hm, h4 float64) string {
switch {
case h1 > 0 && hm > 0 && h4 > 0:
return lunarPartitionCategoryEntire
case h1 > 0 && h4 > 0:
return lunarPartitionCategoryMoonset
case h1 > 0:
return lunarPartitionCategoryMoonset
case h4 > 0:
return lunarPartitionCategoryMoonrise
case hm > 0:
return lunarPartitionCategoryMoonset
}
return lunarPartitionCategoryUnavailable
}
+457
View File
@@ -0,0 +1,457 @@
package svg
import (
"encoding/xml"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 月食全球可见图的分区契约:四类区域对全球网格穷尽且互斥,"不可见"只覆盖 P1、食甚、P4 三刻都在
// 地平下的地点,"全程可见"恰好等于 P1、食甚、P4 三刻都在地平上的地点。
// Partition contract: the four regions tile the globe exhaustively and exclusively.
const (
lunarPartitionCategoryEntire = "entire"
lunarPartitionCategoryMoonset = "moonset"
lunarPartitionCategoryMoonrise = "moonrise"
lunarPartitionCategoryUnavailable = "unavailable"
lunarPartitionCategoryPenumbra = "penumbra-only"
)
type lunarPartitionLayer struct {
category string
shape string
masks []string
clips []string
}
type lunarPartition struct {
frame svgFrameBox
shapes map[string][][][2]float64
masks map[string][]string
clips map[string][]string
layers []lunarPartitionLayer
}
func lunarMapPartitionOf(t *testing.T, doc string) lunarPartition {
t.Helper()
partition := lunarPartition{
frame: lunarPenumbralTestFrame(t, doc),
shapes: map[string][][][2]float64{},
masks: map[string][]string{},
clips: map[string][]string{},
}
type frame struct {
masks []string
clips []string
}
var stack []frame
inRegions := false
var container, containerID string
decoder := xml.NewDecoder(strings.NewReader(doc))
for {
token, err := decoder.Token()
if err != nil {
break
}
switch typed := token.(type) {
case xml.StartElement:
class := lunarPartitionAttr(typed, "class")
reference := strings.TrimPrefix(lunarPartitionAttr(typed, "href"), "#")
mask := lunarPartitionURLID(lunarPartitionAttr(typed, "mask"))
clip := lunarPartitionURLID(lunarPartitionAttr(typed, "clip-path"))
switch typed.Name.Local {
case "path":
if id := lunarPartitionAttr(typed, "id"); id != "" {
partition.shapes[id] = lunarPenumbralTestRings(t, lunarPartitionAttr(typed, "d"))
}
case "mask", "clipPath":
container, containerID = typed.Name.Local, lunarPartitionAttr(typed, "id")
case "use":
switch container {
case "mask":
if reference != "" {
partition.masks[containerID] = append(partition.masks[containerID], reference)
}
case "clipPath":
if reference != "" {
partition.clips[containerID] = append(partition.clips[containerID], reference)
}
}
case "rect", "circle":
if container == "clipPath" && containerID != "" && partition.clips[containerID] == nil {
partition.clips[containerID] = []string{}
}
case "g":
if strings.Contains(class, "lunar-visibility-regions") {
inRegions = true
}
inherited := frame{}
if len(stack) > 0 {
inherited = stack[len(stack)-1]
}
if mask != "" {
inherited.masks = append(append([]string(nil), inherited.masks...), mask)
}
if clip != "" {
inherited.clips = append(append([]string(nil), inherited.clips...), clip)
}
stack = append(stack, inherited)
}
if inRegions && (typed.Name.Local == "use" || typed.Name.Local == "rect" || typed.Name.Local == "circle") {
current := frame{}
if len(stack) > 0 {
current = stack[len(stack)-1]
}
if mask != "" {
current.masks = append(append([]string(nil), current.masks...), mask)
}
if clip != "" {
current.clips = append(append([]string(nil), current.clips...), clip)
}
partition.layers = append(partition.layers, lunarPartitionLayer{
category: lunarPartitionCategory(class, typed.Name.Local),
shape: reference,
masks: current.masks,
clips: current.clips,
})
}
case xml.EndElement:
if typed.Name.Local == "mask" || typed.Name.Local == "clipPath" {
container, containerID = "", ""
}
if typed.Name.Local == "g" {
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
if len(stack) == 0 {
inRegions = false
}
}
}
}
if len(partition.layers) == 0 {
t.Fatal("visibility region layers not found")
}
return partition
}
func lunarPartitionAttr(element xml.StartElement, name string) string {
for _, attribute := range element.Attr {
if attribute.Name.Local == name {
return attribute.Value
}
}
return ""
}
func lunarPartitionURLID(value string) string {
value = strings.TrimSuffix(strings.TrimPrefix(value, "url(#"), ")")
return value
}
func lunarPartitionCategory(class, element string) string {
switch {
case strings.Contains(class, "entire-eclipse-region"):
return lunarPartitionCategoryEntire
case strings.Contains(class, "penumbra-only-region"):
return lunarPartitionCategoryPenumbra
case strings.Contains(class, "moonset-region"):
return lunarPartitionCategoryMoonset
case strings.Contains(class, "moonrise-region"):
return lunarPartitionCategoryMoonrise
case strings.Contains(class, "eclipse-unavailable-region"):
return lunarPartitionCategoryUnavailable
}
return element + ":" + class
}
// covers 逐点复算一层是否落在该点:形状、外层 clip-path(多形状取并)与嵌套 mask 都要通过,
// mask 里的黑色形状按挖空处理。
func (partition lunarPartition) covers(layer lunarPartitionLayer, x, y float64) bool {
if layer.shape != "" && !partition.inside(layer.shape, x, y) {
return false
}
// clipPath 里的多个形状取并集:落在其中任意一个之内就算通过。
for _, clip := range layer.clips {
shapes := partition.clips[clip]
if len(shapes) == 0 {
continue
}
inside := false
for _, shape := range shapes {
if partition.inside(shape, x, y) {
inside = true
break
}
}
if !inside {
return false
}
}
for _, maskID := range layer.masks {
for _, excluded := range partition.masks[maskID] {
if partition.inside(excluded, x, y) {
return false
}
}
}
return true
}
func (partition lunarPartition) inside(shapeID string, x, y float64) bool {
return lunarPenumbralTestInside(partition.shapes[shapeID], x, y)
}
// painted 返回覆盖该点的最上层区域(SVG 后画的盖住先画的)与覆盖层数。
func (partition lunarPartition) painted(x, y float64) (string, int) {
category, count := "", 0
for _, layer := range partition.layers {
if partition.covers(layer, x, y) {
category, count = layer.category, count+1
}
}
return category, count
}
// partitionGrid 遍历 1°×1° 全球网格(64800 点,格心取 ±0.5°)。
func (partition lunarPartition) grid(visit func(longitude, latitude float64, x, y float64, category string, covered int)) {
for longitude := -179.5; longitude < 180; longitude += 1 {
for latitude := -89.5; latitude < 90; latitude += 1 {
x, y, projectable := partition.frame.Project(longitude, latitude)
if !projectable {
continue
}
category, count := partition.painted(x, y)
visit(longitude, latitude, x, y, category, count)
}
}
}
func lunarPartitionAltitude(at time.Time, longitude, latitude float64) float64 {
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
}
func TestLunarEclipseMapSVGVisibilityRegionsTileTheGlobe(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
} {
// 只检查四类底图的分区:半影带另有覆盖性用例。
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
counts := map[string]int{}
uncovered, covered, multiple := 0, 64800, 0
var firstUncovered string
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
counts[category]++
if category == "" {
uncovered++
if firstUncovered == "" {
firstUncovered = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
}
return
}
if layers > 1 {
multiple++
}
})
t.Logf("%s %s: %v", date.Format("2006-01-02"), countsString(counts), "uncovered="+lunarPartitionFormat(float64(uncovered)))
if uncovered != 0 {
t.Errorf("%s: %d of %d grid points carry no region colour, first at %s",
date.Format("2006-01-02"), uncovered, covered, firstUncovered)
}
if multiple != 0 {
t.Errorf("%s: %d grid points are painted by more than one region layer", date.Format("2006-01-02"), multiple)
}
for _, category := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable} {
if counts[category] == 0 {
t.Errorf("%s: region %s covers no grid point", date.Format("2006-01-02"), category)
}
}
}
}
// 打开半影阶段后,"仅见半影"带与带食月出/带食月落必须互斥:两种半透明底色叠在一起会混出图例里
// 没有的颜色,所以底带要被同源的 U1/U4 形状裁掉;同时裁切不能切出没有着色的缝。
func TestLunarEclipseMapSVGPenumbralPhaseKeepsCoverage(t *testing.T) {
date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC)
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing penumbral-phase map")
}
partition := lunarMapPartitionOf(t, doc)
uncovered, penumbraOnly, doubleTinted := 0, 0, 0
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
if category == "" {
uncovered++
return
}
if category != lunarPartitionCategoryPenumbra {
return
}
penumbraOnly++
for _, layer := range partition.layers {
if layer.category == lunarPartitionCategoryPenumbra || !partition.covers(layer, x, y) {
continue
}
if layer.category == lunarPartitionCategoryMoonset || layer.category == lunarPartitionCategoryMoonrise {
doubleTinted++
return
}
}
})
if uncovered != 0 {
t.Errorf("penumbral-phase map leaves %d grid points without a region colour", uncovered)
}
if penumbraOnly == 0 || doubleTinted != 0 {
t.Errorf("penumbra-only bands cover %d points, %d of them still tinted by a moonrise/moonset band", penumbraOnly, doubleTinted)
}
}
// 与 NASA/EclipseWise 世界图同口径:月球在食甚时刻已在地平上的地点,无论月出月落落在食内哪一段,
// 都属于可见区,绝不能画成"不可见";反之不可见区必须三刻都在地平下。
func TestLunarEclipseMapSVGNotVisibleFollowsHorizon(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
}
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
// 0.02° 容差内的地点落在边界上,任何一类都不算错。
wronglyInvisible, upAtMaximumInvisible, wronglyVisible := 0, 0, 0
var sample string
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
start := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude)
maximum := lunarPartitionAltitude(info.Maximum, longitude, latitude)
end := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude)
if math.Abs(start) < 0.02 || math.Abs(maximum) < 0.02 || math.Abs(end) < 0.02 {
return
}
invisible := category == lunarPartitionCategoryUnavailable
wantInvisible := start < 0 && maximum < 0 && end < 0
if invisible && maximum > 0 {
upAtMaximumInvisible++
if sample == "" {
sample = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
}
}
if invisible != wantInvisible {
wronglyInvisible++
}
if !invisible && start > 0 && maximum > 0 && end > 0 && category != lunarPartitionCategoryEntire {
wronglyVisible++
}
})
t.Logf("%s: 食甚在地平上却画成不可见 %d 点%s;与三刻地平判据不符 %d 点;全程在地平上却不在全程可见区 %d 点",
date.Format("2006-01-02"), upAtMaximumInvisible, sample, wronglyInvisible, wronglyVisible)
if upAtMaximumInvisible != 0 {
t.Errorf("%s: %d grid points whose Moon is above the horizon at greatest eclipse %s are painted as not visible",
date.Format("2006-01-02"), upAtMaximumInvisible, sample)
}
if wronglyInvisible != 0 || wronglyVisible != 0 {
t.Errorf("%s: visibility regions disagree with the horizon at P1/greatest/P4 for %d + %d grid points",
date.Format("2006-01-02"), wronglyInvisible, wronglyVisible)
}
}
}
// "全程可见"必须同时满足 P1、食甚、P4 三刻月亮都在地平上:两端可见推不出中途可见,
// 高纬下中天会让月亮在食甚前后落到地平下,这类点归入带食月落。
// Visible throughout requires the Moon above the horizon at P1, greatest and P4 alike:
// visible at both contacts does not imply visible in between.
func TestLunarEclipseMapSVGEntireRegionRequiresGreatestVisibility(t *testing.T) {
for _, date := range []time.Time{
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
} {
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
}
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok {
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
}
partition := lunarMapPartitionOf(t, doc)
holes, holeArea, missing, mislabelled, partialBand := 0, 0.0, 0, 0, 0
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
inP1 := partition.inside("lunar-visible-p1-shape", x, y)
inP4 := partition.inside("lunar-visible-p4-shape", x, y)
inMaximum := partition.inside("lunar-visible-maximum-shape", x, y)
cell := math.Cos(latitude * math.Pi / 180)
switch {
case inP1 && inP4 && inMaximum && category != lunarPartitionCategoryEntire:
holes++
holeArea += cell
case !(inP1 && inP4 && inMaximum) && category == lunarPartitionCategoryEntire:
missing++
case inP1 && inP4 && !inMaximum && category == lunarPartitionCategoryMoonset:
partialBand++
}
// 几何复核:三刻中任一刻月亮在地平下(留 0.05° 余量)就不得涂成全程可见。
if category == lunarPartitionCategoryEntire {
if lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) < -0.05 ||
lunarPartitionAltitude(info.Maximum, longitude, latitude) < -0.05 ||
lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) < -0.05 {
mislabelled++
}
}
})
t.Logf("%s: p1∩p4∩max 内未着全程可见色 %d 点(%.1f 平方度),全程可见区越界 %d 点,中间不可见带 %d 点,误标 %d 点",
date.Format("2006-01-02"), holes, holeArea, missing, partialBand, mislabelled)
if holes != 0 || missing != 0 || mislabelled != 0 {
t.Errorf("%s: entire-eclipse region is not exactly p1∩p4∩max (holes=%d, extra=%d, mislabelled=%d)",
date.Format("2006-01-02"), holes, missing, mislabelled)
}
}
}
func lunarPartitionFormat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func countsString(counts map[string]int) string {
keys := []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable, "", lunarPartitionCategoryPenumbra}
parts := make([]string, 0, len(keys))
for _, key := range keys {
if counts[key] == 0 {
continue
}
name := key
if name == "" {
name = "uncovered"
}
parts = append(parts, name+"="+lunarPartitionFormat(float64(counts[key])))
}
return strings.Join(parts, " ")
}
+1 -1
View File
@@ -35,7 +35,7 @@ func TestLunarEclipseMapSVGPolarWitnessStaysOutsideEquirectangularVisibility(t *
if len(rings) == 0 {
t.Fatal("visibility path has no rings")
}
jd := basic.Date2JDE(info.PenumbralStart)
jd := basic.Date2JD(info.PenumbralStart)
// 只取足够深入地平线以下的见证点:−89.9° 处月心高度约 −0.054°,而更靠近极点的
// −89.99° 只有约 −0.004°,落在可见性判定本身的数值容差里,不能作为回归依据。
// Only a witness clearly below the horizon is used: at -89.9 degrees the Moon centre sits
+15 -3
View File
@@ -47,11 +47,13 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
for _, want := range []string{
`mask id="lunar-not-visible-mask"`,
`mask id="lunar-not-p1-p4-mask"`,
`mask id="lunar-not-p4-mask"`,
`mask id="lunar-not-p1-mask"`,
`class="eclipse-unavailable-region" mask="url(#lunar-not-visible-mask)"`,
`class="visible-at-p1 moonset-region" mask="url(#lunar-not-p4-mask)"`,
`class="visible-at-p4 moonrise-region" mask="url(#lunar-not-p1-mask)"`,
`class="eclipse-at-maximum moonset-region" href="#lunar-visible-maximum-shape"`,
} {
if !strings.Contains(diagram, want) {
t.Fatalf("lunar-eclipse map does not render exclusive visibility regions: missing %q", want)
@@ -60,8 +62,18 @@ func TestLunarEclipseMapSVGUsesExclusiveVisibilityLayers(t *testing.T) {
if strings.Contains(diagram, `class="entire-eclipse-region"`) && strings.Contains(diagram, `fill-opacity="0.70"`) {
t.Fatal("entire-eclipse region still uses the opaque stacked-overlay style")
}
if !strings.Contains(diagram, `clipPath id="lunar-visible-maximum"`) {
t.Fatal("entire-eclipse region is not constrained by greatest-eclipse visibility")
// 全程可见区必须同时受 P1、P4 与食甚可见区裁剪:两端可见推不出中途可见,
// 中间落到地平下的高纬带另画带食月落,不能再被算成全程可见。
if !strings.Contains(diagram, `<clipPath id="lunar-visible-maximum"><use href="#lunar-visible-maximum-shape"/></clipPath>`) ||
!strings.Contains(diagram, `<g clip-path="url(#lunar-visible-p4)"><g clip-path="url(#lunar-visible-maximum)"><use class="entire-eclipse-region"`) {
t.Fatal("entire-eclipse region is not cut by greatest-eclipse visibility")
}
if !strings.Contains(diagram, `<g mask="url(#lunar-not-maximum-mask)"><use class="visible-at-p1-below-maximum moonset-region"`) {
t.Fatal("partly visible moonset band is missing")
}
if !strings.Contains(diagram, `mask id="lunar-not-visible-mask" maskUnits="userSpaceOnUse"`) ||
!strings.Contains(diagram, `<use href="#lunar-visible-maximum-shape" fill="#000000"/>`) {
t.Fatal("not-visible region is not bounded by greatest-eclipse visibility")
}
}
@@ -99,7 +111,7 @@ func TestLunarEclipseVisibilityPathMatchesTopocentricHorizon(t *testing.T) {
if len(rings) == 0 || len(rings) > 3 {
t.Fatalf("projection=%s subpaths=%d", projection, len(rings))
}
jd := basic.Date2JDE(at)
jd := basic.Date2JD(at)
for _, point := range boundary {
if altitude := basic.HMoonHeight(jd, point.Longitude, point.Latitude, 0); math.Abs(altitude) > 1e-9 {
t.Fatalf("horizon altitude=%g", altitude)
+20 -6
View File
@@ -4,6 +4,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
@@ -21,8 +22,11 @@ const (
LunarEclipseTotal = eclipsecore.LunarEclipseTotal
)
func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.Location) LunarEclipseInfo {
return LunarEclipseInfo{
func lunarEclipseInfoFromBasic(
result basic.LunarEclipseResult,
location *time.Location,
) LunarEclipseInfo {
info := LunarEclipseInfo{
Type: mapBasicLunarEclipseType(result.Type),
PenumbralMagnitude: result.PenumbralMagnitude,
UmbralMagnitude: result.Magnitude,
@@ -38,6 +42,16 @@ func lunarEclipseInfoFromBasic(result basic.LunarEclipseResult, location *time.L
HasPartial: result.HasPartial,
HasTotal: result.HasTotal,
}
return info
}
// lunarEclipseDisplayInfo 给出写图用的时刻副本;几何必须用未换算的 UTC 时刻,
// 否则把 UT1 读数当民用时刻会平移地平线、月下点与地心坐标块。
func lunarEclipseDisplayInfo(info LunarEclipseInfo, scale astro.TimeScale) LunarEclipseInfo {
if scale == astro.TimeScaleUT1 {
return eclipsecore.LunarEclipseInfoInUT1(info)
}
return info
}
func lunarEclipseContactPointsFromBasic(
@@ -128,13 +142,13 @@ func ttJDEToTime(ttJDE float64, location *time.Location) time.Time {
if ttJDE == 0 {
return time.Time{}
}
utcJDE := basic.TD2UT(ttJDE, false)
return basic.JDE2DateByZone(utcJDE, location, false)
utcJD := basic.TT2UTC(ttJDE)
return basic.JD2DateByZone(utcJD, location, false)
}
func timeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
utcJD := basic.Date2JD(date.UTC())
return basic.UTC2TT(utcJD)
}
func normalizeDegree360(angle float64) float64 {
+419
View File
@@ -0,0 +1,419 @@
package svg
import (
"crypto/md5"
"encoding/hex"
"math"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
// 半影阶段开关的契约:零值输出与不区分半影阶段的基线逐字节一致,打开后补画 U1–U4 地平边界、
// 单独着色只看得见半影的月出月落带,且每条边界仍落在它自己的接触时刻上。
// Contract of the penumbral-phase switch: DisablePenumbralPhase reproduces the penumbral-agnostic baseline
// byte for byte, while the enabled value adds the U1-U4 horizons and the penumbra-only bands, with
// every boundary still sitting on its own contact instant.
var lunarPenumbralPhaseBaseline = []struct {
name string
digest string
length int
}{
{"2026-03-03 全食 960x640 zh CST", "4e83ea42b18c703bdde6715f572169d5", 243483},
{"2026-03-03 全食 960x640 en CST", "21696a72ec3bc77a21752e2d675a230b", 243544},
{"2026-03-03 全食 1200x800 zh UTC", "e5a72a8f0b73449f85b4e433f6054069", 247413},
{"2026-03-03 全食 1200x800 en UTC", "1a0386abae194bdbee0bd5c0cb7a5a15", 247482},
{"2025-03-14 全食 960x640 zh CST", "84c39bb9f893678c1cdf354ba8acf850", 250237},
{"2025-03-14 全食 1200x800 zh UTC", "dad36f434ba2bfd7369a430b6f00e567", 255213},
{"2026-03-03 北极投影 zh", "8ee2f75f0f7c617b136576635d908cb4", 159000},
{"2026-03-03 南极投影 zh", "931e502692cdc5b0d09e8f91629f1b2f", 84934},
{"2016-08-18 半影 1200x800 zh UTC", "6d9c60787cc1b703abdd13c4b75a2a92", 241387},
{"2029-01-01 全食 960x640 en CST", "95c0a9d618bda4c0510fa827e104ceaa", 231567},
{"2026-03-03 详细版 zh", "31795334a05621f3a5a0beff1b63b5bd", 270844},
{"2026-03-03 详细版 en", "c5ee0c76f875c3c5fe4a7ff29a90a653", 270992},
{"2029-01-01 详细版 zh", "b0521aa165454262df4344168c9100e9", 257156},
{"2016-08-18 详细版 en", "0c5fdc6c04f993504ae970c2e8861ccb", 261488},
}
func TestLunarEclipseMapSVGDisablePenumbralPhaseMatchesBaseline(t *testing.T) {
render := lunarPenumbralPhaseBaselineRenderers()
if len(render) != len(lunarPenumbralPhaseBaseline) {
t.Fatalf("baseline table has %d cases, renderers %d", len(lunarPenumbralPhaseBaseline), len(render))
}
for _, want := range lunarPenumbralPhaseBaseline {
draw, ok := render[want.name]
if !ok {
t.Fatalf("baseline case %q has no renderer", want.name)
}
doc, rendered := draw()
if !rendered {
t.Fatalf("%s: chart missing", want.name)
}
sum := md5.Sum([]byte(doc))
if got := hex.EncodeToString(sum[:]); got != want.digest || len(doc) != want.length {
t.Fatalf("%s: md5=%s bytes=%d, want md5=%s bytes=%d",
want.name, got, len(doc), want.digest, want.length)
}
}
}
func lunarPenumbralPhaseBaselineRenderers() map[string]func() (string, bool) {
cst := time.FixedZone("CST", 8*3600)
utc := time.UTC
return map[string]func() (string, bool){
"2026-03-03 全食 960x640 zh CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 全食 960x640 en CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 全食 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2026-03-03 全食 1200x800 en UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "en", Location: utc, DisablePenumbralPhase: true})
},
"2025-03-14 全食 960x640 zh CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2025-03-14 全食 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2026-03-03 北极投影 zh": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionNorthPolar, DisablePenumbralPhase: true})
},
"2026-03-03 南极投影 zh": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Language: "zh", Location: utc, Projection: EclipseMapProjectionSouthPolar, DisablePenumbralPhase: true})
},
"2016-08-18 半影 1200x800 zh UTC": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, utc),
LunarEclipseMapSVGOptions{Width: 1200, Height: 800, Language: "zh", Location: utc, DisablePenumbralPhase: true})
},
"2029-01-01 全食 960x640 en CST": func() (string, bool) {
return LunarEclipseMapSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 详细版 zh": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2026-03-03 详细版 en": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true})
},
"2029-01-01 详细版 zh": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2029, 1, 1, 0, 0, 0, 0, cst),
LunarEclipseDetailedSVGOptions{Language: "zh", Location: cst, DisablePenumbralPhase: true})
},
"2016-08-18 详细版 en": func() (string, bool) {
return LunarEclipseDetailedSVG(time.Date(2016, 8, 18, 12, 0, 0, 0, utc),
LunarEclipseDetailedSVGOptions{Language: "en", Location: cst, DisablePenumbralPhase: true})
},
}
}
func TestLunarEclipseMapSVGPenumbralPhaseAddsUmbralContacts(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
date := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
options := LunarEclipseMapSVGOptions{Language: "zh", Location: cst}
on, ok := LunarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing penumbral-phase lunar-eclipse map")
}
options.DisablePenumbralPhase = true
off, ok := LunarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing lunar-eclipse map")
}
for _, want := range []string{
`class="u1-horizon"`, `class="u2-horizon"`, `class="u3-horizon"`, `class="u4-horizon"`,
`class="penumbra-only-region penumbra-moonset-region"`, `class="penumbra-only-region penumbra-moonrise-region"`,
`mask id="lunar-not-umbral-visible-mask"`, `clip-path="url(#lunar-visible-umbral)"`,
`id="lunar-visible-umbral-1"`,
`id="lunar-visible-u1-shape"`, `id="lunar-visible-u4-shape"`,
"半影月出", "半影月落", "本影阶段 U1",
} {
if !strings.Contains(on, want) {
t.Fatalf("penumbral-phase map missing %q", want)
}
if strings.Contains(off, want) {
t.Fatalf("default map must not contain %q", want)
}
}
if err := validateEclipseMapXML(on); err != nil {
t.Fatalf("penumbral-phase map is not valid XML: %v", err)
}
for _, id := range []string{"lunar-visible-p1-shape", "lunar-visible-p4-shape", "lunar-visible-maximum-shape"} {
shape := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`)
a, b := shape.FindStringSubmatch(off), shape.FindStringSubmatch(on)
if a == nil || b == nil || a[1] != b[1] {
t.Fatalf("shape %s changed when the penumbral phase is enabled", id)
}
}
english, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Language: "en", Location: cst})
if !ok || !strings.Contains(english, "Penumbra moonrise") || !strings.Contains(english, "Penumbra moonset") ||
!strings.Contains(english, "Umbral phases U1") {
t.Fatal("English penumbral-phase map misses its legend entry or contact row")
}
// 纯半影月食没有本影接触,按 NASA 口径不该凭空生成本影边界。
onlyPenumbral, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing penumbral-only lunar-eclipse map")
}
for _, unwanted := range []string{"u1-horizon", "penumbra-only-region", "半影月出", "半影月落"} {
if strings.Contains(onlyPenumbral, unwanted) {
t.Fatalf("penumbral-only eclipse must not gain %q", unwanted)
}
}
onlyPenumbralDefault, ok := LunarEclipseMapSVG(time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
LunarEclipseMapSVGOptions{Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
if !ok || onlyPenumbralDefault != onlyPenumbral {
t.Fatal("penumbral-only eclipse must render identically with and without the switch")
}
}
// 同判:U1/U4 形状等价于「该接触时刻月球中心在地平线上」,半影带等价于对应的三段高度判据,
// 每条地平边界线都落在自己的接触时刻上(P1 线不能落在 U1 时刻)。
func TestLunarEclipseMapSVGPenumbraBandsMatchHorizonCriterion(t *testing.T) {
for _, date := range []time.Time{
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
} {
info, ok := eclipsecore.LunarEclipseOnDate(date)
if !ok || !info.HasPartial {
t.Fatalf("%s: missing umbral lunar eclipse", date.Format("2006-01-02"))
}
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 1200, Height: 800, Language: "zh", Location: time.UTC})
if !ok {
t.Fatalf("%s: missing penumbral-phase map", date.Format("2006-01-02"))
}
frame := lunarPenumbralTestFrame(t, doc)
p1 := lunarPenumbralTestShape(t, doc, "lunar-visible-p1-shape")
p4 := lunarPenumbralTestShape(t, doc, "lunar-visible-p4-shape")
u1 := lunarPenumbralTestShape(t, doc, "lunar-visible-u1-shape")
u4 := lunarPenumbralTestShape(t, doc, "lunar-visible-u4-shape")
for _, line := range []struct {
class string
at time.Time
other time.Time
}{
{"p1-horizon", info.PenumbralStart, info.PartialStart},
{"p4-horizon", info.PenumbralEnd, info.PartialEnd},
{"u1-horizon", info.PartialStart, info.PenumbralStart},
{"u4-horizon", info.PartialEnd, info.PenumbralEnd},
} {
maxOnContact := 0.0
var otherAltitudes []float64
vertices := lunarPenumbralTestLine(t, doc, line.class)
if len(vertices) < 30 {
t.Fatalf("%s: %s has %d vertices", date.Format("2006-01-02"), line.class, len(vertices))
}
for _, vertex := range vertices {
longitude, latitude := lunarPenumbralTestUnproject(frame, vertex[0], vertex[1])
maxOnContact = math.Max(maxOnContact, math.Abs(lunarPenumbralTestAltitude(line.at, longitude, latitude)))
otherAltitudes = append(otherAltitudes, math.Abs(lunarPenumbralTestAltitude(line.other, longitude, latitude)))
}
if maxOnContact > 0.01 {
t.Fatalf("%s: %s deviates %.4f deg from its own contact", date.Format("2006-01-02"), line.class, maxOnContact)
}
// 两条地平线必然相交于两点,交点是整条线唯一贴近另一组接触的地方;中位数说明其余部分分得很开。
sort.Float64s(otherAltitudes)
median := otherAltitudes[len(otherAltitudes)/2]
if median < 2 {
t.Fatalf("%s: %s sits only %.3f deg (median) from the other contact", date.Format("2006-01-02"), line.class, median)
}
t.Logf("%s %s: 与自身接触最大偏差 %.5f 度,与另一组接触 |alt| 中位数 %.3f 度(最小 %.3f)",
date.Format("2006-01-02"), line.class, maxOnContact, median, otherAltitudes[0])
}
judged, mismatches := 0, 0
for longitude := -179.0; longitude < 180; longitude += 2 {
for latitude := -89.0; latitude < 90; latitude += 2 {
x, y, projectable := frame.Project(longitude, latitude)
if !projectable {
continue
}
altitudes := [4]float64{
lunarPenumbralTestAltitude(info.PenumbralStart, longitude, latitude),
lunarPenumbralTestAltitude(info.PartialStart, longitude, latitude),
lunarPenumbralTestAltitude(info.PartialEnd, longitude, latitude),
lunarPenumbralTestAltitude(info.PenumbralEnd, longitude, latitude),
}
nearHorizon := false
for _, altitude := range altitudes {
if math.Abs(altitude) < 0.05 {
nearHorizon = true
}
}
if nearHorizon {
continue
}
judged++
if lunarPenumbralTestInside(u1, x, y) != (altitudes[1] > 0) {
t.Errorf("%s: U1 shape disagrees with the U1 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude)
}
if lunarPenumbralTestInside(u4, x, y) != (altitudes[2] > 0) {
t.Errorf("%s: U4 shape disagrees with the U4 horizon at (%g,%g)", date.Format("2006-01-02"), longitude, latitude)
}
// SVG 组合规则:月落带 = P1 可见、P4 与 U1 不可见;月出带 = P4 可见、P1 与 U4 不可见。
svgMoonset := lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(u1, x, y)
svgMoonrise := lunarPenumbralTestInside(p4, x, y) && !lunarPenumbralTestInside(p1, x, y) && !lunarPenumbralTestInside(u4, x, y)
wantMoonset := altitudes[0] > 0 && altitudes[1] <= 0 && altitudes[3] <= 0
wantMoonrise := altitudes[3] > 0 && altitudes[2] <= 0 && altitudes[0] <= 0
if svgMoonset != wantMoonset {
mismatches++
}
if svgMoonrise != wantMoonrise {
mismatches++
}
}
}
if judged < 5000 {
t.Fatalf("%s: too few judged samples (%d)", date.Format("2006-01-02"), judged)
}
if mismatches != 0 {
t.Fatalf("%s: penumbra band boundaries disagree with the horizon criterion at %d of %d judged sites",
date.Format("2006-01-02"), mismatches, judged)
}
}
}
func lunarPenumbralTestAltitude(at time.Time, longitude, latitude float64) float64 {
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
}
func lunarPenumbralTestFrame(t *testing.T, doc string) svgFrameBox {
t.Helper()
match := regexp.MustCompile(`<clipPath id="lunar-map-clip"><rect x="([-0-9.]+)" y="([-0-9.]+)" width="([-0-9.]+)" height="([-0-9.]+)"`).FindStringSubmatch(doc)
if match == nil {
// 圆盘版式(极区/正射)画的是圆,用 map-frame 圆的外接方形当图框。
disk := regexp.MustCompile(`<circle class="map-frame" cx="([-0-9.]+)" cy="([-0-9.]+)" r="([-0-9.]+)"`).FindStringSubmatch(doc)
if disk == nil {
t.Fatal("map frame not found")
}
cx, _ := strconv.ParseFloat(disk[1], 64)
cy, _ := strconv.ParseFloat(disk[2], 64)
radius, _ := strconv.ParseFloat(disk[3], 64)
return svgFrameBox{x: cx - radius, y: cy - radius, width: 2 * radius, height: 2 * radius}
}
values := make([]float64, 4)
for index := range values {
values[index], _ = strconv.ParseFloat(match[index+1], 64)
}
return svgFrameBox{x: values[0], y: values[1], width: values[2], height: values[3]}
}
type svgFrameBox struct{ x, y, width, height float64 }
func (frame svgFrameBox) Project(longitude, latitude float64) (float64, float64, bool) {
return frame.x + (longitude+180)/360*frame.width, frame.y + (90-latitude)/180*frame.height, true
}
func lunarPenumbralTestUnproject(frame svgFrameBox, x, y float64) (float64, float64) {
return (x-frame.x)/frame.width*360 - 180, 90 - (y-frame.y)/frame.height*180
}
func lunarPenumbralTestShape(t *testing.T, doc, id string) [][][2]float64 {
t.Helper()
match := regexp.MustCompile(`id="` + id + `" d="([^"]*)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatalf("shape %s not found", id)
}
return lunarPenumbralTestRings(t, match[1])
}
func lunarPenumbralTestLine(t *testing.T, doc, class string) [][2]float64 {
t.Helper()
match := regexp.MustCompile(`class="` + class + `" d="([^"]*)"`).FindStringSubmatch(doc)
if match == nil {
t.Fatalf("boundary %s not found", class)
}
rings := lunarPenumbralTestRings(t, match[1])
if len(rings) == 0 {
t.Fatalf("boundary %s has no vertices", class)
}
return rings[0]
}
func lunarPenumbralTestRings(t *testing.T, path string) [][][2]float64 {
t.Helper()
var rings [][][2]float64
for _, chunk := range strings.Split(path, "Z") {
fields := strings.Fields(chunk)
var ring [][2]float64
for index := 0; index < len(fields); {
if fields[index] == "M" || fields[index] == "L" {
x, _ := strconv.ParseFloat(fields[index+1], 64)
y, _ := strconv.ParseFloat(fields[index+2], 64)
ring = append(ring, [2]float64{x, y})
index += 3
continue
}
index++
}
if len(ring) >= 3 {
rings = append(rings, ring)
}
}
return rings
}
func lunarPenumbralTestInside(rings [][][2]float64, x, y float64) bool {
for _, ring := range rings {
if pointInLunarVisibilityPolygon(x, y, ring) {
return true
}
}
return false
}
// 仅见半影的两条带必须排除"本影区间里月亮曾在地平上"的站点:只挖 U1/U4 两刻会漏掉两刻之间的窗口,
// 按经度插值又会把近乎子午线方向的边界切进真实区域(1932-03-22 西经 75.5° 一带 0.5° 经度内上边界
// 从 +19.9° 掉到 −27.2°)。掩膜按 15 分钟档位取样整球可见半球,残余的掠射窗口深度约 0.05°
// (约 0.15 像素);深度更浅、只持续几分钟的窗口只由站点 API 与 GeoJSON 精确判定。
func TestLunarEclipseMapSVGPenumbralBandsExcludeUmbralWindow(t *testing.T) {
date := time.Date(1932, 3, 22, 0, 0, 0, 0, time.UTC)
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 1200, Height: 800, Language: "zh", Location: time.UTC})
if !ok {
t.Fatal("missing lunar-eclipse map")
}
partition := lunarMapPartitionOf(t, doc)
for _, witness := range []struct {
name string
lon, lat float64
want string
}{
{"U1 已在地平上(+0.0041 度)", -91.75, -88.75, lunarPartitionCategoryMoonrise},
{"U1 高度 +0.21 度(陡边界,1° 列会切进去)", -75.5, 6.5, lunarPartitionCategoryMoonset},
{"本影极值 -2.25 度(宽余量)", -68.75, -70.15, lunarPartitionCategoryPenumbra},
} {
x, y, projectable := partition.frame.Project(witness.lon, witness.lat)
if !projectable {
t.Fatalf("%s: 投影失败", witness.name)
}
category, _ := partition.painted(x, y)
if category != witness.want {
t.Fatalf("%s (%.4f,%.4f) category=%q want %q", witness.name, witness.lon, witness.lat, category, witness.want)
}
}
}
+191
View File
@@ -0,0 +1,191 @@
package svg
import (
"regexp"
"strings"
"testing"
"time"
"b612.me/astro"
eclipsecore "b612.me/astro/eclipse"
)
// UT1 出图:换算不能碰零值(零值表示"该阶段不存在"),且非 UTC 时区应明确失败而不是画错图。
func TestLunarEclipseUT1LabelsPreserveZeroPhases(t *testing.T) {
info := eclipsecore.LunarEclipseInfo{
Maximum: time.Date(2025, 9, 7, 18, 11, 0, 0, time.UTC),
}
got := eclipsecore.LunarEclipseInfoInUT1(info)
if !got.TotalStart.IsZero() {
t.Fatalf("不存在的阶段应保持零值, got %v", got.TotalStart)
}
if delta := got.Maximum.Sub(info.Maximum).Seconds(); delta <= 0 {
t.Fatalf("UT1 时刻应领先民用时刻, got %g 秒", delta)
}
}
// assertUT1Declaration 钉住 UT1 声明必须落在 <text> 元素内,并写出 DUT1 差值。
func assertUT1Declaration(t *testing.T, name, svg string) {
t.Helper()
declaration := "DUT1 = UT1−UTC = "
idx := strings.Index(svg, declaration)
if idx < 0 {
t.Errorf("%s 应在图内声明 UT1 并写出 DUT1 差值", name)
return
}
rest := svg[idx:]
end := strings.Index(rest, "</text>")
if end < 0 || strings.Contains(rest[:end], "<") {
t.Errorf("%s 的 UT1 声明必须落在 <text> 元素内", name)
}
}
func TestLunarEclipseMapSVGTimeScaleOption(t *testing.T) {
date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)
utcSVG, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{})
if !ok {
t.Fatal("UTC 渲染应成功")
}
ut1SVG, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1})
if !ok {
t.Fatal("UT1 渲染应成功")
}
assertUT1Declaration(t, "月食全球图", ut1SVG)
if strings.Contains(utcSVG, "世界时") {
t.Fatal("UTC 图不应带 UT1 图注")
}
cst := time.FixedZone("CST", 8*3600)
if _, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok {
t.Fatal("UT1 配非 UTC 时区应返回 false")
}
if _, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: time.UTC}); !ok {
t.Fatal("UT1 配 UTC 时区应成功")
}
}
// 三个月食入口共用同一个展示副本:不存在的阶段(零值)不得被换算成真实时刻,
// 且展示副本只影响文字,几何仍走原 UTC 数据。
func TestLunarEclipseUT1LabelHelperPreservesZero(t *testing.T) {
real := time.Date(2025, 9, 7, 18, 11, 0, 0, time.UTC)
info := LunarEclipseInfo{Maximum: real, PenumbralStart: real}
display := lunarEclipseDisplayInfo(info, astro.TimeScaleUT1)
if !display.TotalStart.IsZero() {
t.Fatalf("零值阶段应原样保留, got %v", display.TotalStart)
}
if !display.Maximum.After(real) {
t.Fatalf("UT1 时刻应领先民用时刻: %v vs %v", display.Maximum, real)
}
if utc := lunarEclipseDisplayInfo(info, astro.TimeScaleUTC); !utc.Maximum.Equal(real) {
t.Fatalf("UTC 口径不应换算: %v vs %v", utc.Maximum, real)
}
}
func TestLunarEclipseChartTimeScaleGuard(t *testing.T) {
date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)
cst := time.FixedZone("CST", 8*3600)
renderers := map[string]func(time.Time, *time.Location) (string, bool){
"diagram": func(d time.Time, loc *time.Location) (string, bool) {
return LunarEclipseSVG(d, LunarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc})
},
"detailed": func(d time.Time, loc *time.Location) (string, bool) {
return LunarEclipseDetailedSVG(d, LunarEclipseDetailedSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc})
},
"map": func(d time.Time, loc *time.Location) (string, bool) {
return LunarEclipseMapSVG(d, LunarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: loc})
},
}
for name, render := range renderers {
if _, ok := render(date, cst); ok {
t.Errorf("%s: UT1 配非 UTC 时区应返回 false", name)
}
if _, ok := render(date, time.UTC); !ok {
t.Errorf("%s: UT1 配 UTC 时区应成功", name)
}
if _, ok := render(date, nil); !ok {
t.Errorf("%s: UT1 下 nil Location 应被强制为 UTC 并成功", name)
}
}
}
// 日食两族:UT1 选项必须成功、在图注里声明尺度,并且拒绝非 UTC 时区。
func TestSolarEclipseChartTimeScaleOption(t *testing.T) {
date := time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC)
cst := time.FixedZone("CST", 8*3600)
mapSVG, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1})
if !ok {
t.Fatal("日食地图 UT1 渲染应成功")
}
assertUT1Declaration(t, "日食地图", mapSVG)
if _, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok {
t.Error("日食地图 UT1 配非 UTC 时区应返回 false")
}
localSVG, ok := LocalSolarEclipseSVG(date, -96.8, 32.8, 0, LocalSolarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1})
if !ok {
t.Fatal("站心日食 UT1 渲染应成功")
}
assertUT1Declaration(t, "站心日食图", localSVG)
if _, ok := LocalSolarEclipseSVG(date, -96.8, 32.8, 0, LocalSolarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1, Location: cst}); ok {
t.Error("站心日食 UT1 配非 UTC 时区应返回 false")
}
}
// 月食组合图与详细版的图注也要声明尺度。
func TestLunarEclipseChartsDeclareUT1(t *testing.T) {
date := time.Date(2025, time.September, 7, 12, 0, 0, 0, time.UTC)
for name, svg := range map[string]string{
"diagram": func() string {
text, _ := LunarEclipseSVG(date, LunarEclipseSVGOptions{TimeScale: astro.TimeScaleUT1})
return text
}(),
"detailed": func() string {
text, _ := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{TimeScale: astro.TimeScaleUT1})
return text
}(),
} {
if svg == "" {
t.Fatalf("%s: UT1 渲染应成功", name)
}
assertUT1Declaration(t, name, svg)
}
}
// UT1 输出只允许改文字:把 <text> 元素整体去掉后,UTC 与 UT1 的同名图必须逐字节相同。
// 几何一旦用了 UT1 读数(把 UT1 当民用时刻),地平线、月下点与地心坐标块都会平移。
func TestLunarEclipseUT1ChartsKeepGeometry(t *testing.T) {
date := time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC)
renderers := map[string]func(time.Time, astro.TimeScale) (string, bool){
"map": func(at time.Time, scale astro.TimeScale) (string, bool) {
return LunarEclipseMapSVG(at, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Location: time.UTC, TimeScale: scale})
},
"detailed": func(at time.Time, scale astro.TimeScale) (string, bool) {
return LunarEclipseDetailedSVG(at, LunarEclipseDetailedSVGOptions{
Width: 1000, Height: 1414, Location: time.UTC, TimeScale: scale})
},
"diagram": func(at time.Time, scale astro.TimeScale) (string, bool) {
return LunarEclipseSVG(at, LunarEclipseSVGOptions{
Width: 920, Height: 720, Location: time.UTC, TimeScale: scale})
},
}
textElements := regexp.MustCompile(`(?s)<text[^>]*>.*?</text>`)
for name, render := range renderers {
utc, ok := render(date, astro.TimeScaleUTC)
if !ok {
t.Fatalf("%s: UTC render failed", name)
}
ut1, ok := render(date, astro.TimeScaleUT1)
if !ok {
t.Fatalf("%s: UT1 render failed", name)
}
if strings.Contains(ut1, "UT1") == false {
t.Errorf("%s: UT1 chart does not declare the scale", name)
}
utcGeometry := textElements.ReplaceAllString(utc, "")
ut1Geometry := textElements.ReplaceAllString(ut1, "")
if utcGeometry != ut1Geometry {
t.Errorf("%s: switching to UT1 changed chart geometry", name)
}
}
}
+91 -18
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgchart"
@@ -64,6 +66,11 @@ type LocalSolarEclipseSVGOptions struct {
// Location 是图中显示时刻的时区;nil 时使用 UTC+8。
// Location is the display timezone for chart times; nil uses UTC+8.
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
}
type localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult
@@ -106,6 +113,12 @@ func localSolarEclipseSVG(
calculator localSolarEclipseSVGCalculator,
finder localSolarEclipseSVGFinder,
) (string, bool) {
if options.TimeScale == astro.TimeScaleUT1 {
if options.Location != nil && options.Location != time.UTC {
return "", false
}
options.Location = time.UTC
}
options = normalizeLocalSolarEclipseSVGOptions(options)
diagram := calculator(
solarEclipseTimeToTTJDE(date),
@@ -117,7 +130,7 @@ func localSolarEclipseSVG(
if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 {
return "", false
}
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location)
info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location, options.TimeScale)
if finder != nil {
coreInfo := finder(info.GreatestEclipse, lon, lat, height)
info.HasSaros = coreInfo.HasSaros
@@ -153,6 +166,14 @@ func renderLocalSolarEclipseSVG(
headerBottom := localSolarEclipseSVGHeaderBottom(headerTexts)
width := float64(options.Width)
height := float64(options.Height)
direction := localSolarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
scaleNote := timenote.Scale(options.TimeScale, info.GreatestEclipse, options.Location, options.Language)
footerLines := localSolarEclipseSVGFooterLines(direction, localSolarEclipseSVGFooterNoteText(options), scaleNote, width, height, headerBottom)
footerBaselines, footerOccupied := svgchart.FooterBlock(len(footerLines), height, 12,
svgchart.FooterLineHeight(12), localDiagramFooterBottomPadding)
margin := math.Max(30, math.Min(46, width*0.05))
panelWidth := math.Max(230, math.Min(280, width*0.28))
diagramLeft := margin
@@ -160,7 +181,8 @@ func renderLocalSolarEclipseSVG(
if diagramRight-diagramLeft < width*0.48 {
diagramRight = width - margin
}
footerHeight := 72.0
// 页脚与图区之间留一点空,页脚高度按实际行数算出来,说明文字再多也不会顶到画布底边。
footerHeight := footerOccupied + 10
stageHeight := math.Max(160, math.Min(210, height*0.27))
stageTop := height - stageHeight - footerHeight
if stageTop < headerBottom+160 {
@@ -250,22 +272,13 @@ func renderLocalSolarEclipseSVG(
}
writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight)
direction := localSolarEclipseSVGDirectionTextValue(options)
if options.DirectionText != "" {
direction = svgchart.EllipsizeText(direction, width-80, 12)
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, height-54, html.EscapeString(direction))
// 默认说明是单行;调用方文本折行后按画布底边截断,首行位置不变。
lines := []string{localSolarEclipseSVGFooterNoteText(options)}
if options.FooterNote != "" {
maxWidth := width - 80
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, 12), maxWidth, 12,
svgchart.BaselineLineLimit(12, 15, height-34, height-4))
}
for index, line := range lines {
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, height-34+float64(index)*15, html.EscapeString(line))
for index, line := range footerLines {
fill := "#555"
if index == 0 {
fill = "#333"
}
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="%s" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
40.0, footerBaselines[index], fill, html.EscapeString(line))
}
writeLocalSolarEclipseContacts(&b, info, options, panelX, math.Max(154, cy-92))
b.WriteString(`</svg>`)
@@ -359,6 +372,10 @@ func localSolarEclipseSVGDirectionTextValue(options LocalSolarEclipseSVGOptions)
}
func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) string {
return localSolarEclipseSVGFooterNoteTextBase(options)
}
func localSolarEclipseSVGFooterNoteTextBase(options LocalSolarEclipseSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -368,6 +385,62 @@ func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) str
return "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。"
}
// localSolarEclipseSVGFooterLines 折行页脚说明并按图区下限与画布高度截断行数,返回方向行在前的页脚各行。
// localDiagramFooterBottomPadding 是站心图页脚末行基线距画布底边的留白:图框在画布内缩 18px,
// 留白取 18px 框线 + 4px 间隙 + 12px 字的字下伸部,末行因此落在框线之内而不是压在框线上。
const localDiagramFooterBottomPadding = 26.0
func localSolarEclipseSVGFooterLines(direction, noteText, scaleNote string, width, height, headerBottom float64) []string {
const (
fontSize = 12.0
stageFloor = 160.0
stageGap = 10.0
)
lineHeight := svgchart.FooterLineHeight(fontSize)
padding := localDiagramFooterBottomPadding
// 先扣掉底边留白、方向行与图区下限,剩下的才是说明行能用的高度;再按画布高度两成封顶。
maxLines := svgchart.TextLineLimit(fontSize, lineHeight,
height-headerBottom-stageFloor-stageGap-padding-lineHeight)
if cap := int(height * 0.20 / lineHeight); maxLines > cap {
maxLines = cap
}
if maxLines < 1 {
maxLines = 1
}
maxWidth := width - 80
// 默认把时标声明并进说明一起均衡折行(行宽最接近方向说明),在放得下的行数里挑最均衡的一种。
mergedText := strings.TrimSpace(noteText + " " + scaleNote)
lines := svgchart.WrapTextBalanced(mergedText, maxWidth, fontSize)
for slots := 2; slots <= maxLines; slots++ {
candidate := svgchart.WrapTextBalancedLines(mergedText, maxWidth, fontSize, slots)
if len(candidate) > maxLines {
break
}
if solarEclipseLocalFooterSpread(direction, candidate) < solarEclipseLocalFooterSpread(direction, lines) {
lines = candidate
}
}
if len(lines) > maxLines {
// 说明太长折不下:截断说明,把时标声明单独留成最后一行。
notesOnly := svgchart.WrapTextBalanced(noteText, maxWidth, fontSize)
lines = svgchart.FooterLinesWithScale(notesOnly, scaleNote, maxWidth, fontSize, maxLines)
}
return append([]string{direction}, lines...)
}
// solarEclipseLocalFooterSpread 返回页脚各行估算宽度的最大最小比,用于在两种折行方案里取更整齐的一种。
func solarEclipseLocalFooterSpread(direction string, lines []string) float64 {
minimum, maximum := svgchart.EstimatedTextWidth(direction, 12), svgchart.EstimatedTextWidth(direction, 12)
for _, line := range lines {
width := svgchart.EstimatedTextWidth(line, 12)
minimum, maximum = math.Min(minimum, width), math.Max(maximum, width)
}
if minimum <= 0 {
return math.Inf(1)
}
return maximum / minimum
}
func localSolarEclipseSVGHeaderLineY(index int) float64 {
return 86 + float64(index)*23
}
@@ -0,0 +1,129 @@
package svg
import (
"encoding/xml"
"io"
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 站心日食页脚契约:方向说明与补充说明(含时标声明)同字号排成三行,行宽接近、不超可用宽度,
// 且显示时区的偏移只写一次。
const (
solarEclipseLocalFooterDirectionFill = "#333"
solarEclipseLocalFooterNoteFill = "#555"
// 页脚与页眉同色,靠字号区分。
solarEclipseLocalFooterFontSize = 12
)
type solarEclipseLocalFooterText struct {
fill string
fontSize float64
value string
}
func solarEclipseLocalFooterTexts(t *testing.T, diagram string) []solarEclipseLocalFooterText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(diagram))
texts := []solarEclipseLocalFooterText{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("decode SVG: %v", err)
}
element, ok := token.(xml.StartElement)
if !ok || element.Name.Local != "text" {
continue
}
text := solarEclipseLocalFooterText{}
for _, attribute := range element.Attr {
switch attribute.Name.Local {
case "fill":
text.fill = attribute.Value
case "font-size":
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
}
}
if text.fontSize != solarEclipseLocalFooterFontSize {
continue
}
if text.fill != solarEclipseLocalFooterDirectionFill && text.fill != solarEclipseLocalFooterNoteFill {
continue
}
var content string
if err := decoder.DecodeElement(&content, &element); err != nil {
t.Fatalf("decode text element: %v", err)
}
text.value = content
texts = append(texts, text)
}
return texts
}
func solarEclipseLocalFooterDiagram(t *testing.T, language string, location *time.Location) string {
t.Helper()
diagram, ok := LocalSolarEclipseSVG(
time.Date(2012, time.May, 21, 6, 10, 0, 0, time.UTC),
118.0894, 24.4798, 0,
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Language: language, Location: location},
)
if !ok {
t.Fatalf("%s %s: no local solar eclipse diagram", language, location)
}
return diagram
}
func TestLocalSolarEclipseFooterIsEvenAndNamesTheZoneOnce(t *testing.T) {
const width = 920
for _, location := range []*time.Location{
time.FixedZone("UTC+08:00", 8*3600),
time.FixedZone("CST", 8*3600),
} {
for _, language := range []string{"zh", "en"} {
diagram := solarEclipseLocalFooterDiagram(t, language, location)
lines := solarEclipseLocalFooterTexts(t, diagram)
if len(lines) != 3 {
t.Fatalf("%s %s: footer lines = %d, want the direction line plus two note lines (%#v)",
language, location, len(lines), lines)
}
minimum, maximum := math.Inf(1), 0.0
note := []string{}
for _, line := range lines {
lineWidth := svgchart.EstimatedTextWidth(line.value, line.fontSize)
minimum, maximum = math.Min(minimum, lineWidth), math.Max(maximum, lineWidth)
if lineWidth > width-80 {
t.Fatalf("%s %s: footer line %q width %.1f exceeds %d", language, location, line.value, lineWidth, width-80)
}
if strings.Contains(line.value, "…") {
t.Fatalf("%s %s: default footer text is truncated: %q", language, location, line.value)
}
if line.fill == solarEclipseLocalFooterNoteFill {
note = append(note, line.value)
}
}
if spread := maximum / minimum; spread > 1.15 {
t.Fatalf("%s %s: footer spread = %.3f, want <= 1.15 (%#v)", language, location, spread, lines)
}
noteText := strings.Join(note, " ")
if language == "en" {
if !strings.Contains(noteText, "shown in") {
t.Fatalf("en %s: footer note lost the time zone declaration: %q", location, noteText)
}
} else if !strings.Contains(noteText, "显示时区") {
t.Fatalf("zh %s: footer note lost the time zone declaration: %q", location, noteText)
}
if got := strings.Count(noteText, "UTC+08:00"); got != 1 {
t.Fatalf("%s %s: footer note names UTC+08:00 %d times, want once: %q", language, location, got, noteText)
}
}
}
}
+43 -33
View File
@@ -1,12 +1,14 @@
package svg
import (
"b612.me/astro/internal/timenote"
"fmt"
"html"
"math"
"strings"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
@@ -50,6 +52,11 @@ type SolarEclipseMapSVGOptions struct {
// Location 控制显示的事件时刻;nil 使用 date.Location()。
// Location controls displayed event times. Nil uses date.Location().
Location *time.Location
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 改用 UT1 时刻,此时 Location 必须是
// nil 或 UTC(否则返回 false),并在图注里声明尺度。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 uses UT1 labels, requires
// Location to be nil or UTC (otherwise the renderer returns false) and declares the scale in the footer.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值从事件几何中自动选择,不支持的值使渲染器返回 false。
// Projection selects the map projection. The zero value selects one from the event geometry; unsupported values make the renderer return false.
Projection EclipseMapProjection
@@ -159,6 +166,12 @@ func solarEclipseMapSVG(
if !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 = normalizeSolarEclipseMapSVGOptions(date, options)
// 日期门与核心一致:当天没有日食就不出图。偏食足迹是“取最近一次”语义,
// 缺这道门会把邻近日期的图当成当天的图交出去。
@@ -198,14 +211,25 @@ func solarEclipseMapSVG(
if calculators.panel != nil {
geocentric, hasGeocentric = calculators.panel(date)
}
// 几何(直射点、闭合弧、等时线取值)必须用民用时刻算;只有写出的文字换时标。
// 时刻标记按输出时标的整点取点(那里本就是另一个物理时刻),位置随整点标注移动。
civilPartial := partial
if options.TimeScale == astro.TimeScaleUT1 {
global = eclipsecore.SolarEclipseInfoInUT1(global)
greatestTimes = eclipsecore.TimeLabelsInUT1(greatestTimes)
partial = eclipsecore.SolarEclipsePartialFootprintsInUT1(partial)
central = eclipsecore.SolarEclipsePathInUT1(central)
local = eclipsecore.LocalSolarEclipseInfoInUT1(local)
geocentric = eclipsecore.SolarEclipseGeocentricPanelInUT1(geocentric)
}
plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric,
options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial))
// 数据块行距或图例带放不下时拒绝该画布,而不是把压叠的文字画出来。
if !plan.fits() {
return "", false
}
return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric,
options, projection), true
return plan.render(civilPartial, partial, central, hasCentral,
local, hasLocal, geocentric, hasGeocentric, options, projection), true
}
// solarEclipseMapHasCentralBand 报告该事件是否有中心食带(包络、限界或瞬时足迹任一存在)。
@@ -414,10 +438,11 @@ func renderSolarEclipseMapSVG(
) string {
plan := solarEclipseMapPlanFor(partial, local, hasLocal, geocentric, hasGeocentric,
options, projection, center, hasCentral, solarEclipseMapHasCentralBand(partial))
return plan.render(partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection)
return plan.render(partial, partial, central, hasCentral, local, hasLocal, geocentric, hasGeocentric, options, projection)
}
func (plan solarEclipseMapPlan) render(
civilPartial eclipsecore.SolarEclipsePartialFootprintsInfo,
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
@@ -436,7 +461,7 @@ func (plan solarEclipseMapPlan) render(
if options.Title != "" {
titleText = svgchart.EllipsizeText(title, float64(options.Width)-52, 24)
}
partialPath, partialSource := solarEclipsePartialSweepPath(partial, frame)
partialPath, partialSource := solarEclipsePartialSweepPath(civilPartial, frame)
// 一张已放置矩形表:固定元素先占位,地图标注再按候选偏移避让。
labels := &svgchart.LabelTable{}
@@ -465,7 +490,7 @@ func (plan solarEclipseMapPlan) render(
writeSolarEclipseRiseSetCurves(&builder, partial.RiseSetCurves, frame)
writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options, labels)
// 点标注比等值线标注重要:先把食甚、接触、直射点与中心线时刻占到位置,等值线标注再让开。
pointLabels := solarEclipseMapPlacePointLabels(partial, central, hasCentral, frame, options, labels)
pointLabels := solarEclipseMapPlacePointLabels(civilPartial, partial, central, hasCentral, frame, options, labels)
writeSolarEclipseGreatestTimeContours(&builder, partial.GreatestTimeContours, frame, options, labels)
magnitudeAxis, magnitudeNormal, hasMagnitudeAxis := solarEclipseMagnitudeLabelAxis(central)
if !hasMagnitudeAxis {
@@ -769,8 +794,8 @@ func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector {
func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint {
ttJDE := solarEclipseTimeToTTJDE(value)
ra, dec := basic.HSunApparentRaDec(ttJDE)
utJDE := basic.TD2UT(ttJDE, false)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15)
ut1JDE := basic.TT2UT1(ttJDE)
longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(ut1JDE)*15)
return svgmap.GeoPoint{Longitude: longitude, Latitude: dec}
}
@@ -896,7 +921,7 @@ func solarEclipseCentralEnvelopeCoversPath(
return false
}
limitToleranceKM := solarEclipseCentralEnvelopeAxisMissToleranceKM
if width := path.Eclipse.PathWidthKM; width > 0 && !math.IsInf(width, 1) {
if width := path.Eclipse.PathWidthKM; path.Eclipse.PathWidthDefined && width > 0 && !math.IsInf(width, 1) {
limitToleranceKM = math.Max(limitToleranceKM, solarEclipseCentralEnvelopeLimitMissFraction*width)
}
for _, series := range [][]eclipsecore.SolarEclipsePathPoint{path.NorthernLimit, path.SouthernLimit} {
@@ -1107,7 +1132,7 @@ func writeSolarEclipseMapSummary(
summaryX, html.EscapeString(text))
details := make([]string, 0, 4)
if info.HasCentral {
if info.PathWidthDefined {
if options.Language == "en" {
details = append(details, fmt.Sprintf("central path width %.1f km", info.PathWidthKM))
} else {
@@ -1167,33 +1192,12 @@ func writeSolarEclipseMapSummary(
summaryX, conjunctionY, html.EscapeString(conjunctionText))
}
// 图上所有时刻都按展示时区,这里明确写出它与 UT 的偏差,避免被当成 UT 读。
zoneNote := solarEclipseTimeZoneNote(maximum, options.Language)
zoneNote := timenote.Scale(options.TimeScale, maximum, options.Location, options.Language)
labels.ReserveText(summaryX, conjunctionY+20, 11, zoneNote, "middle")
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">%s</text>`,
summaryX, conjunctionY+20, html.EscapeString(zoneNote))
}
func solarEclipseTimeZoneNote(maximum time.Time, language string) string {
name, offsetSeconds := maximum.Zone()
if offsetSeconds == 0 {
if language == "en" {
return "All times are UT"
}
return "图中时刻为 UT"
}
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := offsetSeconds / 3600
minutes := (offsetSeconds % 3600) / 60
if language == "en" {
return fmt.Sprintf("All times are %s (UT%s%02d:%02d)", name, sign, hours, minutes)
}
return fmt.Sprintf("图中时刻为 %s(UT%s%02d:%02d)", name, sign, hours, minutes)
}
func formatSolarEclipseMapDuration(value time.Duration) string {
seconds := int(math.Round(value.Seconds()))
if seconds < 0 {
@@ -1274,6 +1278,10 @@ const (
)
func solarEclipseMapFooterText(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string {
return solarEclipseMapFooterTextBase(options, projection)
}
func solarEclipseMapFooterTextBase(options SolarEclipseMapSVGOptions, projection svgmap.Projection) string {
if options.FooterNote != "" {
return options.FooterNote
}
@@ -1301,11 +1309,13 @@ func writeSolarEclipseMapFooter(
maxWidth := float64(options.Width) - footerX - layout.margin
lines = svgchart.TruncateTextLines(svgchart.WrapText(options.FooterNote, maxWidth, solarEclipseMapFooterFontSize),
maxWidth, solarEclipseMapFooterFontSize,
svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, 15, baseline, float64(options.Height)-4))
svgchart.BaselineLineLimit(solarEclipseMapFooterFontSize, svgchart.FooterLineHeight(solarEclipseMapFooterFontSize),
baseline, float64(options.Height)-svgchart.FooterBottomPadding(solarEclipseMapFooterFontSize)))
}
for index, line := range lines {
fmt.Fprintf(builder, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="%.0f">%s</text>`,
footerX, baseline+float64(index)*15, solarEclipseMapFooterFontSize, html.EscapeString(line))
footerX, baseline+float64(index)*svgchart.FooterLineHeight(solarEclipseMapFooterFontSize),
solarEclipseMapFooterFontSize, html.EscapeString(line))
}
}
+4 -4
View File
@@ -114,16 +114,16 @@ func TestSolarEclipseMapOrthographicIsExplicitOnly(t *testing.T) {
}
}
// 图上时刻都按展示时区,必须显式写出与 UT 的偏差,且 详细版式的所有内容都不得溢出图框。
// 图上时刻必须同时写明时标(UTC)与展示时区相对它的偏差,且详细版式的所有内容都不得溢出图框。
func TestSolarEclipseMapStatesTimeZoneOffset(t *testing.T) {
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
cases := []struct {
location *time.Location
want string
}{
{location: time.UTC, want: "图中时刻为 UT"},
{location: time.FixedZone("CST", 8*3600), want: "UT+08:00"},
{location: time.FixedZone("EST", -5*3600), want: "UT-05:00"},
{location: time.UTC, want: "图中时刻为 UTC"},
{location: time.FixedZone("CST", 8*3600), want: "UTC+08:00"},
{location: time.FixedZone("EST", -5*3600), want: "UTC-05:00"},
}
for _, test := range cases {
options := SolarEclipseMapSVGOptions{
@@ -58,7 +58,7 @@ func solarEclipseContractLevelDiff(got, want []string) string {
// 导出 TT/UTC 换算把力学时儒略日换回 UTC 时刻,口径与核心写进 PartialBegin/EndOnEarth 的一致。
func solarEclipseContractTimeFromTT(ttJDE float64) time.Time {
return basic.JDE2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false)
return basic.JD2DateByZone(ttJDE-basic.DeltaT(ttJDE, true)/86400, time.UTC, false)
}
func TestSolarEclipseGreatestTimeLevelsContract(t *testing.T) {
@@ -160,8 +160,8 @@ func TestSolarEclipseGreatestTimeLevelsOnRealEclipseContract(t *testing.T) {
t.Fatal("missing 2009-07-22 eclipse")
}
// 同一力学时时刻经导出换算回 UTC,不依赖 eclipse 包内部的 TT→UTC 实现。
beginTT := basic.TD2UT(basic.Date2JDE(reported.PartialBeginOnEarth.UTC()), true)
endTT := basic.TD2UT(basic.Date2JDE(reported.PartialEndOnEarth.UTC()), true)
beginTT := basic.UTC2TT(basic.Date2JD(reported.PartialBeginOnEarth.UTC()))
endTT := basic.UTC2TT(basic.Date2JD(reported.PartialEndOnEarth.UTC()))
info := eclipsecore.SolarEclipseInfo{
PartialBeginOnEarth: solarEclipseContractTimeFromTT(beginTT),
PartialEndOnEarth: solarEclipseContractTimeFromTT(endTT),
+2 -1
View File
@@ -350,6 +350,7 @@ func solarEclipseContactMarkerSpecs(info eclipsecore.SolarEclipsePartialFootprin
// solarEclipseMapPlacePointLabels 按重要性放置点标注:食甚最先占位,其余标注再让开。
func solarEclipseMapPlacePointLabels(
civilPartial eclipsecore.SolarEclipsePartialFootprintsInfo,
partial eclipsecore.SolarEclipsePartialFootprintsInfo,
central eclipsecore.SolarEclipsePath,
hasCentral bool,
@@ -376,7 +377,7 @@ func solarEclipseMapPlacePointLabels(
}, frame)
}
result.contacts = solarEclipseMapPlaceContactLabels(partial, frame, labels)
result.subsolar = solarEclipseMapPlaceSubsolarLabel(partial.Eclipse, frame, options.Language, labels)
result.subsolar = solarEclipseMapPlaceSubsolarLabel(civilPartial.Eclipse, frame, options.Language, labels)
if hasCentral {
result.times = solarEclipseMapPlaceTimeLabels(central, frame, options, labels)
}
+5 -2
View File
@@ -69,11 +69,14 @@ func solarEclipseContactRows(
contact("U4 "+name("本影外切", "umbra ends"), partial.U4),
}
info := partial.Eclipse
// 非中心食没有中心线,带宽与中心食时长无从谈起,留空而不是写 0。
// 非中心食没有中心线,单侧极限的中心带只擦到地球边缘:两者带宽都无定义,留空而不是写 0 或发散值。
// 中心食时长不受影响,单侧极限事件同样有目录口径的时长。
pathWidth, centralDuration := "—", "—"
if info.HasCentral {
pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM)
centralDuration = formatSolarEclipseMapDuration(info.CentralDuration)
if info.PathWidthDefined {
pathWidth = fmt.Sprintf("%.1f km", info.PathWidthKM)
}
}
circumstances = []svgchart.PanelRow{
{Label: name("食甚", "Greatest"), Value: info.GreatestEclipse.In(options.Location).Format("15:04:05")},
@@ -0,0 +1,198 @@
package svg
import (
"math"
"testing"
"time"
"b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
"b612.me/astro/internal/svgmap"
)
type projectedFillCase struct {
name string
date time.Time
pole float64
projection geodata.Projection
}
// 偏食可见域的填充必须与球面并集同域:极冠环在等经纬图上沿地图上、下边缘闭合、
// 接缝两侧各贴自己那一侧的边缘,在正射球面图上沿视界圆盘闭合;
// 把窗口两端折到同侧会让闭合边横穿整幅图并丢掉极冠。
func TestSolarEclipseProjectedPartialFillMatchesUnion(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := make([]projectedFillCase, 0, 6)
for _, item := range []projectedFillCase{
{"2012-05-21 北极冠", time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 90, ""},
{"2021-12-04 南极冠", time.Date(2021, 12, 4, 12, 0, 0, 0, cst), -90, ""},
{"2035-09-02 无冠", time.Date(2035, 9, 2, 12, 0, 0, 0, cst), 0, ""},
} {
for _, projection := range []geodata.Projection{
geodata.ProjectionEquirectangular, geodata.ProjectionOrthographic,
} {
item := item
item.projection = projection
cases = append(cases, item)
}
}
for _, item := range cases {
item := item
t.Run(item.name+"/"+string(item.projection), func(t *testing.T) {
info, ok := eclipse.SolarEclipsePartialFootprints(item.date, eclipse.SolarEclipsePartialFootprintOptions{
Step: 5 * time.Minute, BoundaryPoints: 180,
})
if !ok {
t.Fatal("missing partial footprints")
}
polygons, ok := solarEclipsePartialBandPolygons(info)
if !ok || len(polygons) == 0 {
t.Fatal("missing partial-band union")
}
options := SolarEclipseMapSVGOptions{Width: 1200, Height: 800, Location: cst}
layout := solarEclipseMapLayoutFor(options, item.projection,
geodata.GeoPoint{Longitude: info.Eclipse.GreatestLongitude, Latitude: info.Eclipse.GreatestLatitude})
frame := layout.frame
rings := projectedPartialRings(t, polygons, frame)
if item.projection == geodata.ProjectionEquirectangular {
for _, ring := range rings {
for index := range ring {
point, next := ring[index], ring[(index+1)%len(ring)]
if math.Abs(next[0]-point[0]) <= frame.Width/2 || math.Abs(next[1]-point[1]) >= 1 {
continue
}
if frame.Y-point[1] > 1 && point[1]-(frame.Y+frame.Height) > 1 {
t.Fatalf("填充边横穿整幅图:y=%.3f x=%.3f→%.3f", point[1], point[0], next[0])
}
}
}
}
projected := func(longitude, latitude float64) (float64, float64, bool) {
x, y, ok := frame.Project(longitude, latitude)
if !ok {
return 0, 0, false
}
if item.projection != geodata.ProjectionOrthographic {
return x, y, true
}
radius := math.Min(frame.Width, frame.Height) / 2
if math.Hypot(x-(frame.X+frame.Width/2), y-(frame.Y+frame.Height/2)) > radius+1 {
return 0, 0, false
}
return x, y, true
}
polarSamples := 0
if item.pole != 0 {
latitude := math.Copysign(89.5, item.pole)
for longitude := -180.0; longitude < 180; longitude += 5 {
point := geodata.GeoPoint{Longitude: longitude, Latitude: latitude}
if !geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{point})[0] {
continue
}
x, y, ok := projected(point.Longitude, point.Latitude)
if !ok {
continue
}
if !planeFillContains(rings, x, y) {
t.Fatalf("纬度 %.1f 经度 %.1f 在球面并集内但未被填充", latitude, longitude)
}
polarSamples++
}
if polarSamples < 48 {
t.Fatalf("极冠样本只有 %d 个", polarSamples)
}
}
checked, skipped := 0, 0
for latitude := -85.0; latitude <= 85; latitude += 5 {
for longitude := -180.0; longitude < 180; longitude += 10 {
point := geodata.GeoPoint{Longitude: longitude, Latitude: latitude}
x, y, ok := projected(point.Longitude, point.Latitude)
if !ok {
continue
}
if planeFillBoundaryDistance(rings, x, y) < 3 {
skipped++
continue
}
spherical := geodata.SphericalPolygonsContainPoints(polygons, []geodata.GeoPoint{point})[0]
if plane := planeFillContains(rings, x, y); plane != spherical {
t.Fatalf("%.1f %.1f 填充=%v 球面并集=%v", longitude, latitude, plane, spherical)
}
checked++
}
}
if checked < 400 || skipped > checked/4 {
t.Fatalf("一致性样本 checked=%d skipped=%d", checked, skipped)
}
})
}
}
func projectedPartialRings(t *testing.T, polygons [][]geodata.GeoPoint, frame svgmap.Frame) [][][2]float64 {
t.Helper()
var rings [][][2]float64
for _, polygon := range polygons {
points := make([]geodata.GeoPoint, len(polygon))
copy(points, polygon)
for _, fragment := range svgmap.PolygonFragments(points, frame.Clip()) {
ring := make([][2]float64, 0, len(fragment))
for _, point := range fragment {
x, y, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
ring = nil
break
}
ring = append(ring, [2]float64{x, y})
}
if len(ring) >= 3 {
rings = append(rings, ring)
}
}
}
if len(rings) == 0 {
t.Fatal("no projected fragment")
}
return rings
}
func planeFillContains(rings [][][2]float64, x, y float64) bool {
winding := 0
for _, ring := range rings {
for index := range ring {
first, second := ring[index], ring[(index+1)%len(ring)]
if first[1] <= y {
if second[1] > y && (second[0]-first[0])*(y-first[1])-(x-first[0])*(second[1]-first[1]) > 0 {
winding++
}
} else if second[1] <= y && (second[0]-first[0])*(y-first[1])-(x-first[0])*(second[1]-first[1]) < 0 {
winding--
}
}
}
return winding != 0
}
func planeFillBoundaryDistance(rings [][][2]float64, x, y float64) float64 {
distance := math.Inf(1)
for _, ring := range rings {
for index := range ring {
first, second := ring[index], ring[(index+1)%len(ring)]
distance = math.Min(distance, planeSegmentDistance(x, y, first, second))
}
}
return distance
}
func planeSegmentDistance(x, y float64, first, second [2]float64) float64 {
dx, dy := second[0]-first[0], second[1]-first[1]
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, ((x-first[0])*dx+(y-first[1])*dy)/length))
}
return math.Hypot(x-(first[0]+fraction*dx), y-(first[1]+fraction*dy))
}
+55
View File
@@ -0,0 +1,55 @@
package svg
import (
"regexp"
"strings"
"testing"
"time"
"b612.me/astro"
)
// 太阳能图的 UT1 输出只改时刻文字:直射点、闭合弧与等时线几何必须逐字节相同。
// 时刻标记按输出时标的整点取点(UT1 整点是另一个物理时刻),与文字一起从比较里剔除。
func TestSolarEclipseMapUT1KeepsGeometry(t *testing.T) {
for _, date := range []time.Time{
time.Date(2021, time.June, 10, 12, 0, 0, 0, time.UTC), // DUT1 较大,直射点位移可测
time.Date(1995, time.October, 24, 12, 0, 0, 0, time.UTC), // 双向极限事件
time.Date(2024, time.April, 8, 12, 0, 0, 0, time.UTC),
} {
options := SolarEclipseMapSVGOptions{
Width: 1200, Height: 800, Location: time.UTC, TimeLabelStep: 30 * time.Minute,
}
utc, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatalf("%s: UTC render failed", date.Format("2006-01-02"))
}
ut1Options := options
ut1Options.TimeScale = astro.TimeScaleUT1
ut1, ok := SolarEclipseMapSVG(date, ut1Options)
if !ok {
t.Fatalf("%s: UT1 render failed", date.Format("2006-01-02"))
}
if !strings.Contains(ut1, "DUT1 = UT1−UTC") {
t.Errorf("%s: UT1 chart does not declare the DUT1 offset", date.Format("2006-01-02"))
}
cst := time.FixedZone("CST", 8*3600)
badOptions := ut1Options
badOptions.Location = cst
if _, ok := SolarEclipseMapSVG(date, badOptions); ok {
t.Errorf("%s: UT1 with a non-UTC location must fail", date.Format("2006-01-02"))
}
if stripSolarTimescaleText(utc) != stripSolarTimescaleText(ut1) {
t.Errorf("%s: switching to UT1 changed chart geometry", date.Format("2006-01-02"))
}
}
}
var (
solarTimescaleText = regexp.MustCompile(`(?s)<text[^>]*>.*?</text>`)
solarTimescaleMarkers = regexp.MustCompile(`(?s)<g class="solar-time-marker">.*?</g>`)
)
func stripSolarTimescaleText(doc string) string {
return solarTimescaleText.ReplaceAllString(solarTimescaleMarkers.ReplaceAllString(doc, ""), "")
}
@@ -0,0 +1,90 @@
package svg
import (
"strings"
"testing"
"time"
)
// 带宽无定义时地图面板与摘要行都按目录口径留空,不得打印发散的解析值。
func TestSolarEclipseMapHidesUndefinedPathWidth(t *testing.T) {
testCases := []struct {
name string
date time.Time
panelValue string
summary string
forbidden []string
}{
{
name: "-1404-01-07 single-sided limit",
date: time.Date(-1404, 1, 7, 0, 0, 0, 0, time.UTC),
panelValue: "—",
forbidden: []string{"20692", "中心食带宽 "},
},
{
name: "2003-05-31 single-sided limit with limit lines",
date: time.Date(2003, 5, 31, 0, 0, 0, 0, time.UTC),
panelValue: "—",
forbidden: []string{"4454", "中心食带宽 "},
},
{
name: "2024-04-08 total",
date: time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
panelValue: "198.6 km",
summary: "中心食带宽 198.6 km",
},
{
name: "2010-01-15 annular",
date: time.Date(2010, 1, 15, 0, 0, 0, 0, time.UTC),
panelValue: "335.0 km",
summary: "中心食带宽 335.0 km",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
diagram, ok := SolarEclipseMapSVG(tc.date, SolarEclipseMapSVGOptions{
Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute,
})
if !ok {
t.Fatalf("expected map")
}
value, found := solarEclipsePanelRowValue(diagram, "中心食带宽")
if !found {
t.Fatalf("panel row missing")
}
if value != tc.panelValue {
t.Fatalf("panel row %q want %q", value, tc.panelValue)
}
if tc.summary != "" && !strings.Contains(diagram, tc.summary) {
t.Fatalf("summary missing %q", tc.summary)
}
for _, unwanted := range tc.forbidden {
if strings.Contains(diagram, unwanted) {
t.Fatalf("diagram contains %q", unwanted)
}
}
if err := validateEclipseMapXML(diagram); err != nil {
t.Fatalf("map is not valid XML: %v", err)
}
})
}
}
func solarEclipsePanelRowValue(diagram, label string) (string, bool) {
index := strings.Index(diagram, ">"+label+"</text>")
if index < 0 {
return "", false
}
rest := diagram[index+len(label)+len("></text>"):]
start := strings.Index(rest, ">")
if start < 0 {
return "", false
}
rest = rest[start+1:]
end := strings.Index(rest, "</text>")
if end < 0 {
return "", false
}
return rest[:end], true
}
+9 -4
View File
@@ -4,6 +4,7 @@ import (
"math"
"time"
"b612.me/astro"
"b612.me/astro/basic"
eclipsecore "b612.me/astro/eclipse"
)
@@ -31,9 +32,13 @@ func localSolarEclipseInfoFromDiagram(
diagram basic.LocalSolarEclipseDiagramResult,
lon, lat, height float64,
location *time.Location,
scale astro.TimeScale,
) LocalSolarEclipseInfo {
info := localSolarEclipseInfoFieldsFromBasic(diagram.Eclipse, lon, lat, height, location)
info.ContactPoints = localSolarEclipseContactPointsFromFrames(diagram.Frames, location)
if scale == astro.TimeScaleUT1 {
info = eclipsecore.LocalSolarEclipseInfoInUT1(info)
}
return info
}
@@ -131,13 +136,13 @@ func solarEclipseTTJDEToTime(ttJDE float64, location *time.Location) time.Time {
if ttJDE == 0 {
return time.Time{}
}
utcJDE := basic.TD2UT(ttJDE, false)
return basic.JDE2DateByZone(utcJDE, location, false)
utcJD := basic.TT2UTC(ttJDE)
return basic.JD2DateByZone(utcJD, location, false)
}
func solarEclipseTimeToTTJDE(date time.Time) float64 {
utcJDE := basic.Date2JDE(date.UTC())
return basic.TD2UT(utcJDE, true)
utcJD := basic.Date2JD(date.UTC())
return basic.UTC2TT(utcJD)
}
func localSolarEclipseVisibilityThreshold(height, latitude float64) float64 {