feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'05.2""},
|
||||
{"2025-03-14", time.Date(2025, 3, 14, 0, 0, 0, 0, cst), "11h38m23.0s", "+02°40'54.3""},
|
||||
}
|
||||
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
@@ -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 表示该阶段
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user