1 Commits

Author SHA1 Message Date
b612 ccf5880269 feat: 完善水星与金星全球凌日搜索及制图导出
- 基于平均下合周期与节点相位预筛候选,减少无效星历计算;跳过无法求解的单个会合周期,避免有限覆盖星历导致搜索提前终止
- 引入影轴 γ、半影半径及地球尺度几何判定,结合站心可见性识别地心无接触、仅部分地区可见的掠凌
- 添加金星 243 年、水星 46 年周期族编号,并明确编号适用范围及未分配时的零值语义
- 为 SVG、GeoJSON 和 KML 增加站心部分凌日可见区,适配接触等时线、几何元数据、图层样式及取景;为地心无接触凌日事件增加正射图,按可见区球面面积加权居中
2026-10-20 22:35:39 +08:00
41 changed files with 1554 additions and 307 deletions
+16 -6
View File
@@ -14,7 +14,7 @@ func ClosestJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenon
return ClosestJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, nil)
}
// ClosestMercuryTransit 最近一次地心水星凌日 / closest geocentric transit of Mercury.
// ClosestMercuryTransit 最近一次全球水星凌日 / closest global transit of Mercury.
func ClosestMercuryTransit(jde float64) PlanetTransitResult {
return ClosestMercuryTransitWithInstance(jde, nil)
}
@@ -34,7 +34,7 @@ func ClosestMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlane
return ClosestMoonPlanetConjunctionWithInstance(jde, planet, nil)
}
// ClosestVenusTransit 最近一次地心金星凌日 / closest geocentric transit of Venus.
// ClosestVenusTransit 最近一次全球金星凌日 / closest global transit of Venus.
func ClosestVenusTransit(jde float64) PlanetTransitResult {
return ClosestVenusTransitWithInstance(jde, nil)
}
@@ -236,7 +236,7 @@ func LastJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonTyp
return LastJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, nil)
}
// LastMercuryTransit 上一次地心水星凌日 / previous geocentric transit of Mercury.
// LastMercuryTransit 上一次全球水星凌日 / previous global transit of Mercury.
func LastMercuryTransit(jde float64) PlanetTransitResult {
return LastMercuryTransitWithInstance(jde, nil)
}
@@ -256,7 +256,7 @@ func LastMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet)
return LastMoonPlanetConjunctionWithInstance(jde, planet, nil)
}
// LastVenusTransit 上一次地心金星凌日 / previous geocentric transit of Venus.
// LastVenusTransit 上一次全球金星凌日 / previous global transit of Venus.
func LastVenusTransit(jde float64) PlanetTransitResult { return LastVenusTransitWithInstance(jde, nil) }
// LocalSolarEclipseDiagramIAUSingleK 站心日食视圆图几何(IAU Single-K) / local solar eclipse disk diagram, IAU Single-K.
@@ -560,7 +560,7 @@ func NextJupiterGalileanPhenomenonEvent(jd float64, satellite int, phenomenonTyp
return NextJupiterGalileanPhenomenonEventWithInstance(jd, satellite, phenomenonType, nil)
}
// NextMercuryTransit 下一次地心水星凌日 / next geocentric transit of Mercury.
// NextMercuryTransit 下一次全球水星凌日 / next global transit of Mercury.
func NextMercuryTransit(jde float64) PlanetTransitResult {
return NextMercuryTransitWithInstance(jde, nil)
}
@@ -580,7 +580,7 @@ func NextMoonPlanetConjunction(jde float64, planet MoonPlanetConjunctionPlanet)
return NextMoonPlanetConjunctionWithInstance(jde, planet, nil)
}
// NextVenusTransit 下一次地心金星凌日 / next geocentric transit of Venus.
// NextVenusTransit 下一次全球金星凌日 / next global transit of Venus.
func NextVenusTransit(jde float64) PlanetTransitResult { return NextVenusTransitWithInstance(jde, nil) }
// PlanetOccultationDiagram 已求解固定地点行星月掩的月心切平面轨迹,事件无效时不含帧 / tangent-plane track of a solved fixed-site planetary occultation.
@@ -596,6 +596,16 @@ func PlanetOccultationFootprintsAt(at time.Time, planet OccultationPlanet) (Plan
return PlanetOccultationFootprintsAtWithInstance(at, planet, nil)
}
// PlanetTransitVisibilityColumns 按 2° 经度、1° 纬度探测站心可见区并细化边界 / sample topocentric visibility at 2-degree longitude and 1-degree latitude spacing and refine boundaries.
func PlanetTransitVisibilityColumns(transit PlanetTransitResult) []PlanetTransitVisibilityColumn {
return PlanetTransitVisibilityColumnsWithInstance(transit, nil)
}
// PlanetTransitVisibilityPolygons 可见区的经度条带多边形 / longitude-strip polygons of the visible region.
func PlanetTransitVisibilityPolygons(transit PlanetTransitResult) [][]PlanetTransitIsochronePoint {
return PlanetTransitVisibilityPolygonsWithInstance(transit, nil)
}
// SaturnAscendingNodeN 土星升交点黄经(n 变体) / n-parameterised ascending node longitude of Saturn.
func SaturnAscendingNodeN(jd float64, n int) float64 {
return SaturnAscendingNodeNWithInstance(jd, n, nil)
+67 -124
View File
@@ -7,20 +7,17 @@ import (
)
const (
planetTransitMeanSolarMotionDegPerDay = 360.0 / 365.2422
planetTransitTropicalYearDays = 365.2422
planetTransitSeasonProbeStepDays = 20.0
planetTransitSearchLimit = 2400
planetTransitSearchEpsilonDays = 1.0 / 86400.0
planetTransitGreatestWindowDays = 1.2
planetTransitGreatestToleranceDays = 0.25 / 86400.0
planetTransitContactStepDays = 0.02
planetTransitContactSpanDays = 1.0
planetTransitContactToleranceDays = 0.25 / 86400.0
planetTransitCoarseN = 16
planetTransitSearchLimit = 2400
planetTransitSearchEpsilonDays = 1.0 / 86400.0
planetTransitGreatestWindowDays = 1.2
planetTransitGreatestToleranceDays = 0.25 / 86400.0
planetTransitContactStepDays = 0.02
planetTransitContactSpanDays = 1.0
planetTransitContactToleranceDays = 0.25 / 86400.0
planetTransitCoarseN = 16
)
// PlanetTransitResult 表示一次地心行星凌日结果。
// PlanetTransitResult 全球行星凌日与地心接触 / global planetary transit and geocentric contacts.
//
// Valid 为 false 时表示没有找到有效凌日。所有时刻字段均为 UT 儒略日。
// MinimumSeparationArcsec、SunSemidiameterArcsec、PlanetSemidiameterArcsec 的单位均为角秒。
@@ -45,6 +42,15 @@ type PlanetTransitResult struct {
HasExternal bool
HasInternal bool
// WindowStart、WindowEnd 是站心求解窗口,不是接触时刻。
WindowStart float64
WindowEnd float64
// Gamma 是凌甚时影轴到地心的有符号距离,以地球赤道半径为单位,北侧为正。
Gamma float64
// PenumbralRadius 是地心基本面上的半影半径,以地球赤道半径为单位。
PenumbralRadius float64
Series PlanetTransitSeries
}
type planetTransitConfig struct {
@@ -53,7 +59,6 @@ type planetTransitConfig struct {
synodicPeriodDays float64
anchorInferiorTT float64
seasonWindowDays float64
latitudePrefilter float64
conjunctionStepDay float64
@@ -62,7 +67,6 @@ type planetTransitConfig struct {
apparentRaDecN func(float64, int) (float64, float64)
semidiameterN func(float64, int) float64
earthDistanceN func(float64, int) float64
nodeN func(float64, int) float64
}
type planetTransitState struct {
@@ -82,7 +86,6 @@ func mercuryTransitConfig(instances ...*Instance) planetTransitConfig {
planetIndex: 1,
synodicPeriodDays: MERCURY_S_PERIOD,
anchorInferiorTT: UTC2TT(JDCalc(2019, 11, 11+(15+21.0/60+40.0/3600)/24)),
seasonWindowDays: 12,
latitudePrefilter: 1.0,
conjunctionStepDay: 0.00001,
apparentLoN: func(jd float64, n int) float64 { return MercuryApparentLoNWithInstance(jd, n, inst) },
@@ -90,7 +93,6 @@ func mercuryTransitConfig(instances ...*Instance) planetTransitConfig {
apparentRaDecN: func(jde float64, n int) (float64, float64) { return MercuryApparentRaDecNWithInstance(jde, n, inst) },
semidiameterN: func(jde float64, n int) float64 { return MercurySemidiameterNWithInstance(jde, n, inst) },
earthDistanceN: func(jd float64, n int) float64 { return EarthMercuryAwayNWithInstance(jd, n, inst) },
nodeN: func(jd float64, n int) float64 { return MercuryAscendingNodeNWithInstance(jd, n, inst) },
}
}
@@ -101,7 +103,6 @@ func venusTransitConfig(instances ...*Instance) planetTransitConfig {
planetIndex: 2,
synodicPeriodDays: VENUS_S_PERIOD,
anchorInferiorTT: UTC2TT(JDCalc(2012, 6, 6+(1+29.0/60)/24)),
seasonWindowDays: 8,
latitudePrefilter: 0.8,
conjunctionStepDay: 0.00001,
apparentLoN: func(jd float64, n int) float64 { return VenusApparentLoNWithInstance(jd, n, inst) },
@@ -109,7 +110,6 @@ func venusTransitConfig(instances ...*Instance) planetTransitConfig {
apparentRaDecN: func(jd float64, n int) (float64, float64) { return VenusApparentRaDecNWithInstance(jd, n, inst) },
semidiameterN: func(jde float64, n int) float64 { return VenusSemidiameterNWithInstance(jde, n, inst) },
earthDistanceN: func(jd float64, n int) float64 { return EarthVenusAwayNWithInstance(jd, n, inst) },
nodeN: func(jd float64, n int) float64 { return VenusAscendingNodeNWithInstance(jd, n, inst) },
}
}
@@ -174,18 +174,24 @@ func searchPlanetTransit(jd float64, cfg planetTransitConfig, direction int, inc
}
targetTT := UTC2TT(jd)
// 节点季可能略早于查询时刻而凌甚仍在其后,从一个季窗之前起探,方向由凌甚时刻再判。
probeTT := targetTT - float64(direction)*cfg.seasonWindowDays
if !isFiniteFloat(targetTT) {
return PlanetTransitResult{}, false
}
cycle := math.Floor((targetTT-cfg.anchorInferiorTT)/cfg.synodicPeriodDays) - 1
if direction < 0 {
cycle += 3
}
for i := 0; i < planetTransitSearchLimit; i++ {
seasonTT, ok := nextPlanetTransitSeasonTT(probeTT, cfg, direction, inst)
if !ok {
return PlanetTransitResult{}, false
}
seedTT := nearestPlanetTransitInferiorSeedTT(seasonTT, cfg)
if math.Abs(seedTT-seasonTT) <= cfg.seasonWindowDays {
conjunctionTT := refinePlanetTransitInferiorConjunctionTT(seedTT, cfg, inst)
if math.Abs(conjunctionTT-seasonTT) <= cfg.seasonWindowDays+1 && isPotentialPlanetTransit(conjunctionTT, cfg, inst) {
seedTT := cfg.anchorInferiorTT + cycle*cfg.synodicPeriodDays
if planetTransitPredictedSeason(seedTT, cfg, inst) {
// 单个会合周算不出粗时刻(例如候选落在有限覆盖星历之外)时跳过该周,后面的周期仍可能落在覆盖内。
coarseTT := planetTransitCoarseConjunction(seedTT, cfg, inst)
if !isFiniteFloat(coarseTT) {
cycle += float64(direction)
continue
}
if isPotentialPlanetTransit(coarseTT, cfg, inst) {
conjunctionTT := refinePlanetTransitInferiorConjunctionTT(coarseTT, cfg, inst)
resultTT, ok := planetTransitAtInferiorConjunctionTT(conjunctionTT, cfg, inst)
if ok && planetTransitMatchesDirection(resultTT.Greatest, targetTT, direction, includeCurrent) {
return planetTransitResultTTToUT(resultTT), true
@@ -193,102 +199,12 @@ func searchPlanetTransit(jd float64, cfg planetTransitConfig, direction int, inc
}
}
probeTT = seasonTT + float64(direction)*planetTransitSeasonProbeStepDays
cycle += float64(direction)
}
return PlanetTransitResult{}, false
}
func nextPlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, direction int, instances ...*Instance) (float64, bool) {
inst := instanceValue(instances)
best := math.NaN()
for nodeOffset := 0; nodeOffset <= 1; nodeOffset++ {
candidate := estimatePlanetTransitSeasonTT(jdTT, cfg, nodeOffset, direction, inst)
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset, inst)
if !isFiniteFloat(candidate) {
continue
}
matchedDirection := false
for i := 0; i < eventDirectionalSearchIterations; i++ {
if planetTransitMatchesDirection(candidate, jdTT, direction, false) {
matchedDirection = true
break
}
candidate += float64(direction) * planetTransitTropicalYearDays
candidate = refinePlanetTransitSeasonTT(candidate, cfg, nodeOffset, inst)
if !isFiniteFloat(candidate) {
break
}
}
if !matchedDirection {
continue
}
if !isFiniteFloat(best) || math.Abs(candidate-jdTT) < math.Abs(best-jdTT) {
best = candidate
}
}
if !isFiniteFloat(best) {
return 0, false
}
return best, true
}
func estimatePlanetTransitSeasonTT(jdTT float64, cfg planetTransitConfig, nodeOffset int, direction int, instances ...*Instance) float64 {
inst := instanceValue(instances)
sunLongitude := HSunApparentLoNWithInstance(jdTT, planetTransitCoarseN, inst)
nodeLongitude := planetTransitNodeLongitude(jdTT, cfg, nodeOffset, planetTransitCoarseN)
if direction > 0 {
delta := Limit360(nodeLongitude - sunLongitude)
if delta <= planetTransitSearchEpsilonDays {
delta += 360
}
return jdTT + delta/planetTransitMeanSolarMotionDegPerDay
}
delta := Limit360(sunLongitude - nodeLongitude)
if delta <= planetTransitSearchEpsilonDays {
delta += 360
}
return jdTT - delta/planetTransitMeanSolarMotionDegPerDay
}
func refinePlanetTransitSeasonTT(seedTT float64, cfg planetTransitConfig, nodeOffset int, instances ...*Instance) float64 {
inst := instanceValue(instances)
current := seedTT
for i := 0; i < 8; i++ {
prev := current
value := planetTransitSunNodeLongitudeDelta(prev, cfg, nodeOffset, inst)
slope := (planetTransitSunNodeLongitudeDelta(prev+0.5, cfg, nodeOffset, inst) -
planetTransitSunNodeLongitudeDelta(prev-0.5, cfg, nodeOffset, inst)) / 1.0
if slope == 0 || !isFiniteFloat(slope) {
break
}
current = prev - value/slope
if math.Abs(current-prev) <= 0.00001 {
break
}
}
return current
}
func planetTransitSunNodeLongitudeDelta(jdTT float64, cfg planetTransitConfig, nodeOffset int, instances ...*Instance) float64 {
inst := instanceValue(instances)
return planetTransitAngleDelta(HSunApparentLoNWithInstance(jdTT, planetTransitCoarseN, inst) -
planetTransitNodeLongitude(jdTT, cfg, nodeOffset, planetTransitCoarseN))
}
func planetTransitNodeLongitude(jdTT float64, cfg planetTransitConfig, nodeOffset int, n int) float64 {
return Limit360(cfg.nodeN(jdTT, n) + float64(nodeOffset)*180)
}
func nearestPlanetTransitInferiorSeedTT(seasonTT float64, cfg planetTransitConfig) float64 {
k := math.Round((seasonTT - cfg.anchorInferiorTT) / cfg.synodicPeriodDays)
return cfg.anchorInferiorTT + k*cfg.synodicPeriodDays
}
func refinePlanetTransitInferiorConjunctionTT(seedTT float64, cfg planetTransitConfig, instances ...*Instance) float64 {
inst := instanceValue(instances)
@@ -342,7 +258,11 @@ func planetTransitAtInferiorConjunctionTT(conjunctionTT float64, cfg planetTrans
greatestTT := greatestPlanetTransitTT(conjunctionTT, cfg, inst)
greatestState := planetTransitStateAt(greatestTT, cfg, -1, inst)
if !isFiniteFloat(greatestState.externalContactMetric) || greatestState.externalContactMetric > 0 {
if !isFiniteFloat(greatestState.externalContactMetric) {
return PlanetTransitResult{}, false
}
gamma, penumbra, surfaceSeed, possible := planetTransitShadowGeometry(greatestTT, cfg, inst)
if !possible {
return PlanetTransitResult{}, false
}
@@ -353,8 +273,19 @@ func planetTransitAtInferiorConjunctionTT(conjunctionTT float64, cfg planetTrans
MinimumSeparationArcsec: greatestState.separationArcsec,
SunSemidiameterArcsec: greatestState.sunSemidiameter,
PlanetSemidiameterArcsec: greatestState.planetSemidiameter,
HasExternal: true,
HasExternal: greatestState.externalContactMetric <= 0,
HasInternal: greatestState.internalContactMetric <= 0,
WindowStart: greatestTT - 0.25,
WindowEnd: greatestTT + 0.25,
Gamma: gamma,
PenumbralRadius: penumbra,
Series: planetTransitSeriesAt(greatestTT, cfg),
}
if !result.HasExternal {
if !planetTransitSurfaceOverlap(planetTransitResultTTToUT(result), surfaceSeed, inst) {
return PlanetTransitResult{}, false
}
return result, true
}
externalIngress, ok := refinePlanetTransitContactTT(greatestTT, cfg, -1, false, inst)
@@ -367,6 +298,8 @@ func planetTransitAtInferiorConjunctionTT(conjunctionTT float64, cfg planetTrans
}
result.ExternalIngress = externalIngress
result.ExternalEgress = externalEgress
result.WindowStart = externalIngress - planetTransitSnapshotMarginDays
result.WindowEnd = externalEgress + planetTransitSnapshotMarginDays
if result.HasInternal {
internalIngress, ok := refinePlanetTransitContactTT(greatestTT, cfg, -1, true, inst)
@@ -527,8 +460,18 @@ func bisectPlanetTransitContactTT(leftJD, leftValue, rightJD, rightValue float64
func planetTransitResultTTToUT(result PlanetTransitResult) PlanetTransitResult {
result.Greatest = TT2UTC(result.Greatest)
result.ExternalIngress = TT2UTC(result.ExternalIngress)
result.ExternalEgress = TT2UTC(result.ExternalEgress)
if result.ExternalIngress != 0 {
result.ExternalIngress = TT2UTC(result.ExternalIngress)
}
if result.ExternalEgress != 0 {
result.ExternalEgress = TT2UTC(result.ExternalEgress)
}
if result.WindowStart != 0 {
result.WindowStart = TT2UTC(result.WindowStart)
}
if result.WindowEnd != 0 {
result.WindowEnd = TT2UTC(result.WindowEnd)
}
if result.InternalIngress != 0 {
result.InternalIngress = TT2UTC(result.InternalIngress)
}
+67
View File
@@ -0,0 +1,67 @@
package basic
import "math"
const (
planetTransitEarthRadiusKM = 6378.137
planetTransitEarthEccentricitySquared = 0.00669437999014
)
func planetTransitShadowGeometry(tt float64, cfg planetTransitConfig, inst *Instance) (float64, float64, Observer, bool) {
sunRA, sunDec := HSunApparentRaDecNWithInstance(tt, -1, inst)
planetRA, planetDec := cfg.apparentRaDecN(tt, -1)
sunDistance := EarthAwayNWithInstance(tt, -1, inst) * angularDiameterAstronomicalUnitKM
planetDistance := cfg.earthDistanceN(tt, -1) * angularDiameterAstronomicalUnitKM
sun := eclipticCartesian(sunRA, sunDec, sunDistance)
body := eclipticCartesian(planetRA, planetDec, planetDistance)
axis, length := pn(Vector3{body[0] - sun[0], body[1] - sun[1], body[2] - sun[2]})
projection := -(body[0]*axis[0] + body[1]*axis[1] + body[2]*axis[2])
impact := Vector3{body[0] + projection*axis[0], body[1] + projection*axis[1], body[2] + projection*axis[2]}
_, distance := pn(impact)
planetRadius := venusEquatorialRadiusKM
if cfg.planetIndex == 1 {
planetRadius = mercuryEquatorialRadiusKM
}
sine := (sunEquatorialRadiusKM + planetRadius) / length
cosine := math.Sqrt(1 - sine*sine)
penumbra := (planetRadius + projection*sine) / cosine / planetTransitEarthRadiusKM
gamma := math.Copysign(distance/planetTransitEarthRadiusKM, impact[2])
sidereal := ApparentSiderealTime(TT2UT1(tt)) * 15
seed := Observer{
Longitude: normalizeLongitude180(math.Atan2(impact[1], impact[0])*180/math.Pi - sidereal),
Latitude: math.Atan2(impact[2], math.Hypot(impact[0], impact[1])*(1-planetTransitEarthEccentricitySquared)) * 180 / math.Pi,
}
possible := isFiniteFloat(gamma) && isFiniteFloat(penumbra) && projection > 0 && planetDistance < sunDistance && math.Abs(gamma) <= penumbra+1/cosine
return gamma, penumbra, seed, possible
}
func planetTransitSurfaceOverlap(transit PlanetTransitResult, seed Observer, inst *Instance) bool {
snapshot, ok := buildPlanetTransitSnapshot(transit, inst)
if !ok {
return false
}
best := math.Inf(1)
for step := 4.0; step > 0.0001; step /= 2 {
center := seed
for _, latitudeStep := range []float64{-step, 0, step} {
for _, longitudeStep := range []float64{-step, 0, step} {
observer := Observer{Longitude: normalizeLongitude180(center.Longitude + longitudeStep), Latitude: math.Max(-90, math.Min(90, center.Latitude+latitudeStep))}
tt, valid := snapshot.localGreatestTT(UTC2TT(transit.Greatest), observer)
state, stateOK := snapshot.stateAt(tt, observer)
if !valid || !stateOK {
continue
}
if state.external < 0 {
local := snapshot.local(transit, observer)
if local.Valid && local.Visible {
return true
}
}
if state.external < best {
best, seed = state.external, observer
}
}
}
}
return false
}
+170
View File
@@ -0,0 +1,170 @@
package basic
import (
"math"
"testing"
"time"
"b612.me/astro/internal/ephem"
)
func TestPlanetTransitGlobalGrazes(tester *testing.T) {
for _, model := range []ephem.Model{ephem.Semianalytic, ephem.Legacy} {
inst := ephem.NewModelInstance(model)
for _, year := range []int{-540, 2854} {
transit := NextVenusTransitWithInstance(JDCalc(year, 1, 1), inst)
if !transit.Valid || JD2DateByZone(transit.Greatest, time.UTC, false).Year() != year {
tester.Fatalf("model=%d year=%d: missed global event: %+v", model, year, transit)
}
if transit.HasExternal || transit.HasInternal || transit.ExternalIngress != 0 || transit.ExternalEgress != 0 {
tester.Fatalf("global graze fabricated geocentric contacts: %+v", transit)
}
if math.Abs(transit.Gamma) <= transit.PenumbralRadius || math.Abs(transit.Gamma) >= transit.PenumbralRadius+1.01 {
tester.Fatalf("unexpected shadow geometry: %+v", transit)
}
observer := Observer{Longitude: -135, Latitude: -60}
local := PlanetTransitLocalWithInstance(transit, observer, inst)
if !local.Valid || !local.Visible || local.HasInternal || !local.ExternalIngress.Valid || !local.ExternalEgress.Valid {
tester.Fatalf("model=%d year=%d: missing visible local graze: %+v", model, year, local)
}
if year == 2854 {
if other := PlanetTransitLocalWithInstance(transit, Observer{Latitude: 45}, inst); other.Valid {
tester.Fatal("northern site must miss the 2854 event")
}
}
}
}
}
func TestPlanetTransitSeriesNumbers(tester *testing.T) {
for _, sample := range []struct{ planet, year, number, period int }{
{2, 1631, 6, 243}, {2, 1639, 4, 243}, {2, 2004, 3, 243}, {2, 2012, 5, 243}, {2, 2611, 4, 243}, {2, 2854, 4, 243},
{1, 1957, 9, 46}, {1, 2003, 9, 46}, {1, 2006, 8, 46}, {1, 2016, 7, 46}, {1, 2019, 6, 46}, {1, 2174, 11, 46},
} {
query := JDCalc(sample.year, 1, 1)
result := NextVenusTransit(query)
if sample.planet == 1 {
result = NextMercuryTransit(query)
}
if result.Series.Number != sample.number || result.Series.PeriodYears != sample.period {
tester.Fatalf("planet=%d year=%d: series=%+v, want %d/%d", sample.planet, sample.year, result.Series, sample.number, sample.period)
}
}
}
func TestPlanetTransitSeriesUnassigned(tester *testing.T) {
for _, sample := range []struct {
cfg planetTransitConfig
year int
}{
{mercuryTransitConfig(), 1500}, {mercuryTransitConfig(), 2400},
{venusTransitConfig(), -2100}, {venusTransitConfig(), 4100},
} {
series := planetTransitSeriesAt(JDCalc(sample.year, 1, 1), sample.cfg)
if series.Number != 0 || series.PeriodYears == 0 {
tester.Fatalf("unassigned series: %+v", series)
}
}
}
func TestPlanetTransitNearInternalEdge(tester *testing.T) {
for _, year := range []int{1631, 2611} {
transit := NextVenusTransit(JDCalc(year, 1, 1))
if !transit.Valid || !transit.HasExternal || !transit.HasInternal {
tester.Fatalf("year=%d: missing geocentric contacts", year)
}
full, partial := false, false
for latitude := -75; latitude <= 75; latitude += 30 {
for longitude := -180; longitude < 180; longitude += 45 {
local := PlanetTransitLocal(transit, Observer{Longitude: float64(longitude), Latitude: float64(latitude)})
if !local.Valid || !local.Visible {
continue
}
if local.HasInternal {
full = true
} else {
partial = true
}
}
}
if !full || !partial {
tester.Fatalf("year=%d: full=%v partial=%v", year, full, partial)
}
}
}
func TestPlanetTransitInputBounds(tester *testing.T) {
for _, query := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), math.MaxFloat64} {
if result := NextVenusTransit(query); result.Valid {
tester.Fatal("non-finite query produced a transit")
}
}
}
func TestPlanetTransitResultRebuild(tester *testing.T) {
greatest := time.Date(2854, 12, 14, 13, 0, 0, 0, time.UTC)
if PlanetTransitResultFromTimes(2, time.Time{}, time.Time{}, greatest, time.Time{}, time.Time{}).Valid {
tester.Fatal("contact rebuild accepted missing C1/C4")
}
for _, window := range [][2]time.Time{
{{}, greatest.Add(time.Hour)}, {greatest, greatest.Add(time.Hour)}, {greatest.Add(-time.Hour), greatest},
} {
if PlanetTransitResultFromWindow(2, window[0], greatest, window[1]).Valid {
tester.Fatalf("window rebuild accepted %v", window)
}
}
transit := NextVenusTransit(JDCalc(2854, 1, 1))
rebuilt := PlanetTransitResultFromWindow(2, JD2DateByZone(transit.WindowStart, time.UTC, false),
JD2DateByZone(transit.Greatest, time.UTC, false), JD2DateByZone(transit.WindowEnd, time.UTC, false))
observer := Observer{Longitude: -135, Latitude: -60}
want, got := PlanetTransitLocal(transit, observer), PlanetTransitLocal(rebuilt, observer)
if !got.Valid || math.Abs(got.ExternalIngress.JD-want.ExternalIngress.JD)*86400 > 0.01 || math.Abs(got.ExternalEgress.JD-want.ExternalEgress.JD)*86400 > 0.01 {
tester.Fatalf("window rebuild changed local contacts: got %+v want %+v", got, want)
}
}
// boundedCoverageEngine 把内置星历限制在一个时间窗内,窗外的请求按越界报错。
type boundedCoverageEngine struct {
ephem.Engine
start, end float64
}
func (bounded boundedCoverageEngine) within(jd float64) bool {
return jd >= bounded.start && jd <= bounded.end
}
func (bounded boundedCoverageEngine) State(target, center ephem.Body, jdTDB float64) (pos, vel [3]float64, err error) {
if !bounded.within(jdTDB) {
return [3]float64{}, [3]float64{}, ephem.ErrOutOfRange
}
return bounded.Engine.State(target, center, jdTDB)
}
func (bounded boundedCoverageEngine) Apparent(target ephem.Body, jdTDB float64) (pos [3]float64, lightTime, distance float64, err error) {
if !bounded.within(jdTDB) {
return [3]float64{}, 0, 0, ephem.ErrOutOfRange
}
return bounded.Engine.(ephem.ApparentEngine).Apparent(target, jdTDB)
}
func (bounded boundedCoverageEngine) Coverage(ephem.Body) (start, end float64, ok bool) {
return bounded.start, bounded.end, true
}
// 有限覆盖星历:搜索起点前的候选落在覆盖之外时,不得因此丢掉覆盖内的凌日。
func TestPlanetTransitBoundedEphemerisCoverage(tester *testing.T) {
base := ephem.NewModelInstance(ephem.Semianalytic).Engine()
start := JDCalc(2019, 1, 1)
bounded := ephem.NewInstance(boundedCoverageEngine{Engine: base, start: start, end: JDCalc(2021, 1, 1)})
query := start + 60
transit := NextMercuryTransitWithInstance(query, bounded)
if !transit.Valid {
tester.Fatal("bounded ephemeris lost the 2019-11-11 transit inside its coverage")
}
if year := JD2DateByZone(transit.Greatest, time.UTC, false).Year(); year != 2019 {
tester.Fatalf("bounded ephemeris returned year %d, want 2019", year)
}
if transit.HasExternal != true || transit.HasInternal != true {
tester.Fatalf("bounded ephemeris returned an incomplete transit: %+v", transit)
}
}
+48 -21
View File
@@ -7,7 +7,7 @@ import (
"time"
)
// 站心凌日:以地心凌日为种子,对太阳与行星的视赤经赤纬分别做观测者视差修正后重解接触与凌甚。
// 站心凌日:以全球凌日窗口为种子,分别修正太阳与行星的视差后重解接触与凌甚。
// 太阳视半径按站心距离缩放(影响最多约 0.04″);行星视半径沿用地心值,站心修正低于 0.01″。
// 结果里的太阳高度一律为几何中心高度;可见性、日出日落与等时线裁剪按太阳中心视高度 0° 判定,
// 即计入标准大气(1010 hPa、10 °C)下的地平折射,几何高度阈值约 -34′。
@@ -91,8 +91,7 @@ type PlanetTransitLocalResult struct {
Sunset float64
}
// PlanetTransitLocal 返回地心凌日 transit 在观测者 observer 处的站心接触与凌甚。
// PlanetTransitLocal returns the topocentric contacts and greatest instant of the geocentric transit seen by observer.
// PlanetTransitLocal 观测地的站心接触与凌甚 / topocentric contacts and greatest transit at the site.
func PlanetTransitLocal(transit PlanetTransitResult, observer Observer) PlanetTransitLocalResult {
return PlanetTransitLocalWithInstance(transit, observer, nil)
}
@@ -118,7 +117,9 @@ type planetTransitSnapshot struct {
step float64
// 按列存储:太阳赤经、赤纬、距离,行星赤经、赤纬、距离,太阳与行星视半径,格林尼治视恒星时(度)。
// 赤经与恒星时已展开为连续值,避免插值跨越 0°/360°。
columns [planetTransitSnapshotColumns][]float64
columns [planetTransitSnapshotColumns][]float64
visibilityOnce sync.Once
visibility []PlanetTransitVisibilityColumn
}
const (
@@ -137,9 +138,9 @@ const (
const (
// planetTransitSnapshotStepDays 是缓存节点间距;四点拉格朗日插值在十分钟间距下误差远低于 1e-6″。
planetTransitSnapshotStepDays = 10.0 / 1440
// planetTransitSnapshotMarginDays 是缓存相对地心一触、四触向外扩展的余量。
// planetTransitSnapshotMarginDays 是缓存相对地心一触、四触或地心无接触时求解窗口向外扩展的余量。
planetTransitSnapshotMarginDays = 2.0 / 24
// planetTransitMaxSpanDays 是地心一触到四触的跨度上限。实际凌日最长约 8 小时(水星)、
// planetTransitMaxSpanDays 是地心一触到四触或求解窗口的跨度上限。实际凌日最长约 8 小时(水星)、
// 金星不到 9 小时;超出视为异常输入,避免按跨度分配巨大缓存。
planetTransitMaxSpanDays = 1.0
)
@@ -171,10 +172,16 @@ func newPlanetTransitSnapshot(transit PlanetTransitResult, inst *Instance) (*pla
}
func buildPlanetTransitSnapshot(transit PlanetTransitResult, inst *Instance) (*planetTransitSnapshot, bool) {
if !transit.Valid || !transit.HasExternal || !isFiniteFloat(transit.ExternalIngress) || !isFiniteFloat(transit.ExternalEgress) ||
!isFiniteFloat(transit.Greatest) || transit.ExternalEgress <= transit.ExternalIngress ||
transit.ExternalEgress-transit.ExternalIngress > planetTransitMaxSpanDays ||
transit.Greatest < transit.ExternalIngress || transit.Greatest > transit.ExternalEgress {
start, end := transit.WindowStart, transit.WindowEnd
if transit.HasExternal {
start, end = transit.ExternalIngress, transit.ExternalEgress
}
if !transit.HasExternal && (transit.ExternalIngress != 0 || transit.ExternalEgress != 0 || transit.HasInternal) {
return nil, false
}
if !transit.Valid || !isFiniteFloat(start) || !isFiniteFloat(end) || start == 0 || end == 0 ||
!isFiniteFloat(transit.Greatest) || end <= start || end-start > planetTransitMaxSpanDays ||
transit.Greatest < start || transit.Greatest > end {
return nil, false
}
var cfg planetTransitConfig
@@ -186,8 +193,8 @@ func buildPlanetTransitSnapshot(transit PlanetTransitResult, inst *Instance) (*p
default:
return nil, false
}
startTT := UTC2TT(transit.ExternalIngress) - planetTransitSnapshotMarginDays
endTT := UTC2TT(transit.ExternalEgress) + planetTransitSnapshotMarginDays
startTT := UTC2TT(start) - planetTransitSnapshotMarginDays
endTT := UTC2TT(end) + planetTransitSnapshotMarginDays
count := int(math.Ceil((endTT-startTT)/planetTransitSnapshotStepDays)) + 1
snapshot := &planetTransitSnapshot{planetIndex: transit.PlanetIndex, startTT: startTT, step: planetTransitSnapshotStepDays}
for column := range snapshot.columns {
@@ -445,7 +452,11 @@ func (snapshot *planetTransitSnapshot) localContactTT(
if seedValue > 0 {
direction = -outward
}
limit := int(math.Ceil(planetTransitLocalWindowDays / planetTransitLocalScanStepDays))
span := planetTransitLocalWindowDays
if math.Abs(seedTT-greatestTT) < planetTransitLocalToleranceDays {
span = math.Max(greatestTT-snapshot.startTT, snapshot.startTT+float64(len(snapshot.columns[0])-1)*snapshot.step-greatestTT)
}
limit := int(math.Ceil(span / planetTransitLocalScanStepDays))
near, nearValue := seedTT, seedValue
for step := 1; step <= limit; step++ {
far := seedTT + direction*float64(step)*planetTransitLocalScanStepDays
@@ -544,13 +555,13 @@ type PlanetTransitIsochrone struct {
// PlanetTransitContactIsochrones 返回接触 kind 的站心等时线,时刻按 step 对齐到整刻度(UTC);
// 条数超过 64 时步长按倍数放大,保证覆盖整个接触时段;
// step 非正、kind 不是 C1–C4 或地心凌日没有该接触时返回 nil。
// step 非正、kind 不是 C1–C4 或地表没有该接触时返回 nil。
// 时刻是站心接触时刻,受视差影响可落在地心 C1–C4 之外(金星约数分钟);
// 一触、四触与远离掠凌边界的二触、三触,线上地点的站心接触时刻与 PlanetTransitLocal 相差不到 1 秒;
// 掠凌边界附近(站心内切余量 <0.5″)内切时刻梯度发散,偏差可达数十秒。
// PlanetTransitContactIsochrones returns topocentric isochrones of contact kind at UTC instants aligned to step.
// When more than 64 lines would be needed, the step is doubled until the whole contact span fits.
// It returns nil for a non-positive step, a kind other than C1-C4, or a contact the geocentric transit lacks.
// It returns nil for a non-positive step, a kind other than C1-C4, or a contact absent everywhere on Earth.
// Points agree with PlanetTransitLocal to within a second, except C2/C3 near the grazing limit (internal margin
// below 0.5″), where the contact-time gradient diverges and errors reach tens of seconds.
func PlanetTransitContactIsochrones(transit PlanetTransitResult, kind PlanetTransitContactKind, step time.Duration) []PlanetTransitIsochrone {
@@ -567,9 +578,6 @@ func PlanetTransitContactIsochronesWithInstance(
if step <= 0 || kind < PlanetTransitC1 || kind > PlanetTransitC4 {
return nil
}
if (kind == PlanetTransitC2 || kind == PlanetTransitC3) && !transit.HasInternal {
return nil
}
snapshot, ok := newPlanetTransitSnapshot(transit, inst)
if !ok {
return nil
@@ -971,9 +979,10 @@ func PlanetTransitLocalInfoFromResult(result PlanetTransitLocalResult, loc *time
}
}
// PlanetTransitResultFromTimes 由各接触时刻重建地心凌日结果,零值时刻表示该接触不存在。
// 行星包的 TransitInfo 只保存 time.Time,站心计算用它还原 basic 结果。
// PlanetTransitResultFromTimes rebuilds a geocentric transit result from contact times; a zero time means the contact is absent.
// PlanetTransitResultFromTimes 由各接触时刻重建地心凌日结果,一触、凌甚、四触必填,内切时刻为零表示不存在;
// 地心无接触事件改用 PlanetTransitResultFromWindow。
// PlanetTransitResultFromTimes rebuilds a geocentric transit result from contact times; C1, greatest and C4 are required,
// and a zero internal contact means it is absent. Use PlanetTransitResultFromWindow for events without geocentric contacts.
func PlanetTransitResultFromTimes(planetIndex int, start, internalStart, greatest, internalEnd, end time.Time) PlanetTransitResult {
jd := func(value time.Time) float64 {
if value.IsZero() {
@@ -992,9 +1001,27 @@ func PlanetTransitResultFromTimes(planetIndex int, start, internalStart, greates
HasExternal: true,
}
result.HasInternal = result.InternalIngress != 0 && result.InternalEgress != 0
if result.Valid {
result.WindowStart = result.ExternalIngress - planetTransitSnapshotMarginDays
result.WindowEnd = result.ExternalEgress + planetTransitSnapshotMarginDays
}
return result
}
// PlanetTransitResultFromWindow 由凌甚与站心求解窗口重建地心无接触的凌日结果 / rebuild a transit without geocentric contacts from greatest and its local search window.
func PlanetTransitResultFromWindow(planetIndex int, windowStart, greatest, windowEnd time.Time) PlanetTransitResult {
if windowStart.IsZero() || greatest.IsZero() || windowEnd.IsZero() || !windowStart.Before(greatest) || !greatest.Before(windowEnd) {
return PlanetTransitResult{PlanetIndex: planetIndex}
}
return PlanetTransitResult{
Valid: true,
PlanetIndex: planetIndex,
Greatest: Date2JD(greatest.UTC()),
WindowStart: Date2JD(windowStart.UTC()),
WindowEnd: Date2JD(windowEnd.UTC()),
}
}
// PlanetTransitLocalFrame 是站心日面上一个时刻的行星位置。
// PlanetTransitLocalFrame is the topocentric planet position on the solar disk at one instant.
type PlanetTransitLocalFrame struct {
+69
View File
@@ -0,0 +1,69 @@
package basic
import (
"math"
"b612.me/astro/internal/ephem"
)
func planetTransitPredictedSeason(seedTT float64, cfg planetTransitConfig, inst *Instance) bool {
centuries := (seedTT - 2451545) / 36525
if inst != nil && inst.Model() == ephem.Custom || centuries < -40 || centuries > 20 {
return true
}
node := 76.679920 + centuries*(0.9011206+centuries*(0.00040618-0.000000093*centuries))
if cfg.planetIndex == 1 {
node = 48.330893 + centuries*(1.1861883+centuries*(0.00017542+0.000000215*centuries))
}
solar := 280.46646 + centuries*(36000.76983+0.0003032*centuries)
phase := math.Abs(math.Remainder(solar-node, 180))
return phase <= 24
}
func planetTransitCoarseConjunction(seedTT float64, cfg planetTransitConfig, inst *Instance) float64 {
current := seedTT
for iteration := 0; iteration < 5; iteration++ {
value := planetTransitLongitudeDeltaN(current, cfg, planetTransitCoarseN, inst)
slope := (planetTransitLongitudeDeltaN(current+0.02, cfg, planetTransitCoarseN, inst) -
planetTransitLongitudeDeltaN(current-0.02, cfg, planetTransitCoarseN, inst)) / 0.04
if !isFiniteFloat(slope) || slope == 0 {
return math.NaN()
}
step := value / slope
current -= step
if math.Abs(step) < 0.0001 {
break
}
}
return current
}
// PlanetTransitSeries 凌日周期族,Number 为零表示超出编号适用范围 / transit family; a zero Number means unassigned.
type PlanetTransitSeries struct {
Number int
PeriodYears int
}
func planetTransitSeriesAt(greatestTT float64, cfg planetTransitConfig) PlanetTransitSeries {
period, synodicCycles := 243, 152
firstYear, lastYear := -2000, 4000
anchors := [][4]int{{-1892, 5, 21, 1}, {-1998, 11, 18, 2}, {-1884, 5, 19, 3}, {-1763, 11, 20, 4}, {554, 5, 22, 5}, {1631, 12, 7, 6}}
if cfg.planetIndex == 1 {
period, synodicCycles = 46, 145
firstYear, lastYear = 1601, 2300
anchors = [][4]int{{1644, 11, 9, 1}, {1631, 11, 7, 2}, {1628, 5, 5, 3}, {1618, 11, 4, 4}, {1615, 5, 3, 5}, {1605, 11, 1, 6}, {1740, 5, 2, 7}, {1776, 11, 2, 8}, {1957, 5, 6, 9}, {1993, 11, 6, 10}, {2174, 5, 8, 11}, {2210, 11, 9, 12}}
}
result := PlanetTransitSeries{PeriodYears: period}
if greatestTT < JDCalc(firstYear, 1, 1) || greatestTT >= JDCalc(lastYear+1, 1, 1) {
return result
}
cycle := int(math.Round((greatestTT - cfg.anchorInferiorTT) / cfg.synodicPeriodDays))
for _, anchor := range anchors {
anchorCycle := int(math.Round((JDCalc(anchor[0], anchor[1], float64(anchor[2])) - cfg.anchorInferiorTT) / cfg.synodicPeriodDays))
if (cycle-anchorCycle)%synodicCycles == 0 {
result.Number = anchor[3]
return result
}
}
return result
}
+79
View File
@@ -0,0 +1,79 @@
package basic
import "math"
// PlanetTransitVisibilityColumn 子午线上的可见纬度区间,端点单位为度 / visible latitude intervals on a meridian, in degrees.
type PlanetTransitVisibilityColumn struct {
Longitude float64
Intervals [][2]float64
}
// PlanetTransitVisibilityColumnsWithInstance 按 2° 经度、1° 纬度探测站心可见区并细化边界 / sample topocentric visibility at 2-degree longitude and 1-degree latitude spacing and refine boundaries.
func PlanetTransitVisibilityColumnsWithInstance(transit PlanetTransitResult, inst *Instance) []PlanetTransitVisibilityColumn {
snapshot, ok := newPlanetTransitSnapshot(transit, inst)
if !ok {
return nil
}
snapshot.visibilityOnce.Do(func() {
for column := 0; column <= 180; column++ {
longitude := -180 + float64(column)*2
visible := func(latitude float64) bool {
local := snapshot.local(transit, Observer{Longitude: longitude, Latitude: latitude})
return local.Valid && local.Visible
}
value := PlanetTransitVisibilityColumn{Longitude: longitude}
previous, start := visible(-90), -90.0
for latitude := -89.0; latitude <= 90; latitude++ {
current := visible(latitude)
if current != previous {
left, right := latitude-1, latitude
for right-left > 0.0001 {
middle := (left + right) / 2
if visible(middle) == previous {
left = middle
} else {
right = middle
}
}
boundary := (left + right) / 2
if current {
start = boundary
} else {
value.Intervals = append(value.Intervals, [2]float64{start, boundary})
}
}
previous = current
}
if previous {
value.Intervals = append(value.Intervals, [2]float64{start, 90})
}
snapshot.visibility = append(snapshot.visibility, value)
}
})
result := make([]PlanetTransitVisibilityColumn, len(snapshot.visibility))
for index, column := range snapshot.visibility {
result[index] = PlanetTransitVisibilityColumn{Longitude: column.Longitude, Intervals: append([][2]float64(nil), column.Intervals...)}
}
return result
}
// PlanetTransitVisibilityPolygonsWithInstance 可见区的经度条带多边形 / longitude-strip polygons of the visible region.
func PlanetTransitVisibilityPolygonsWithInstance(transit PlanetTransitResult, inst *Instance) [][]PlanetTransitIsochronePoint {
columns := PlanetTransitVisibilityColumnsWithInstance(transit, inst)
var result [][]PlanetTransitIsochronePoint
for index := 1; index < len(columns); index++ {
left, right := columns[index-1], columns[index]
for _, interval := range left.Intervals {
for _, other := range right.Intervals {
if math.Min(interval[1], other[1]) <= math.Max(interval[0], other[0]) {
continue
}
result = append(result, []PlanetTransitIsochronePoint{
{Longitude: left.Longitude, Latitude: interval[0]}, {Longitude: right.Longitude, Latitude: other[0]},
{Longitude: right.Longitude, Latitude: other[1]}, {Longitude: left.Longitude, Latitude: interval[1]},
})
}
}
}
return result
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 302 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 302 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+13 -2
View File
@@ -33,6 +33,7 @@
- [凌日出图](#凌日出图)
- [水星:2032-11-13(北京)](#水星2032-11-13北京)
- [金星:2012-06-06(北京)](#金星2012-06-06北京)
- [特例:地心无接触的部分凌日](#特例地心无接触的部分凌日)
## 简单示例:2009长江大日食,上海某地见食情况
@@ -990,10 +991,14 @@ ut1, ok := eclipsesvg.SolarEclipseMapSVG(
| 入口 | 画面 | 画布建议 |
| --- | --- | --- |
| `TransitMapSVG` | 地心几何的全球可见图:全程可见、日出时已在凌日、日落时仍在凌日、不可见四区 | 960×640 起 |
| `TransitMapSVG` | 全球可见图;地心无接触时绘制站心部分凌日可见区 | 960×640 起 |
| `TransitLocalSVG` | 站心日面图:上北左东,行星中心轨迹、各接触与凌甚的行星圆面、接触时刻表 | 960×680 起 |
可见区按一触、凌甚、四触三刻太阳中心是否在视地平上划分,计入标准大气折射(地平圈半径 90°34′)。等时线默认不画,用 `Isochrones` 按位开启:`TransitIsochroneC1` … `TransitIsochroneC4`,或组合 `TransitIsochroneExternal`(一触、四触)、`TransitIsochroneAll`;掠凌没有内切接触时 C2/C3 自动略去。等时线是站心接触时刻,只画太阳在视地平上的部分;`IsochroneStep` 非正时水星取 20 秒、金星取 1 分钟。凌日可见范围接近半个地球,地图始终画全球,`Projection` 可选四种投影。
普通凌日可见区按一触、凌甚、四触三刻太阳中心是否在视地平上划分,计入标准大气折射(地平圈半径 90°34′);地心无接触事件改用实际站心可见区。等时线默认不画,用 `Isochrones` 按位开启:`TransitIsochroneC1` … `TransitIsochroneC4`,或组合 `TransitIsochroneExternal`(一触、四触)、`TransitIsochroneAll`;地表没有站心内切接触时 C2/C3 不画,图例也不列出;地心只有外切的掠凌仍可能在部分地区出现站心二、三触,此时照常绘制。等时线是站心接触时刻,只画太阳在视地平上的部分;`IsochroneStep` 非正时水星取 20 秒、金星取 1 分钟。
地心无接触事件的部分凌日可见区按 2° 经度、1° 纬度探测并细化已检出的边界,这只是制图采样,不是站心接触求解精度;比采样间距更窄的可见区可能未被检出,需要精确的地点判断时直接用 `LocalTransit`。
`Projection` 可选四种投影,默认等经纬图展示全球。正射图只展示朝向视点的半个地球:水星、金星在地心无接触时以站心可见区的球面面积加权中心取景,例如 `1937-05-11` 水星凌日和 `2854-12-14` 金星凌日;普通凌日仍以凌甚太阳直射点取景。极区事件也可显式选择南、北极投影。
```go
venus := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
@@ -1022,3 +1027,9 @@ local, ok := eclipsesvg.TransitLocalSVG(eclipsesvg.TransitVenus, venus, 116.4, 3
![2012 金星凌日北京站心图](../img/transit-venus-beijing-2012.svg)
![2012 金星凌日全球可见图](../img/transit-venus-beijing-2012-global.svg)
### 特例:地心无接触的部分凌日
1937-05-11 水星凌日即为一例,可见区横跨南大西洋、非洲南端与印度洋,东至澳大利亚东侧。`2854-12-14` 金星凌日同属此类,可见区跨越南极圈,集中在南纬 50° 以南。
![1937 水星凌日(地心无接触)全球可见图](../img/transit-mercury-1937-partial.svg)
+13 -2
View File
@@ -33,6 +33,7 @@ This manual covers solar- and lunar-eclipse queries and charts, plus the two Mer
- [Transit charts](#transit-charts)
- [Mercury: 2032-11-13 (Beijing)](#mercury-2032-11-13-beijing)
- [Venus: 2012-06-06 (Beijing)](#venus-2012-06-06-beijing)
- [Special case: partial transits without geocentric contacts](#special-case-partial-transits-without-geocentric-contacts)
## Simple example: the 2009 Great Yangtze Eclipse at a site in Shanghai
@@ -1064,10 +1065,14 @@ Transit contact times, topocentric visibility and `LocalTransit` live in [Planet
| Entry | Chart | Suggested canvas |
| --- | --- | --- |
| `TransitMapSVG` | Geocentric global visibility: entire transit, transit at sunrise, transit at sunset, not visible | 960×640 and up |
| `TransitMapSVG` | Global visibility, including topocentric partial-only events without geocentric contacts | 960×640 and up |
| `TransitLocalSVG` | Topocentric solar disk: north up, east left, planet-center track, planet disk at each contact and at greatest, contact table | 960×680 and up |
Regions follow whether the Sun's center is above the apparent horizon at C1, greatest and C4, with standard refraction (a horizon circle of 90°34′). Isochrones are off by default; enable them bitwise with `Isochrones`: `TransitIsochroneC1` … `TransitIsochroneC4`, or the combinations `TransitIsochroneExternal` (C1 and C4) and `TransitIsochroneAll`. C2/C3 are dropped for a grazing transit without internal contacts. Isochrones are topocentric contact times drawn only where the Sun is above the apparent horizon; a non-positive `IsochroneStep` uses 20 s for Mercury and 1 min for Venus. A transit is visible from roughly half the globe, so the map always shows the whole world; `Projection` selects one of the four projections.
For ordinary transits, regions follow whether the Sun's center is above the apparent horizon at C1, greatest and C4, with standard refraction (a horizon circle of 90°34′); events without geocentric contacts use the actual topocentric visible footprint. Isochrones are off by default; enable them bitwise with `Isochrones`: `TransitIsochroneC1` … `TransitIsochroneC4`, or the combinations `TransitIsochroneExternal` (C1 and C4) and `TransitIsochroneAll`. C2/C3 are omitted, including from the legend, when no site has an internal contact; a graze with only geocentric external contacts can still have topocentric C2/C3 in some regions, and those are drawn. Isochrones are topocentric contact times drawn only where the Sun is above the apparent horizon; a non-positive `IsochroneStep` uses 20 s for Mercury and 1 min for Venus.
For events without geocentric contacts the partial footprint is sampled on a 2° longitude by 1° latitude grid and detected boundaries are refined. That is chart sampling, not contact accuracy: a footprint narrower than the grid may go undetected, so use `LocalTransit` for exact site decisions.
`Projection` selects one of four projections; the default equirectangular map shows the whole world. Orthographic maps show only the facing hemisphere. For both Mercury and Venus, events without geocentric contacts centre on the spherical area-weighted visible footprint, for example Mercury on `1937-05-11` and Venus on `2854-12-14`. Ordinary transits retain the subsolar centre at greatest. Polar events may also use an explicit north- or south-polar projection.
```go
venus := time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)
@@ -1097,3 +1102,9 @@ The Venus transit of 2012-06-06 was visible in full from Beijing, with `6h39m4s`
![Venus transit of 2012-06-06 from Beijing](../../img/transit-venus-beijing-2012-en.svg)
![Venus transit of 2012-06-06 global visibility](../../img/transit-venus-beijing-2012-global-en.svg)
### Special case: partial transits without geocentric contacts
The Mercury transit of 1937-05-11 is one such event: with no geocentric contacts, its footprint spans the South Atlantic, southern Africa and the Indian Ocean east to eastern Australia. The Venus transit of `2854-12-14` belongs to the same class, with a footprint that crosses the Antarctic Circle, all south of 50°S.
![Mercury transit of 1937-05-11 without geocentric contacts, global visibility](../../img/transit-mercury-1937-partial-en.svg)
+8 -4
View File
@@ -73,7 +73,11 @@ A partial eclipse may have no central path, so `centralPath` may be `nil`. This
## Exporting transit GeoJSON
The transit entry points take a geocentric `TransitInfo` and partition the globe exactly like the transit visibility map:
The transit entry points take a global-event `TransitInfo`; contact fields remain geocentric, and regions match the transit visibility map.
Events without geocentric contacts, such as the Venus transit of 2854-12-14, export the actual topocentric
`transit-partial` footprint and omit nonexistent geocentric C1/C4 horizons. Properties also carry
`has_geocentric_contacts`, `gamma`, `penumbral_radius`, `series` and `series_period_years`. The footprint is sampled on a 2° longitude by 1° latitude grid with boundary refinement; that is chart sampling rather than contact accuracy, so a footprint narrower than the grid may go undetected — use `LocalTransit` for exact site decisions.
```go
m := mercury.NextTransit(time.Date(2026, 10, 12, 0, 0, 0, 0, cst))
@@ -194,7 +198,7 @@ Grouped by figure family, the `data-source` values are listed below; the `eclips
- Solar-eclipse instants and magnitude lines: `rise-set-phase-lines`, `magnitude-contours`, `greatest-time-isochrones`
- Instantaneous outlines: `penumbral-outlines`, `central-shadow-outlines`
- Lunar eclipses: `p1-p4-visibility-regions`, `p1-p4-horizon-boundaries`
- Transits: `c1-c4-visibility-regions`, `c1-c4-horizon-boundaries`, `transit-contact-isochrones`
- Transits: `c1-c4-visibility-regions`, `c1-c4-horizon-boundaries`, `transit-contact-isochrones`; events without geocentric contacts fill the footprint from `topocentric-transit-visibility`
## Map Projections
@@ -312,7 +316,7 @@ Each event type has plain and time-marker variants:
- `MarshalLunarEclipseWithOptions`: one `LunarEclipseOptions` value carrying the time markers, `SkipRoles` and the time-envelope sampling parameters. `SkipRoles` can omit envelopes and penumbra-only bands; skipping both envelopes also skips their sampling. `EnvelopeSweepSamples` defaults to 48 and is clamped to `[2, 192]`; `EnvelopeLongitudePoints` defaults to `max(360, boundaryPoints)` and is clamped to `[12, 720]`.
- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers`
- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers`
- `MarshalMercuryTransit` / `MarshalVenusTransit`: take `mercury.TransitInfo` / `venus.TransitInfo` and partition the globe like the transit SVG map (whether the Sun's center is above the apparent horizon, standard refraction, at C1, greatest and C4). `BoundaryPoints` is the horizon sample count and also sets the region longitude columns to `max(360, BoundaryPoints)`, capped at 720. Roles are the three regions `transit-entire`, `transit-at-sunrise` and `transit-at-sunset`, the visible hemispheres and horizons `visible-at-c1` / `visible-at-c4` and `c1-horizon` / `c4-horizon`, `greatest` (the subsolar point) and the time markers `time-marker`. A positive `IsochroneStep` (at least 1 s) adds topocentric `c1-isochrone` through `c4-isochrone` (no C2/C3 for a grazing transit; the times are topocentric contacts and parallax can place them outside the geocentric C1–C4 interval), and a contact whose isochrone does not reach the visible hemisphere omits that role; skipping all three region roles skips the region computation.
- `MarshalMercuryTransit` / `MarshalVenusTransit`: take `mercury.TransitInfo` / `venus.TransitInfo` and partition the globe like the transit SVG map (whether the Sun's center is above the apparent horizon, standard refraction, at C1, greatest and C4). `BoundaryPoints` is the horizon sample count and also sets the region longitude columns to `max(360, BoundaryPoints)`, capped at 720. Roles are the three regions `transit-entire`, `transit-at-sunrise` and `transit-at-sunset`, the visible hemispheres and horizons `visible-at-c1` / `visible-at-c4` and `c1-horizon` / `c4-horizon`, `greatest` (the subsolar point) and the time markers `subsolar-track`. Events without geocentric contacts switch to a `transit-partial` region and omit the three visibility regions, the horizons and the visible hemispheres. A positive `IsochroneStep` (at least 1 s) adds topocentric `c1-isochrone` through `c4-isochrone` (no C2/C3 when no site has an internal contact, though a graze with only geocentric external contacts can still have them in some regions; the times are topocentric contacts and parallax can place them outside the geocentric C1–C4 interval), and a contact whose isochrone does not reach the visible hemisphere omits that role; skipping all three region roles skips the region computation.
- `MarshalSolarEclipseWithOptions`: one `SolarEclipseOptions` value carrying both the time markers and `SkipRoles`, equivalent to the solar entry points above plus layer trimming.
The usual entry in `SkipRoles` is `partial-footprint` (the instantaneous penumbral outlines); `partial-band` is the edge of the eclipse band (the magnitude-0 limit) and is normally kept.
@@ -472,7 +476,7 @@ Features are grouped into Folders by `event` and `role`. Collections containing
| Total and partial occultation bands | `total-band`, `partial-band`, `total-band-outline`, `occultation-band` | Yellow with translucent band fill |
| Rise/set visibility edges, lunar time envelopes and solar partial-band edge | `visibility-boundary`, `p1-horizon`, `p4-horizon`, `visible-at-p1`, `visible-at-p4`, `visible-during-eclipse`, `visible-throughout-eclipse`, solar `partial-band` | Orange |
| Penumbra-only moonrise/moonset bands | `penumbra-moonrise`, `penumbra-moonset` | Blue-violet for moonrise, violet for moonset, with a translucent fill |
| Transit visibility regions and contact lines | `transit-entire`, `transit-at-sunrise`, `transit-at-sunset`, `c1-horizon`, `c4-horizon`, `visible-at-c1`, `visible-at-c4`, `c1-isochrone` … `c4-isochrone` | Each of the three regions has its own fill; C1/C4 horizons and visible hemispheres are orange; the four contact isochrones each get a color |
| Transit visibility regions and contact lines | `transit-entire`, `transit-at-sunrise`, `transit-at-sunset`, `transit-partial`, `c1-horizon`, `c4-horizon`, `visible-at-c1`, `visible-at-c4`, `c1-isochrone` … `c4-isochrone` | Each of the three regions and the partial-transit footprint has its own fill; C1/C4 horizons and visible hemispheres are orange; the four contact isochrones each get a color |
| Other limits, outlines, footprints and time markers | Other roles | Grey; polygons are outline-only by default |
Zero-valued `Style` fields retain palette defaults:
+15 -13
View File
@@ -38,7 +38,7 @@ The inner planets (Mercury and Venus) add superior/inferior conjunction, greates
- [Position and coordinates](#position-and-coordinates)
- [Rise, set and culmination](#rise-set-and-culmination)
- [Conjunctions, oppositions, stations and quadratures](#conjunctions-oppositions-stations-and-quadratures)
- [Greatest elongation and geocentric transits](#greatest-elongation-and-geocentric-transits)
- [Greatest elongation and transits](#greatest-elongation-and-transits)
- [Nodes, phase, magnitude, diameter and parallactic angle](#nodes-phase-magnitude-diameter-and-parallactic-angle)
- [Division of labour with other manuals](#division-of-labour-with-other-manuals)
- [Physical ephemerides](#physical-ephemerides)
@@ -188,22 +188,22 @@ fmt.Println(saturn.NextProgradeToRetrograde(date)) // Saturn's next prograde-to-
Event searches return the nearest event at or after the input instant and keep its time zone; the paired `Last...` functions and the direction-agnostic `NextRetrograde` are in [Conjunctions, oppositions, stations and quadratures](#conjunctions-oppositions-stations-and-quadratures);
Mercury's and Venus's `NextGreatestElongationEast` / `...West` are in [Greatest elongation and geocentric transits](#greatest-elongation-and-geocentric-transits).
Mercury's and Venus's `NextGreatestElongationEast` / `...West` are in [Greatest elongation and transits](#greatest-elongation-and-transits).
### Mercury and Venus transits
```go
transit := mercury.NextTransit(date) // next geocentric Mercury transit after 2020
transit := mercury.NextTransit(date) // next Mercury transit after 2020
fmt.Println(transit.Valid, transit.Start, transit.Greatest) // whether one was found, first contact, greatest transit
fmt.Println(transit.Duration, transit.MinimumSeparationArcsec) // duration and minimum separation at greatest transit
```
```text
true 2032-11-13 14:41:13.161198198 +0800 CST 2032-11-13 16:54:12.821315824 +0800 CST
4h26m2.695272267s 572.0643215495325
true 2032-11-13 14:41:13.691189289 +0800 CST 2032-11-13 16:54:13.351347148 +0800 CST
4h26m2.484410405s 572.0831814505963
```
`TransitInfo.Valid` false means no transit inside the search window and every other field is the zero value; `TransitInfo` is the geocentric geometry. For the contact times seen from one site, `LocalTransit` returns the topocentric result:
`TransitInfo.Valid` false means no transit inside the search window and every other field is the zero value; otherwise `TransitInfo` describes a global event whose contact fields are geocentric reference times. Without geocentric contacts `HasExternal` is false, `Start` / `End` are zero, and only the `WindowStart` / `WindowEnd` search window is set. For the contact times seen from one site, `LocalTransit` returns the topocentric result:
```go
transit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC))
@@ -214,12 +214,14 @@ fmt.Println(local.Start.Time, local.Start.SunAltitude) // topocentric C1
```text
true true true
2019-11-11 12:36:09.037762284 +0000 UTC 7.933256722170642
2019-11-11 12:36:08.979182839 +0000 UTC 7.933092450591312
```
Topocentric contacts are re-solved after applying parallax to the Sun and the planet separately; Venus contacts can shift by about ±7 minutes from the geocentric ones across the globe, Mercury by about ±2 minutes. `SunAltitude` is the geometric altitude of the Sun's center; `Visible`, `Sunrise` and `Sunset` test whether the Sun's center is above the apparent horizon with standard refraction (1010 hPa, 10 °C), a geometric threshold of about −34′. A site may see only a grazing transit even when the geocentric one has internal contacts (the 1999-11-15 Mercury transit at high southern latitudes); then `HasInternal` is false and `InternalStart` / `InternalEnd` are zero. Global maps and topocentric disk charts are in [Eclipses and transits](eclipse.md#transit-charts).
The four contacts, partial transits and the internal-contact fields are in [Greatest elongation and geocentric transits](#greatest-elongation-and-geocentric-transits).
The four contacts, partial transits and the internal-contact fields are in [Greatest elongation and transits](#greatest-elongation-and-transits).
`Gamma` is the signed distance from Earth's centre to the shadow axis at greatest (positive to the north) and `PenumbralRadius` is the penumbral radius on the fundamental plane, both in Earth equatorial radii. The transit penumbra is far larger than Earth, so the solar-eclipse test `|Gamma| < 1` does not apply. `Series` is the transit family: Venus families average 243 years and are numbered for astronomical years −2000 to +4000, Mercury families average 46 years and are numbered for +1601 to +2300; outside those spans, or for an unnumbered family, `Number` is zero while `PeriodYears` still gives the mean period.
### Phase, diameter, magnitude and nodes
@@ -580,15 +582,15 @@ fmt.Println(saturn.LastProgradeToRetrograde(date), saturn.NextProgradeToRetrogra
fmt.Println(saturn.LastRetrogradeToPrograde(date), saturn.NextRetrogradeToPrograde(date))
```
### Greatest elongation and geocentric transits
### Greatest elongation and transits
Greatest elongation only makes sense for Mercury and Venus: `LastGreatestElongation` / `NextGreatestElongation` ignore the side, while `LastGreatestElongationEast` / `NextGreatestElongationEast` and the `...West` forms distinguish it.
The public entry points for geocentric transits are likewise only in `mercury` / `venus`: `LastTransit` / `NextTransit` / `ClosestTransit` return a `TransitInfo`.
The public transit entry points are likewise only in `mercury` / `venus`: `LastTransit` / `NextTransit` / `ClosestTransit` return a `TransitInfo`.
`Valid == false` means no transit inside the search window and every other field is a zero value; a partial transit has no internal contacts, so `HasInternal` is false and `InternalStart` / `InternalEnd` are zero.
`Valid == false` means no transit inside the search window and every other field is a zero value. Without geocentric contacts `HasExternal` is false and `Start` / `End` are zero; the event is then accepted or rejected by topocentric surface visibility. Without a topocentric internal contact `HasInternal` is false and `InternalStart` / `InternalEnd` are zero.
A transit is a purely geocentric geometry test and does not check whether the Sun is above the horizon at a given site.
Contact fields come from the geocentric geometry, but whether a site can see the transit, and which contacts it sees, is decided by `LocalTransit`.
```go
// Greatest elongation: use ...East / ...West to pick a side, or the plain form for either.
@@ -902,7 +904,7 @@ Geocentric, topocentric, and distance are three different conventions: `Apparent
### Zero values, out-of-range input and sentinel errors
When an event search finds nothing inside its window it returns zero values or a struct with `Valid == false` (`TransitInfo.Valid`, `GalileanPhenomenonEvent.Valid`, `GalileanPhenomenonContactEvent.Valid`) instead of an error; when `TransitInfo.HasInternal` is false, `InternalStart` / `InternalEnd` are zero values because a partial transit has no internal contacts.
When an event search finds nothing inside its window it returns zero values or a struct with `Valid == false` (`TransitInfo.Valid`, `GalileanPhenomenonEvent.Valid`, `GalileanPhenomenonContactEvent.Valid`) instead of an error; when `TransitInfo.HasExternal` is false, `Start` / `End` are zero values, and when `TransitInfo.HasInternal` is false, `InternalStart` / `InternalEnd` are zero values.
`planet.WherePlanet` / `planet.WherePlanetN` return `NaN` instead of panicking when `xt` / `zn` is out of range. `n < 0` selects the full series; all nonnegative `n` select one semianalytic fast table, while Legacy truncates according to `n`.
+8 -4
View File
@@ -73,7 +73,11 @@ func main() {
## 导出凌日 GeoJSON
凌日入口接收地心 `TransitInfo`,分区口径与凌日全球图一致:
凌日入口接收全球事件的 `TransitInfo`,接触字段仍为地心参考时刻,分区口径与凌日全球图一致:
地心无接触的边缘事件(如 2854-12-14 金星凌日)导出站心实际可见的 `transit-partial` 区域,
不生成不存在的地心 C1/C4 地平线。属性还包含 `has_geocentric_contacts`、`gamma`、`penumbral_radius`、
`series`、`series_period_years`。足迹按 2° 经度、1° 纬度采样并细化边界,这是制图采样而不是接触求解精度,比采样间距更窄的可见区可能未检出;精确判断请用 `LocalTransit`。
```go
m := mercury.NextTransit(time.Date(2026, 10, 12, 0, 0, 0, 0, cst))
@@ -190,7 +194,7 @@ true <nil> true 4217
- 日食时刻与食分线:`rise-set-phase-lines`、`magnitude-contours`、`greatest-time-isochrones`
- 瞬时轮廓:`penumbral-outlines`、`central-shadow-outlines`
- 月食:`p1-p4-visibility-regions`、`p1-p4-horizon-boundaries`
- 凌日:`c1-c4-visibility-regions`、`c1-c4-horizon-boundaries`、`transit-contact-isochrones`
- 凌日:`c1-c4-visibility-regions`、`c1-c4-horizon-boundaries`、`transit-contact-isochrones`;地心无接触时可见区填充改用 `topocentric-transit-visibility`
## 地图投影
@@ -294,7 +298,7 @@ func main() {
- `MarshalLunarEclipseWithOptions`:用 `LunarEclipseOptions` 一次给出时间标记、`SkipRoles` 与时间包络的采样参数。`SkipRoles` 可跳过时间包络和仅见半影带;跳过两个时间包络时不会执行包络采样。`EnvelopeSweepSamples` 默认 48,限制在 `[2, 192]`;`EnvelopeLongitudePoints` 默认 `max(360, boundaryPoints)`,限制在 `[12, 720]`。
- `MarshalStarOccultation` / `MarshalStarOccultationWithTimeMarkers`
- `MarshalPlanetOccultation` / `MarshalPlanetOccultationWithTimeMarkers`
- `MarshalMercuryTransit` / `MarshalVenusTransit`:接收 `mercury.TransitInfo` / `venus.TransitInfo`,分区口径同凌日 SVG 全球图(一触、凌甚、四触太阳中心是否在视地平上,计入标准大气折射)。`BoundaryPoints` 是地平线采样点数,同时决定分区的经度列数 `max(360, BoundaryPoints)`(上限 720)。role 有 `transit-entire`、`transit-at-sunrise`、`transit-at-sunset` 三个分区,`visible-at-c1` / `visible-at-c4` 与 `c1-horizon` / `c4-horizon`,`greatest`(太阳直射点)与时间标记 `time-marker`;`IsochroneStep` 为正(至少 1 秒)时追加 `c1-isochrone` 到 `c4-isochrone` 站心等时线(掠凌无二、三触;时刻是站心接触时刻,受视差影响可落在地心一触到四触之外),某个接触的等时线不落在可见半球上时该 role 不出现;`SkipRoles` 跳过全部三个分区时不做分区计算。
- `MarshalMercuryTransit` / `MarshalVenusTransit`:接收 `mercury.TransitInfo` / `venus.TransitInfo`,分区口径同凌日 SVG 全球图(一触、凌甚、四触太阳中心是否在视地平上,计入标准大气折射)。`BoundaryPoints` 是地平线采样点数,同时决定分区的经度列数 `max(360, BoundaryPoints)`(上限 720)。role 有 `transit-entire`、`transit-at-sunrise`、`transit-at-sunset` 三个分区,`visible-at-c1` / `visible-at-c4` 与 `c1-horizon` / `c4-horizon`,`greatest`(太阳直射点)与时间标记 `subsolar-track`;地心无接触事件改用 `transit-partial` 分区,不生成三个可见分区、视地平与可见半球 role;`IsochroneStep` 为正(至少 1 秒)时追加 `c1-isochrone` 到 `c4-isochrone` 站心等时线(地表没有站心内切接触时无二、三触,地心只有外切的掠凌仍可能在部分地区出现;时刻是站心接触时刻,受视差影响可落在地心一触到四触之外),某个接触的等时线不落在可见半球上时该 role 不出现;`SkipRoles` 跳过全部三个分区时不做分区计算。
- `MarshalSolarEclipseWithOptions`:用 `SolarEclipseOptions` 一次给出时间标记与 `SkipRoles`,等价于上面的日食入口再加图层裁剪。`SkipRoles` 里最常用的是 `partial-footprint`(瞬时半影轮廓);`partial-band` 是日食带边缘(食分 0 界限),通常要留着。
### 图层筛选
@@ -434,7 +438,7 @@ func main() {
| 掩星全掩与偏掩带 | `total-band`、`partial-band`、`total-band-outline`、`occultation-band` | 黄色,带半透明填充 |
| 升落可见边界、月食时间包络、日食偏食带边缘 | `visibility-boundary`、`p1-horizon`、`p4-horizon`、`visible-at-p1`、`visible-at-p4`、`visible-during-eclipse`、`visible-throughout-eclipse`、日食的 `partial-band` | 橙色 |
| 仅见半影的月出/月落带 | `penumbra-moonrise`、`penumbra-moonset` | 月出蓝紫、月落紫红,带半透明填充 |
| 凌日可见分区与接触线 | `transit-entire`、`transit-at-sunrise`、`transit-at-sunset`、`c1-horizon`、`c4-horizon`、`visible-at-c1`、`visible-at-c4`、`c1-isochrone` … `c4-isochrone` | 三个分区各有填充色;C1/C4 视地平与可见半球橙色;四触等时线各一色 |
| 凌日可见分区与接触线 | `transit-entire`、`transit-at-sunrise`、`transit-at-sunset`、`transit-partial`、`c1-horizon`、`c4-horizon`、`visible-at-c1`、`visible-at-c4`、`c1-isochrone` … `c4-isochrone` | 三个分区与部分凌日可见区各有填充色;C1/C4 视地平与可见半球橙色;四触等时线各一色 |
| 其他限界、轮廓、足迹与时间标记 | 其他 `role` | 灰色,面默认只画轮廓 |
`Style` 的零值字段沿用默认样式:
+16 -14
View File
@@ -32,7 +32,7 @@
- [位置与坐标](#位置与坐标)
- [升落与中天](#升落与中天)
- [合冲留与方照](#合冲留与方照)
- [大距与地心凌日](#大距与地心凌日)
- [大距与凌日](#大距与凌日)
- [节点、相位、视星等、视直径与视差角](#节点相位视星等视直径与视差角)
- [与其他手册的分工](#与其他手册的分工)
- [物理星历](#物理星历)
@@ -174,22 +174,22 @@ fmt.Println(saturn.NextProgradeToRetrograde(date)) // 土星下次顺转逆的
2020-05-11 17:26:53.961271941 +0800 CST
```
事件搜索取"当前或之后最近一次"并保持输入时区,成对的 `Last...` 与不区分方向的 `NextRetrograde` 见[合冲留与方照](#合冲留与方照);水星、金星的 `NextGreatestElongationEast` / `...West` 见[大距与地心凌日](#大距与地心凌日)。
事件搜索取"当前或之后最近一次"并保持输入时区,成对的 `Last...` 与不区分方向的 `NextRetrograde` 见[合冲留与方照](#合冲留与方照);水星、金星的 `NextGreatestElongationEast` / `...West` 见[大距与凌日](#大距与凌日)。
### 水星与金星凌日
```go
transit := mercury.NextTransit(date) // 2020 年之后下一场地心水星凌日
transit := mercury.NextTransit(date) // 2020 年之后下一场水星凌日
fmt.Println(transit.Valid, transit.Start, transit.Greatest) // 是否有凌日、一触与凌甚
fmt.Println(transit.Duration, transit.MinimumSeparationArcsec) // 历时与凌甚最小角距
```
```text
true 2032-11-13 14:41:13.161198198 +0800 CST 2032-11-13 16:54:12.821315824 +0800 CST
4h26m2.695272267s 572.0643215495325
true 2032-11-13 14:41:13.691189289 +0800 CST 2032-11-13 16:54:13.351347148 +0800 CST
4h26m2.484410405s 572.0831814505963
```
`TransitInfo.Valid` 为假表示搜索窗口内没有凌日、其余字段是零值;`TransitInfo` 是地心几何结果。某个地点看到的接触时刻用 `LocalTransit` 求站心结果:
`TransitInfo.Valid` 为假表示搜索窗口内没有凌日、其余字段是零值;其余情况下 `TransitInfo` 表示全球事件,接触字段是地心参考时刻。地心无接触时 `HasExternal` 为假,`Start` / `End` 也是零值,只有 `WindowStart` / `WindowEnd` 站心求解窗口有值。某个地点看到的接触时刻用 `LocalTransit` 求站心结果:
```go
transit := mercury.NextTransit(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC))
@@ -200,15 +200,17 @@ fmt.Println(local.Start.Time, local.Start.SunAltitude) // 站心一触
```text
true true true
2019-11-11 12:36:09.037762284 +0000 UTC 7.933256722170642
2019-11-11 12:36:08.979182839 +0000 UTC 7.933092450591312
```
站心接触对太阳与行星分别做视差修正后重解,所以各地接触时刻与地心值不同:金星可达约 ±7 分钟,水星约 ±2 分钟。
- `SunAltitude` 是日心几何高度。
- `SunAltitude` 是太阳中心的几何高度,不含折射。
- `Visible`、`Sunrise`、`Sunset` 按太阳中心是否在视地平上判定,计入标准大气(1010 hPa、10 °C)折射,几何阈值约 −34′。
- 地心有内切接触、站心可能只是掠凌(例如 1999-11-15 水星凌日在南半球高纬),此时 `HasInternal` 为假,`InternalStart` / `InternalEnd` 是零值。
- 全球可见图与站心日面图见[日月食与凌日](eclipse.md#凌日出图);四触、偏凌与内切口径见[大距与地心凌日](#大距与地心凌日)。
- 全球可见图与站心日面图见[日月食与凌日](eclipse.md#凌日出图);四触、偏凌与内切口径见[大距与凌日](#大距与凌日)。
`Gamma` 是凌甚时影轴到地心的有符号距离(北侧为正),`PenumbralRadius` 是地心基本面上的半影半径,两者都以地球赤道半径为单位。凌日半影远大于地球,不要套用日食的 `|Gamma| < 1` 判断。`Series` 是周期族:金星平均 243 年、编号适用于天文年 −2000 至 +4000,水星平均 46 年、适用于 +1601 至 +2300;超出该范围或不属于已编号族时 `Number` 为 0,`PeriodYears` 仍给出平均周期。
### 相位、视直径、视星等与节点
@@ -348,7 +350,7 @@ fmt.Println(venus.AscendingNode(date), venus.DescendingNode(date))
76.86008484515058 256.8600848451506 // 金星升交点、降交点黄经,单位度
```
水星和金星还提供 `NextTransit` / `LastTransit` / `ClosestTransit` 地心凌日查询。这里的“地心”指从地球中心看到的行星圆面经过太阳圆面,不判断某个地点当时太阳是否在地平线上;观测计划用 `LocalTransit` 求站心接触与当时太阳高度(见[水星与金星凌日](#水星与金星凌日))。
水星和金星还提供 `NextTransit` / `LastTransit` / `ClosestTransit` 凌日查询。搜索返回全球事件,接触字段是地心参考时刻;地心无接触、只有部分地区可见的掠凌也会返回,这类事件是否成立按地表站心可见性判定。某个地点当时太阳是否在地平线上、当地看到哪些接触,用 `LocalTransit` 求站心结果(见[水星与金星凌日](#水星与金星凌日))。
```go
package main
@@ -553,11 +555,11 @@ fmt.Println(saturn.LastProgradeToRetrograde(date), saturn.NextProgradeToRetrogra
fmt.Println(saturn.LastRetrogradeToPrograde(date), saturn.NextRetrogradeToPrograde(date))
```
### 大距与地心凌日
### 大距与凌日
大距只对水星、金星有意义:`LastGreatestElongation` / `NextGreatestElongation` 不区分东西,`LastGreatestElongationEast` / `NextGreatestElongationEast` 与 `...West` 区分。
地心凌日的公开入口同样只在 `mercury` / `venus`:`LastTransit` / `NextTransit` / `ClosestTransit` 返回 `TransitInfo`。`Valid` 为假表示搜索窗口内没有凌日,其余字段都是零值;偏凌日没有内切,此时 `HasInternal` 为假、`InternalStart` / `InternalEnd` 是零值。凌日只判断地心几何,不判断某地当时太阳是否在地平线上。
凌日的公开入口同样只在 `mercury` / `venus`:`LastTransit` / `NextTransit` / `ClosestTransit` 返回 `TransitInfo`。`Valid` 为假表示搜索窗口内没有凌日,其余字段都是零值。地心无接触时 `HasExternal` 为假、`Start` / `End` 为零值,事件是否成立按地表站心可见性判定;站心内切不存在时 `HasInternal` 为假、`InternalStart` / `InternalEnd` 是零值。某个地点是否可见、看到哪些接触用 `LocalTransit` 判定。
```go
// 大距:区分东西用 ...East / ...West,不区分时用 NextGreatestElongation。
@@ -566,7 +568,7 @@ fmt.Println(venus.NextGreatestElongation(date))
```
```go
// 地心凌日:Valid 为假时其余字段为零值。
// 凌日:Valid 为假时其余字段为零值。
transit := mercury.NextTransit(date)
if transit.Valid {
fmt.Println(transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End)
@@ -859,7 +861,7 @@ fmt.Println(planet.WherePlanet(-1, 0, 2460000.5), planet.WherePlanet(4, 0, 24600
### 零值、越界与哨兵错误
事件搜索在窗口内没有事件时返回零值或 `Valid=false` 的结构(`TransitInfo.Valid`、`GalileanPhenomenonEvent.Valid`、`GalileanPhenomenonContactEvent.Valid`),不返回错误;`TransitInfo` 在 `HasInternal` 为假时 `InternalStart` / `InternalEnd` 为零值,偏凌日没有内切。
事件搜索在窗口内没有事件时返回零值或 `Valid=false` 的结构(`TransitInfo.Valid`、`GalileanPhenomenonEvent.Valid`、`GalileanPhenomenonContactEvent.Valid`),不返回错误;`TransitInfo` 在 `HasExternal` 为假时 `Start` / `End` 为零值,`HasInternal` 为假时 `InternalStart` / `InternalEnd` 为零值。
`planet.WherePlanet` / `planet.WherePlanetN` 的 `xt` / `zn` 越界返回 `NaN` 而不 panic;`...N` 的 `n < 0` 用完整级数,半解析的非负 `n` 用同一张快速表,Legacy 按 `n` 截断。`RiseTime` / `SetTime` / `DownTime`(含 `N` 版)当天没有几何升/落时返回下面的哨兵错误,时刻为零值。
+1
View File
@@ -18,6 +18,7 @@ func TestEclipseMapDataSourcesUseDocumentedVocabulary(t *testing.T) {
"central-path-limits", "penumbral-outlines", "central-shadow-outlines",
"p1-p4-visibility-regions", "p1-p4-horizon-boundaries",
"c1-c4-visibility-regions", "c1-c4-horizon-boundaries", "transit-contact-isochrones",
"topocentric-transit-visibility",
}
if len(documented) != len(eclipseMapDataSources) {
t.Fatalf("vocabulary has %d entries, want %d", len(eclipseMapDataSources), len(documented))
+3
View File
@@ -19,6 +19,7 @@
// c1-c4-visibility-regions 凌日可见性分区
// c1-c4-horizon-boundaries 凌日 C1、C4 视地平边界
// transit-contact-isochrones 凌日站心接触等时线
// topocentric-transit-visibility 地心无接触时按站心求得的部分凌日可见区
//
// Package svg renders solar-eclipse, lunar-eclipse and Mercury/Venus transit SVG charts. Degradable layers tag the geometry
// source they actually used in data-source, using exactly the values above.
@@ -43,6 +44,7 @@ const (
eclipseMapSourceTransitVisibilityRegions = "c1-c4-visibility-regions"
eclipseMapSourceTransitHorizonBoundaries = "c1-c4-horizon-boundaries"
eclipseMapSourceTransitContactIsochrones = "transit-contact-isochrones"
eclipseMapSourceTransitPartialVisibility = "topocentric-transit-visibility"
)
// eclipseMapDataSources 是全部合法取值,测试用它封闭词表。
@@ -64,4 +66,5 @@ var eclipseMapDataSources = []string{
eclipseMapSourceTransitVisibilityRegions,
eclipseMapSourceTransitHorizonBoundaries,
eclipseMapSourceTransitContactIsochrones,
eclipseMapSourceTransitPartialVisibility,
}
+44 -10
View File
@@ -175,13 +175,31 @@ func renderTransitLocalSVG(
fmt.Fprintf(&b, `<text x="%.3f" y="47" fill="#111111" font-family="Georgia, 'Times New Roman', serif" font-size="24" font-weight="700" text-anchor="middle">%s</text>`,
width/2, html.EscapeString(titleText))
for index, line := range []string{site, summary} {
line = svgchart.EllipsizeText(line, width-80, 13)
fmt.Fprintf(&b, `<text x="%.3f" y="%.3f" fill="#333" font-family="Georgia, 'Times New Roman', serif" font-size="13" text-anchor="middle">%s</text>`,
width/2, 72+float64(index)*19, html.EscapeString(line))
}
// 日面区占左侧,接触表占右侧。
// 日面区占左侧,接触表占右侧;日面半径要容下盘外的东南西北轴标签,所以上下界由副标题与页脚算出。
panelWidth := 300.0
top, bottom := 112.0, height-86
footerLines := transitLocalFooterLines(local, options, width)
footerBaselines := transitLocalFooterBaselines(len(footerLines), height)
footerTop := height - 30.0
if len(footerBaselines) > 0 {
footerTop = footerBaselines[0] - 10.2
}
const (
headerBottom = 72 + 19 + 10 // 经纬度行、摘要行与留白
axisLabelAbove = 33.0 // 盘顶到“北”字上沿
axisLabelBelow = 36.0 // 盘底到“南”字下沿
minimumDiskHeight = 150.0
)
top, bottom := headerBottom+axisLabelAbove, footerTop-axisLabelBelow
if bottom-top < minimumDiskHeight {
shrink := (minimumDiskHeight - (bottom - top)) / 2
top -= shrink
bottom += shrink
}
diskLeft, diskRight := 46.0, width-panelWidth-60
cx, cy := (diskLeft+diskRight)/2, (top+bottom)/2
extent := local.SunSemidiameterArcsec + 2.2*local.PlanetSemidiameterArcsec + 60
@@ -196,6 +214,9 @@ func renderTransitLocalSVG(
events := transitLocalEvents(local, options.Language)
planetRadius := math.Max(local.PlanetSemidiameterArcsec*scale, 2)
table := &svgchart.LabelTable{}
table.Reserve(30, footerTop-4, width-60, height-footerTop+4)
table.Reserve(cx-14, cy+sunRadius+16, 28, 18)
table.Reserve(cx-14, cy-sunRadius-32, 28, 20)
for _, event := range events {
x, y := transitLocalEventPosition(frames, event.contact.JD, mapX, mapY)
fill, stroke := "#1d1d1d", "#000000"
@@ -234,7 +255,7 @@ func renderTransitLocalSVG(
// 标签平移进白色图框内、接触表左侧,不能伸出画面。
box := svgchart.LabelTextBox(candidate.X, candidate.Y, 11, text, anchor)
candidate.X += math.Max(0, 30-box.X) - math.Max(0, box.X+box.Width-(width-panelWidth-44))
candidate.Y += math.Max(0, top-8-box.Y) - math.Max(0, box.Y+box.Height-(height-30))
candidate.Y += math.Max(0, top-8-box.Y) - math.Max(0, box.Y+box.Height-(footerTop-4))
candidates = append(candidates, candidate)
}
placement, placed = table.Place(text, 11, candidates)
@@ -254,8 +275,14 @@ func renderTransitLocalSVG(
placement.X, placement.Y, placement.Anchor, html.EscapeString(text))
}
writeTransitLocalContactTable(&b, local, events, at, width-panelWidth-36, top+10, panelWidth, options.Language)
writeTransitLocalFooter(&b, local, options, width, height)
tableWidth := panelWidth
tableX := width - panelWidth - 36
if minimum := cx + sunRadius + 40; tableX < minimum {
tableX = minimum
tableWidth = width - tableX - 36
}
writeTransitLocalContactTable(&b, local, events, at, tableX, top+10, tableWidth, options.Language)
writeTransitLocalFooter(&b, footerLines, footerBaselines)
b.WriteString(`</svg>`)
return b.String()
}
@@ -301,7 +328,7 @@ func transitLocalSummaryText(local basic.PlanetTransitLocalResult, language stri
// writeTransitLocalAxes 画过日心的南北、东西虚线与方向字。
func writeTransitLocalAxes(b *strings.Builder, cx, cy, radius float64, language string) {
reach := radius + 18
reach := radius + 12
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx, cy-reach, cx, cy+reach)
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="#8b6a2b" stroke-width="0.8" stroke-dasharray="3 4" stroke-opacity="0.7"/>`, cx-reach, cy, cx+reach, cy)
labels := [4]string{"北", "南", "东", "西"}
@@ -419,18 +446,25 @@ func writeTransitLocalContactTable(
}
}
func writeTransitLocalFooter(b *strings.Builder, local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width, height float64) {
func transitLocalFooterLines(local basic.PlanetTransitLocalResult, options TransitLocalSVGOptions, width float64) []string {
note := options.FooterNote
if note == "" {
if options.Language == "en" {
note = "Sky view, north up and east left. Topocentric contacts; Sun altitudes are geometric centre altitudes, grey parts have the Sun's centre below the apparent horizon (standard refraction)."
} else {
note = "天空视向,上北左东;站心接触时刻,太阳高度为日心几何高度,灰色部分太阳中心在视地平下(计入标准大气折射)。"
note = "天空视向,上北左东;站心接触时刻,太阳高度为太阳中心几何高度,灰色部分太阳中心在视地平下(计入标准大气折射)。"
}
}
scaleNote := timenote.Scale(options.TimeScale, basic.JD2DateByZone(local.Greatest.JD, time.UTC, false), options.Location, options.Language)
lines := svgchart.FooterLinesWithScale(svgchart.WrapText(note, width-80, 12), scaleNote, width-80, 12, 3)
baselines, _ := svgchart.FooterBlock(len(lines), height, 12, svgchart.FooterLineHeight(12), svgchart.FooterBottomPadding(12)+18)
return svgchart.FooterLinesWithScale(svgchart.WrapText(note, width-80, 12), scaleNote, width-80, 12, 3)
}
func transitLocalFooterBaselines(lineCount int, height float64) []float64 {
baselines, _ := svgchart.FooterBlock(lineCount, height, 12, svgchart.FooterLineHeight(12), svgchart.FooterBottomPadding(12)+18)
return baselines
}
func writeTransitLocalFooter(b *strings.Builder, lines []string, baselines []float64) {
for index, line := range lines {
fmt.Fprintf(b, `<text x="40" y="%.3f" fill="#555" font-family="Georgia, 'Times New Roman', serif" font-size="12">%s</text>`,
baselines[index], html.EscapeString(line))
+119 -50
View File
@@ -36,9 +36,9 @@ type TransitIsochrones uint8
const (
// TransitIsochroneC1 画一触等时线 / draw C1 isochrones.
TransitIsochroneC1 TransitIsochrones = 1 << iota
// TransitIsochroneC2 画二触等时线;掠凌没有内切接触时忽略 / draw C2 isochrones, ignored for a grazing transit.
// TransitIsochroneC2 画二触等时线;地表没有站心内切时忽略 / draw C2 isochrones, ignored where no site has internal contact.
TransitIsochroneC2
// TransitIsochroneC3 画三触等时线;掠凌没有内切接触时忽略 / draw C3 isochrones, ignored for a grazing transit.
// TransitIsochroneC3 画三触等时线;地表没有站心内切时忽略 / draw C3 isochrones, ignored where no site has internal contact.
TransitIsochroneC3
// TransitIsochroneC4 画四触等时线 / draw C4 isochrones.
TransitIsochroneC4
@@ -64,8 +64,7 @@ type TransitMapSVGOptions struct {
// TimeScale 选择图中时刻的时标:零值 UTC;TimeScaleUT1 要求 Location 为 nil 或 UTC,否则返回 false。
// TimeScale selects the label scale: the zero value is UTC; TimeScaleUT1 requires Location to be nil or UTC, otherwise the renderer returns false.
TimeScale astro.TimeScale
// Projection 选择地图投影;零值使用等经纬投影,正射投影视点取凌甚时的太阳直射点。
// Projection selects the map projection. The zero value uses equirectangular; orthographic centres on the subsolar point at greatest.
// Projection 选择地图投影,默认等经纬;正射视点在地心无接触时取站心可见区中心,否则取凌甚太阳直射点 / projection, default equirectangular; orthographic centres on the visible footprint without geocentric contacts, otherwise the subsolar point at greatest.
Projection EclipseMapProjection
// Isochrones 选择要画的站心接触等时线,可按位组合;零值不画(默认关闭,需显式开启)。
// Isochrones selects the topocentric contact isochrones to draw and may combine flags. The zero value draws none (off by default).
@@ -79,12 +78,14 @@ type TransitMapSVGOptions struct {
FooterNote string
}
// TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:按一触、凌甚、四触太阳中心在视地平上
// (计入标准大气折射)的区域分区;Options.Isochrones 显式开启时叠加所选接触的站心等时线。找不到凌日或选项无效时返回 false。
// TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: regions follow where the
// Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4. Topocentric isochrones of the
// selected contacts are overlaid only when Options.Isochrones enables them. It returns false when no transit is found
// or the options are invalid.
// TransitMapSVG 绘制距 date 最近一次水星或金星凌日的全球可见图:普通凌日按一触、凌甚、四触太阳中心在视地平上
// (计入标准大气折射)的区域分区,地心无接触时绘制站心部分凌日可见区;Options.Isochrones 显式开启时叠加所选接触的
// 站心等时线,图例只列实际画出的接触。找不到凌日或选项无效时返回 false。
// TransitMapSVG renders the global visibility of the Mercury or Venus transit closest to date: ordinary transits are
// partitioned by whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4;
// events without geocentric contacts show the topocentric partial-transit footprint. Topocentric isochrones of the
// selected contacts are overlaid only when Options.Isochrones enables them, and the legend lists only contacts that
// produced lines. It returns false when no transit is found or the options are invalid.
func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOptions) (string, bool) {
transit, ok := closestPlanetTransit(planet, date)
if !ok || !validEclipseMapProjection(options.Projection) {
@@ -97,7 +98,7 @@ func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOp
options.Location = time.UTC
}
options = normalizeTransitMapSVGOptions(date, options)
if !transit.HasInternal {
if !transit.HasExternal {
options.Isochrones &^= TransitIsochroneC2 | TransitIsochroneC3
}
options.Isochrones &= TransitIsochroneAll
@@ -114,7 +115,7 @@ func TransitMapSVG(planet TransitPlanet, date time.Time, options TransitMapSVGOp
return renderTransitMapSVG(planet, transit, options, projection), true
}
// closestPlanetTransit 返回距 date 最近的一次地心凌日;行星无效或找不到时第二个返回值为 false。
// closestPlanetTransit 返回距 date 最近的一次全球凌日。
func closestPlanetTransit(planet TransitPlanet, date time.Time) (basic.PlanetTransitResult, bool) {
jd := basic.Date2JD(date.UTC())
var transit basic.PlanetTransitResult
@@ -155,7 +156,12 @@ type transitMapContacts struct {
}
func transitMapContactsOf(transit basic.PlanetTransitResult) transitMapContacts {
at := func(jd float64) time.Time { return basic.JD2DateByZone(jd, time.UTC, false) }
at := func(jd float64) time.Time {
if jd == 0 {
return time.Time{}
}
return basic.JD2DateByZone(jd, time.UTC, false)
}
contacts := transitMapContacts{
start: at(transit.ExternalIngress),
greatest: at(transit.Greatest),
@@ -178,7 +184,7 @@ func renderTransitMapSVG(
// 底部比月食图多留两行:图例下要放可折行的说明与时标声明。
frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 122)
if projection == svgmap.ProjectionOrthographic {
center := solarEclipseSubsolarPoint(contacts.greatest)
center := transitMapOrthographicCenter(transit)
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
}
title := options.Title
@@ -192,8 +198,12 @@ func renderTransitMapSVG(
}
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">`,
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s"`,
options.Width, options.Height, options.Width, options.Height, html.EscapeString(title))
if projection == svgmap.ProjectionOrthographic {
fmt.Fprintf(&b, ` data-center-longitude="%.6f" data-center-latitude="%.6f"`, frame.CenterLongitude, frame.CenterLatitude)
}
b.WriteString(`>`)
b.WriteString(`<defs>`)
writeTransitMapDefinitions(&b, frame, contacts)
b.WriteString(`</defs>`)
@@ -211,13 +221,15 @@ func renderTransitMapSVG(
frame.WriteOcean(&b)
frame.WriteGraticule(&b, "transit-map-clip")
frame.WriteLand(&b, "transit-map-clip")
writeTransitMapRegions(&b, frame, contacts)
if options.Isochrones != 0 {
writeTransitMapIsochrones(&b, frame, transit, options)
if transit.HasExternal {
writeTransitMapRegions(&b, frame, contacts)
} else {
writeTransitMapPartialRegions(&b, frame, transit)
}
options.Isochrones = writeTransitMapIsochrones(&b, frame, transit, options)
frame.WriteFrame(&b)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows)
legendRows := writeTransitMapLegend(&b, frame, float64(options.Width), options, transit.HasExternal)
writeTransitMapFooter(&b, frame, options, projection, contacts.greatest, legendRows, transit.HasExternal)
b.WriteString(`</svg>`)
return b.String()
}
@@ -264,6 +276,9 @@ const (
// writeTransitMapDefinitions 输出一触、凌甚、四触三个太阳在地平上半球的形状与排除掩膜,分区口径同月食可见图。
func writeTransitMapDefinitions(b *strings.Builder, frame svgmap.Frame, contacts transitMapContacts) {
b.WriteString(frame.ClipDefinition("transit-map-clip"))
if contacts.start.IsZero() || contacts.end.IsZero() {
return
}
startPath, _ := transitMapSunUpPath(contacts.start, frame)
endPath, _ := transitMapSunUpPath(contacts.end, frame)
greatestPath, _ := transitMapSunUpPath(contacts.greatest, frame)
@@ -285,10 +300,19 @@ const (
transitMapColorSunrise = "#4e9da0"
transitMapColorSunset = "#e2aa4b"
transitMapColorNone = "#747b7d"
transitMapColorPartial = "#f7dd93"
transitMapColorC1 = "#a3392f"
transitMapColorC2 = "#c0702a"
transitMapColorC3 = "#7a4ca0"
transitMapColorC4 = "#2f5ea3"
transitMapFooterSideInset = 120.0
transitMapRegionFillOpacity = 0.34
// 部分凌日只有一块可见区,要比普通凌日分区更浓才能从底图上辨认。
transitMapPartialFillOpacity = 0.55
// 图例色块直接画在白底上,比叠在海陆底色上的分区取更浓的值,同月食图。
transitMapLegendFillOpacity = 0.78
)
// writeTransitMapRegions 着色四类可见区:全程可见、日出时凌日已开始、日落时凌日未结束、不可见。
@@ -298,25 +322,27 @@ func writeTransitMapRegions(b *strings.Builder, frame svgmap.Frame, contacts tra
_, endBoundary := transitMapSunUpPath(contacts.end, frame)
fmt.Fprintf(b, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitVisibilityRegions)
if frame.IsPolar() {
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone)
fmt.Fprintf(b, `<circle class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" cx="%.3f" cy="%.3f" r="%.3f" fill="%s" fill-opacity="%.2f"/>`,
frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2, transitMapColorNone, transitMapRegionFillOpacity)
} else {
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="0.34"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone)
fmt.Fprintf(b, `<rect class="transit-not-visible-region" mask="url(#transit-not-visible-mask)" x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s" fill-opacity="%.2f"/>`,
frame.X, frame.Y, frame.Width, frame.Height, transitMapColorNone, transitMapRegionFillOpacity)
}
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="0.34"/>`, transitMapColorSunrise)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorEntire)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="0.34"/></g></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="0.34"/></g>`, transitMapColorSunset)
fmt.Fprintf(b, `<use class="transit-sunset-region" mask="url(#transit-not-c4-mask)" href="#transit-sun-c1-shape" fill="%s" fill-opacity="%.2f"/>`, transitMapColorSunset, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<use class="transit-sunrise-region" mask="url(#transit-not-c1-mask)" href="#transit-sun-c4-shape" fill="%s" fill-opacity="%.2f"/>`, transitMapColorSunrise, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g clip-path="url(#transit-sun-greatest)"><use class="transit-entire-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="%.2f"/></g></g>`, transitMapColorEntire, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<g clip-path="url(#transit-sun-c4)"><g mask="url(#transit-not-greatest-mask)"><use class="transit-sunset-region" href="#transit-sun-c1-shape" fill="%s" fill-opacity="%.2f"/></g></g>`, transitMapColorSunset, transitMapRegionFillOpacity)
fmt.Fprintf(b, `<g mask="url(#transit-not-c1-c4-mask)"><use class="transit-sunset-region" href="#transit-sun-greatest-shape" fill="%s" fill-opacity="%.2f"/></g>`, transitMapColorSunset, transitMapRegionFillOpacity)
b.WriteString(`</g>`)
writeEclipseMapGeoLine(b, frame, startBoundary, "c1-horizon", "#a56c16", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
writeEclipseMapGeoLine(b, frame, endBoundary, "c4-horizon", "#197a82", 1.2, "4 3", "transit-map-clip", eclipseMapSourceTransitHorizonBoundaries)
}
// writeTransitMapIsochrones 画选中接触的站心等时线:每条线是同一时刻看到该接触的地点,只保留该时刻太阳在视地平上的部分。
// 返回实际画出线的接触,供图例使用。
// 每隔若干条标一次时刻,标签按 LabelTable 避让。
func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) {
func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult, options TransitMapSVGOptions) TransitIsochrones {
var drawn TransitIsochrones
table := &svgchart.LabelTable{}
format := "15:04"
if options.IsochroneStep%time.Minute != 0 {
@@ -337,6 +363,9 @@ func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit b
continue
}
lines := basic.PlanetTransitContactIsochrones(transit, contact.kind, options.IsochroneStep)
if len(lines) > 0 {
drawn |= contact.flag
}
labelEvery := transitMapIsochroneLabelEvery(len(lines))
for index, line := range lines {
segments := make([][]svgmap.GeoPoint, 0, len(line.Segments))
@@ -360,6 +389,7 @@ func writeTransitMapIsochrones(b *strings.Builder, frame svgmap.Frame, transit b
contact.class, placement.X, placement.Y, contact.color, placement.Anchor, html.EscapeString(text))
}
}
return drawn
}
// transitMapIsochroneLabelEvery 让每个接触最多标八条线。
@@ -413,7 +443,10 @@ func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResul
if options.Language == "en" {
greatest, separation = "Greatest", "least separation"
}
parts := []string{"C1 " + clock(contacts.start)}
parts := []string{}
if !contacts.start.IsZero() {
parts = append(parts, "C1 "+clock(contacts.start))
}
if contacts.hasInternal {
parts = append(parts, "C2 "+clock(contacts.internalStart))
}
@@ -421,7 +454,13 @@ func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResul
if contacts.hasInternal {
parts = append(parts, "C3 "+clock(contacts.internalEnd))
}
parts = append(parts, "C4 "+clock(contacts.end))
if !contacts.end.IsZero() {
parts = append(parts, "C4 "+clock(contacts.end))
} else if options.Language == "en" {
parts = append(parts, "Partial transit; no geocentric contacts")
} else {
parts = append(parts, "仅部分凌日,地心无接触")
}
line := fmt.Sprintf("%s (%s)", strings.Join(parts, " | "), zone)
fmt.Fprintf(b, `<text x="%.3f" y="82" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(line, float64(options.Width)-60, 13)))
@@ -432,23 +471,33 @@ func writeTransitMapSummary(b *strings.Builder, transit basic.PlanetTransitResul
transit.MinimumSeparationArcsec, transit.SunSemidiameterArcsec, transit.PlanetSemidiameterArcsec)
}
fmt.Fprintf(b, `<text x="%.3f" y="100" fill="#293235" font-family="Arial, sans-serif" font-size="13" text-anchor="middle">%s</text>`,
float64(options.Width)/2, html.EscapeString(note))
float64(options.Width)/2, html.EscapeString(svgchart.EllipsizeText(transitMapGeometryNote(transit, options.Language)+" | "+note, float64(options.Width)-60, 13)))
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions) int {
type entry struct {
text, color string
line bool
}
type transitMapLegendEntry struct {
text, color string
line bool
}
// transitMapLegendEntries 列出图例项:地心无接触时是部分凌日可见区,否则是四个分区;两者都追加实际画出的等时线。
func transitMapLegendEntries(options TransitMapSVGOptions, hasExternal bool) []transitMapLegendEntry {
labels := []string{"全程可见", "日出时已在凌日", "日落时仍在凌日", "不可见", "一触等时线", "二触等时线", "三触等时线", "四触等时线"}
if options.Language == "en" {
labels = []string{"Entire transit", "Transit at sunrise", "Transit at sunset", "Not visible",
"C1 isochrones", "C2 isochrones", "C3 isochrones", "C4 isochrones"}
}
entries := []entry{
{labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false},
{labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false},
var entries []transitMapLegendEntry
if !hasExternal {
partial := "部分凌日可见区"
if options.Language == "en" {
partial = "Partial transit visible"
}
entries = []transitMapLegendEntry{{partial, transitMapColorPartial, false}}
} else {
entries = []transitMapLegendEntry{
{labels[0], transitMapColorEntire, false}, {labels[1], transitMapColorSunrise, false},
{labels[2], transitMapColorSunset, false}, {labels[3], transitMapColorNone, false},
}
}
for index, item := range []struct {
flag TransitIsochrones
@@ -458,14 +507,20 @@ func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth f
{TransitIsochroneC3, transitMapColorC3}, {TransitIsochroneC4, transitMapColorC4},
} {
if options.Isochrones&item.flag != 0 {
entries = append(entries, entry{labels[4+index], item.color, true})
entries = append(entries, transitMapLegendEntry{labels[4+index], item.color, true})
}
}
width := func(item entry) float64 {
return entries
}
// writeTransitMapLegend 画区域色块与等时线图例,按画布宽度折行,返回行数。
func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth float64, options TransitMapSVGOptions, hasExternal bool) int {
entries := transitMapLegendEntries(options, hasExternal)
var rows [][]transitMapLegendEntry
width := func(item transitMapLegendEntry) float64 {
return lunarEclipseDetailedMapLegendIconWidth + svgchart.EstimatedTextWidth(item.text, lunarEclipseDetailedMapLegendFontSize)
}
available := canvasWidth - 2*lunarEclipseDetailedPageInset
var rows [][]entry
used := 0.0
for _, item := range entries {
if len(rows) == 0 || used+lunarEclipseDetailedMapLegendGap+width(item) > available {
@@ -487,7 +542,7 @@ func writeTransitMapLegend(b *strings.Builder, frame svgmap.Frame, canvasWidth f
if item.line {
fmt.Fprintf(b, `<line x1="%.3f" y1="%.3f" x2="%.3f" y2="%.3f" stroke="%s" stroke-width="1.4"/>`, x, y-4, x+18, y-4, item.color)
} else {
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="0.78"/>`, x, y-8, item.color)
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="18" height="9" fill="%s" fill-opacity="%.2f"/>`, x, y-8, item.color, transitMapLegendFillOpacity)
}
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#465053" font-family="Arial, sans-serif" font-size="%.0f">%s</text>`,
x+lunarEclipseDetailedMapLegendIconWidth, y, lunarEclipseDetailedMapLegendFontSize, html.EscapeString(item.text))
@@ -504,17 +559,25 @@ func writeTransitMapFooter(
projection svgmap.Projection,
instant time.Time,
legendRows int,
hasExternal bool,
) {
text := options.FooterNote
if text == "" {
projectionLabel := transitMapProjectionLabel(projection, hasExternal, options.Language)
if options.Language == "en" {
text = eclipseMapProjectionLabel(projection, "en") + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4"
text = projectionLabel + "; regions follow whether the Sun's centre is above the apparent horizon (standard refraction) at C1, greatest and C4"
if !hasExternal {
text = projectionLabel + "; shaded footprint uses topocentric disk overlap and apparent-horizon visibility (standard refraction)"
}
if options.Isochrones != 0 {
text += "; isochrones are topocentric contact times"
}
text += "."
} else {
text = eclipseMapProjectionLabel(projection, "zh") + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
text = projectionLabel + ";按一触、凌甚、四触太阳中心是否在视地平上分区(计入标准大气折射)"
if !hasExternal {
text = projectionLabel + ";着色区域按站心圆面相交与视地平可见性计算(计入标准大气折射)"
}
if options.Isochrones != 0 {
text += ";等时线为站心接触时刻"
}
@@ -525,12 +588,18 @@ func writeTransitMapFooter(
// 页脚紧跟图例末行,不与图例重叠;行数按画布底边截断,时标声明独占最后一行。
baseline := frame.Y + frame.Height + 31 + float64(legendRows-1)*lunarEclipseDetailedMapLegendLineStep + 18
x, anchor := frame.X, "start"
maxWidth := float64(options.Width) - frame.X - 24
// 球面投影的盘面居中而左缘靠里,页脚改为随图例居中,否则文字整体右偏。
if frame.IsDisk() {
x, anchor = float64(options.Width)/2, "middle"
maxWidth = float64(options.Width) - 2*transitMapFooterSideInset
}
lines := svgchart.WrapText(text, maxWidth, 11)
lines = svgchart.FooterLinesWithScale(lines, timenote.Scale(options.TimeScale, instant, options.Location, options.Language), maxWidth, 11,
svgchart.BaselineLineLimit(11, svgchart.FooterLineHeight(11), baseline, float64(options.Height)-footerBottomPadding))
for index, line := range lines {
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11">%s</text>`,
frame.X, baseline+float64(index)*svgchart.FooterLineHeight(11), html.EscapeString(line))
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11" text-anchor="%s">%s</text>`,
x, baseline+float64(index)*svgchart.FooterLineHeight(11), anchor, html.EscapeString(line))
}
}
+158
View File
@@ -0,0 +1,158 @@
package svg
import (
"fmt"
"math"
"regexp"
"strings"
"testing"
"time"
"b612.me/astro/basic"
)
func TestTransitMapVisibilityCenter(tester *testing.T) {
for _, test := range []struct {
name string
columns []basic.PlanetTransitVisibilityColumn
longitude float64
latitude float64
valid bool
}{
{name: "empty"},
{name: "no-visible-interval", columns: []basic.PlanetTransitVisibilityColumn{{Longitude: -180}, {Longitude: 180}}},
{name: "antimeridian", columns: []basic.PlanetTransitVisibilityColumn{
{Longitude: -180, Intervals: [][2]float64{{-10, 10}}},
{Longitude: -178, Intervals: [][2]float64{{-10, 10}}},
{Longitude: -176}, {Longitude: 176},
{Longitude: 178, Intervals: [][2]float64{{-10, 10}}},
{Longitude: 180, Intervals: [][2]float64{{-10, 10}}},
}, longitude: 180, valid: true},
{name: "north", columns: []basic.PlanetTransitVisibilityColumn{
{Longitude: -180, Intervals: [][2]float64{{70, 90}}},
{Longitude: -90, Intervals: [][2]float64{{70, 90}}},
{Longitude: 0, Intervals: [][2]float64{{70, 90}}},
{Longitude: 90, Intervals: [][2]float64{{70, 90}}},
{Longitude: 180, Intervals: [][2]float64{{70, 90}}},
}, latitude: 90, valid: true},
{name: "south", columns: []basic.PlanetTransitVisibilityColumn{
{Longitude: -180, Intervals: [][2]float64{{-90, -70}}},
{Longitude: -90, Intervals: [][2]float64{{-90, -70}}},
{Longitude: 0, Intervals: [][2]float64{{-90, -70}}},
{Longitude: 90, Intervals: [][2]float64{{-90, -70}}},
{Longitude: 180, Intervals: [][2]float64{{-90, -70}}},
}, latitude: -90, valid: true},
} {
tester.Run(test.name, func(tester *testing.T) {
center, valid := transitMapVisibilityCenter(test.columns)
if valid != test.valid {
tester.Fatalf("valid=%v, want %v", valid, test.valid)
}
if valid && (math.Abs(center.Latitude-test.latitude) > 1e-8 ||
math.Abs(test.latitude) < 90 && math.Abs(math.Remainder(center.Longitude-test.longitude, 360)) > 1e-8) {
tester.Fatalf("center=%+v, want longitude=%v latitude=%v", center, test.longitude, test.latitude)
}
})
}
}
func TestTransitMapOrthographicPartialCenter(tester *testing.T) {
for _, test := range []struct {
planet TransitPlanet
year int
month time.Month
day int
longitude float64
latitude float64
}{
{TransitVenus, 2854, time.December, 14, -135, -60},
{TransitMercury, 1937, time.May, 11, 0, -60},
} {
tester.Run(fmt.Sprintf("%d-%d", test.planet, test.year), func(tester *testing.T) {
date := time.Date(test.year, test.month, test.day, 0, 0, 0, 0, time.UTC)
transit, valid := closestPlanetTransit(test.planet, date)
if !valid || transit.HasExternal {
tester.Fatal("expected a transit without geocentric contacts")
}
center := transitMapOrthographicCenter(transit)
local := basic.PlanetTransitLocal(transit, basic.Observer{Longitude: center.Longitude, Latitude: center.Latitude})
if !local.Valid || !local.Visible {
tester.Fatalf("view centre must lie in the visible footprint: %+v", center)
}
for _, projection := range []EclipseMapProjection{EclipseMapProjectionEquirectangular, EclipseMapProjectionSouthPolar, EclipseMapProjectionOrthographic} {
chart, valid := TransitMapSVG(test.planet, date, TransitMapSVGOptions{Projection: projection, Language: "en"})
if !valid || strings.Contains(chart, "NaN") || strings.Contains(chart, "Inf") {
tester.Fatalf("%s: invalid chart", projection)
}
paths := regexp.MustCompile(`<path class="transit-partial-region" d="([^"]+)"[^>]+>`).FindAllStringSubmatch(chart, -1)
if len(paths) != 1 || !strings.Contains(paths[0][0], `fill-rule="nonzero"`) {
tester.Fatalf("%s: footprint must use a single nonzero fill, got %d paths", projection, len(paths))
}
frame := eclipseMapFrame(960, 640, internalEclipseMapProjection(projection), 142, 122)
if projection == EclipseMapProjectionOrthographic {
frame.CenterLongitude, frame.CenterLatitude = center.Longitude, center.Latitude
if !strings.Contains(chart, fmt.Sprintf(`data-center-longitude="%.6f" data-center-latitude="%.6f"`, center.Longitude, center.Latitude)) ||
!strings.Contains(chart, "centred on the topocentric visible footprint") {
tester.Fatal("orthographic view centre or explanation missing")
}
}
x, y, projectable := frame.Project(test.longitude, test.latitude)
if !projectable || !lunarPenumbralTestInside(lunarPenumbralTestRings(tester, paths[0][1]), x, y) {
tester.Fatalf("%s: known visible site is outside the painted footprint", projection)
}
}
})
}
}
func TestTransitMapOrthographicCenterFallback(tester *testing.T) {
for _, test := range []struct {
planet TransitPlanet
date time.Time
}{
{TransitMercury, time.Date(2019, 11, 11, 0, 0, 0, 0, time.UTC)},
{TransitVenus, time.Date(2012, 6, 6, 0, 0, 0, 0, time.UTC)},
} {
transit, valid := closestPlanetTransit(test.planet, test.date)
if !valid || !transit.HasExternal {
tester.Fatal("expected an ordinary geocentric transit")
}
want := solarEclipseSubsolarPoint(transitMapContactsOf(transit).greatest)
if got := transitMapOrthographicCenter(transit); got != want {
tester.Fatalf("ordinary transit view changed: got %+v want %+v", got, want)
}
transit.Valid, transit.HasExternal = false, false
if got := transitMapOrthographicCenter(transit); got != want {
tester.Fatalf("empty visibility must fall back to the subsolar point: %+v", got)
}
}
}
func TestTransitMapFooterAlignment(tester *testing.T) {
footer := regexp.MustCompile(`<text x="([0-9.]+)" y="[0-9.]+" fill="#596164" font-family="Georgia, 'Times New Roman', serif" font-size="11" text-anchor="(\w+)">`)
date := time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC)
for _, test := range []struct {
projection EclipseMapProjection
x, anchor string
}{
{EclipseMapProjectionOrthographic, "480.000", "middle"},
{"", "104.000", "start"},
} {
chart, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Projection: test.projection, Language: "en"})
if !ok {
tester.Fatalf("%q: render failed", test.projection)
}
lines := footer.FindAllStringSubmatch(chart, -1)
if len(lines) < 2 {
tester.Fatalf("%q: footer lines=%d", test.projection, len(lines))
}
for _, line := range lines {
if line[1] != test.x || line[2] != test.anchor {
tester.Fatalf("%q: footer at x=%s anchor=%s, want %s %s", test.projection, line[1], line[2], test.x, test.anchor)
}
}
if !strings.Contains(chart, `class="transit-partial-region"`) || strings.Contains(chart, "#747b7d") || !strings.Contains(chart, `fill="#f7dd93"`) {
tester.Fatalf("%q: partial region fill changed", test.projection)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package svg
import (
"fmt"
"math"
"strings"
"b612.me/astro/basic"
"b612.me/astro/internal/svgmap"
)
func transitMapOrthographicCenter(transit basic.PlanetTransitResult) svgmap.GeoPoint {
if !transit.HasExternal {
if center, ok := transitMapVisibilityCenter(basic.PlanetTransitVisibilityColumns(transit)); ok {
return center
}
}
return solarEclipseSubsolarPoint(transitMapContactsOf(transit).greatest)
}
func transitMapVisibilityCenter(columns []basic.PlanetTransitVisibilityColumn) (svgmap.GeoPoint, bool) {
var momentX, momentY, momentZ float64
for index, column := range columns {
weight := 0.0
if index > 0 {
weight += (column.Longitude - columns[index-1].Longitude) / 2
}
if index+1 < len(columns) {
weight += (columns[index+1].Longitude - column.Longitude) / 2
}
longitude := column.Longitude * math.Pi / 180
for _, interval := range column.Intervals {
lower, upper := interval[0]*math.Pi/180, interval[1]*math.Pi/180
horizontalMoment := (upper-lower)/2 + (math.Sin(2*upper)-math.Sin(2*lower))/4
verticalMoment := (math.Sin(upper)*math.Sin(upper) - math.Sin(lower)*math.Sin(lower)) / 2
momentX += weight * horizontalMoment * math.Cos(longitude)
momentY += weight * horizontalMoment * math.Sin(longitude)
momentZ += weight * verticalMoment
}
}
if math.Sqrt(momentX*momentX+momentY*momentY+momentZ*momentZ) < 1e-12 {
return svgmap.GeoPoint{}, false
}
return svgmap.GeoPoint{
Longitude: math.Atan2(momentY, momentX) * 180 / math.Pi,
Latitude: math.Atan2(momentZ, math.Hypot(momentX, momentY)) * 180 / math.Pi,
}, true
}
func transitMapProjectionLabel(projection svgmap.Projection, hasExternal bool, language string) string {
if projection != svgmap.ProjectionOrthographic {
return eclipseMapProjectionLabel(projection, language)
}
if language == "en" {
center := "the subsolar point at greatest"
if !hasExternal {
center = "the topocentric visible footprint"
}
return "Orthographic globe projection, centred on " + center + "; one hemisphere only"
}
center := "凌甚太阳直射点"
if !hasExternal {
center = "站心可见区中心"
}
return "正射球面投影,视点取" + center + ",只画朝向视点的半个地球"
}
func writeTransitMapPartialRegions(builder *strings.Builder, frame svgmap.Frame, transit basic.PlanetTransitResult) {
fmt.Fprintf(builder, `<g class="transit-visibility-regions" data-source="%s" clip-path="url(#transit-map-clip)">`, eclipseMapSourceTransitPartialVisibility)
var path strings.Builder
for _, polygon := range basic.PlanetTransitVisibilityPolygons(transit) {
points := make([]svgmap.GeoPoint, len(polygon))
for index, point := range polygon {
points[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
path.WriteString(lunarEclipseGeoPolygonPath(points, frame))
}
if path.Len() > 0 {
fmt.Fprintf(builder, `<path class="transit-partial-region" d="%s" fill="%s" fill-opacity="%.2f" fill-rule="nonzero"/>`, path.String(), transitMapColorPartial, transitMapPartialFillOpacity)
}
builder.WriteString(`</g>`)
}
func transitMapGeometryNote(transit basic.PlanetTransitResult, language string) string {
text := fmt.Sprintf("γ %.4f", transit.Gamma)
if transit.Series.Number != 0 {
if language == "en" {
text += fmt.Sprintf(" | Series %d (%d yr)", transit.Series.Number, transit.Series.PeriodYears)
} else {
text += fmt.Sprintf(" | 周期族 %d(%d 年)", transit.Series.Number, transit.Series.PeriodYears)
}
}
return text
}
+106
View File
@@ -0,0 +1,106 @@
package svg
import (
"regexp"
"strconv"
"strings"
"testing"
"time"
)
func TestTransitSVGGlobalPartial(tester *testing.T) {
date := time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC)
chart, ok := TransitMapSVG(TransitVenus, date, TransitMapSVGOptions{Isochrones: TransitIsochroneAll, IsochroneStep: 30 * time.Second})
if !ok {
tester.Fatal("global partial transit render failed")
}
for _, expected := range []string{"2854-12-14", "transit-partial-region", "地心无接触", "周期族 4", "γ ", "c1-isochrone", "c4-isochrone"} {
if !strings.Contains(chart, expected) {
tester.Fatalf("missing %s", expected)
}
}
for _, absent := range []string{"NaN", "Inf", "transit-sun-c1-shape", "c2-isochrone", "c3-isochrone"} {
if strings.Contains(chart, absent) {
tester.Fatalf("invalid SVG contains %s", absent)
}
}
local, ok := TransitLocalSVG(TransitVenus, date, -135, -60, 0, TransitLocalSVGOptions{})
if !ok || !strings.Contains(local, "transit-planet-c1") || !strings.Contains(local, "transit-planet-c4") || strings.Contains(local, "transit-planet-c2") {
tester.Fatal("local partial-transit diagram has invalid contacts")
}
labels := regexp.MustCompile(`class="transit-event-label" x="[0-9.-]+" y="([0-9.-]+)"`).FindAllStringSubmatch(local, -1)
if len(labels) != 3 {
tester.Fatalf("grazing contact labels=%d, want 3", len(labels))
}
footer := regexp.MustCompile(`<text x="40" y="([0-9.-]+)" fill="#555"`).FindAllStringSubmatch(local, -1)
if len(footer) == 0 {
tester.Fatal("local partial-transit diagram has no footer")
}
firstFooterBaseline, _ := strconv.ParseFloat(footer[0][1], 64)
for _, label := range labels {
baseline, _ := strconv.ParseFloat(label[1], 64)
if baseline > firstFooterBaseline-8 {
tester.Fatal("contact label overlaps the footer")
}
}
if _, ok := TransitLocalSVG(TransitVenus, date, 0, 45, 0, TransitLocalSVGOptions{}); ok {
tester.Fatal("a northern site must miss the transit")
}
}
func TestTransitMapLegendListsDrawnIsochrones(tester *testing.T) {
for _, test := range []struct {
planet TransitPlanet
date time.Time
inner bool
}{
{TransitVenus, time.Date(554, 5, 22, 0, 0, 0, 0, time.UTC), true},
{TransitMercury, time.Date(-513, 10, 22, 0, 0, 0, 0, time.UTC), false},
} {
chart, ok := TransitMapSVG(test.planet, test.date, TransitMapSVGOptions{Isochrones: TransitIsochroneAll, Language: "en"})
if !ok {
tester.Fatalf("%v: render failed", test.date)
}
for _, marker := range []string{"c2-isochrone", "C2 isochrones", "C3 isochrones"} {
if strings.Contains(chart, marker) != test.inner {
tester.Fatalf("%v: %s present=%v, want %v", test.date, marker, !test.inner, test.inner)
}
}
}
}
// 地心无接触的部分凌日也要在图例里列出实际画出的一触、四触等时线;二触、三触不存在。
func TestTransitPartialLegendListsDrawnIsochrones(tester *testing.T) {
for _, language := range []string{"zh", "en"} {
chart, ok := TransitMapSVG(TransitVenus, time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC), TransitMapSVGOptions{
Isochrones: TransitIsochroneAll, Language: language,
})
if !ok {
tester.Fatalf("%s: render failed", language)
}
partial, c1, c4 := "部分凌日可见区", "一触等时线", "四触等时线"
if language == "en" {
partial, c1, c4 = "Partial transit visible", "C1 isochrones", "C4 isochrones"
}
for _, expected := range []string{"c1-isochrone", "c4-isochrone", partial, c1, c4} {
if !strings.Contains(chart, expected) {
tester.Fatalf("%s: partial chart is missing %q", language, expected)
}
}
for _, absent := range []string{"c2-isochrone", "c3-isochrone", "二触等时线", "三触等时线", "C2 isochrones", "C3 isochrones"} {
if strings.Contains(chart, absent) {
tester.Fatalf("%s: partial chart must not contain %q", language, absent)
}
}
}
// 默认不画等时线时图例只留可见区色块,不凭空加等时线条目。
chart, ok := TransitMapSVG(TransitVenus, time.Date(2854, 12, 14, 0, 0, 0, 0, time.UTC), TransitMapSVGOptions{Language: "en"})
if !ok {
tester.Fatal("default render failed")
}
for _, absent := range []string{"c1-isochrone", "C1 isochrones", "C4 isochrones"} {
if strings.Contains(chart, absent) {
tester.Fatalf("default chart must not contain %q", absent)
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package svg
import (
"fmt"
"regexp"
"strings"
"testing"
"time"
)
var transitLegendSwatchPattern = regexp.MustCompile(`<rect [^>]*width="18" height="9"[^>]*fill-opacity="([0-9.]+)"`)
type transitOverlapCase struct {
name string
render func(width, height int) (string, bool)
}
func transitOverlapCases() []transitOverlapCase {
partial := time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC)
ordinary := time.Date(2012, time.June, 6, 0, 0, 0, 0, time.UTC)
return []transitOverlapCase{
{"local-partial-zh", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitMercury, partial, 0, -60, 0, TransitLocalSVGOptions{Width: width, Height: height})
}},
{"local-partial-en", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitMercury, partial, 0, -60, 0, TransitLocalSVGOptions{Width: width, Height: height, Language: "en"})
}},
{"local-full-zh", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitVenus, ordinary, 116.4, 39.9, 0, TransitLocalSVGOptions{Width: width, Height: height})
}},
{"local-full-en", func(width, height int) (string, bool) {
return TransitLocalSVG(TransitVenus, ordinary, 116.4, 39.9, 0, TransitLocalSVGOptions{Width: width, Height: height, Language: "en"})
}},
{"map-partial", func(width, height int) (string, bool) {
return TransitMapSVG(TransitMercury, partial, TransitMapSVGOptions{Width: width, Height: height, Isochrones: TransitIsochroneAll})
}},
{"map-partial-orthographic", func(width, height int) (string, bool) {
return TransitMapSVG(TransitMercury, partial, TransitMapSVGOptions{
Width: width, Height: height, Isochrones: TransitIsochroneAll, Projection: EclipseMapProjectionOrthographic,
})
}},
{"map-ordinary", func(width, height int) (string, bool) {
return TransitMapSVG(TransitVenus, ordinary, TransitMapSVGOptions{Width: width, Height: height, Isochrones: TransitIsochroneAll})
}},
}
}
// 凌日图与日月食图同一口径:任意两个尺寸下返回的图都必须 0 文字压叠。
func TestTransitChartTextOverlapMatrix(t *testing.T) {
for _, size := range [][2]int{{640, 420}, {640, 480}, {800, 600}, {960, 640}, {960, 680}, {1200, 800}} {
for _, test := range transitOverlapCases() {
name := fmt.Sprintf("%s/%dx%d", test.name, size[0], size[1])
document, ok := test.render(size[0], size[1])
if !ok {
continue
}
assertNoTextOverlap(t, name, document)
}
}
// 文档下限尺寸必须继续出图,拒绝只允许出现在真放不下的画布上。
if _, ok := TransitLocalSVG(TransitMercury, time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC), 0, -60, 0,
TransitLocalSVGOptions{Width: 640, Height: 480}); !ok {
t.Error("documented 640x480 local transit canvas was rejected")
}
}
// 地心无接触的可见区填充必须落在 data-source 词表内,消费方才能判断该层来源。
func TestTransitPartialRegionsUseDocumentedDataSource(t *testing.T) {
chart, ok := TransitMapSVG(TransitMercury, time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC), TransitMapSVGOptions{})
if !ok {
t.Fatal("partial transit render failed")
}
if !strings.Contains(chart, `data-source="`+eclipseMapSourceTransitPartialVisibility+`"`) {
t.Fatalf("partial regions carry no documented data-source marker")
}
for _, match := range eclipseMapDataSourcePattern.FindAllStringSubmatch(chart, -1) {
known := false
for _, source := range eclipseMapDataSources {
known = known || source == match[1]
}
if !known {
t.Fatalf("undocumented data-source %q", match[1])
}
}
}
// 图例色块画在白底上取月食图同值;部分凌日单块可见区比普通分区更浓。
func TestTransitMapFillOpacities(t *testing.T) {
for _, test := range []struct {
planet TransitPlanet
date time.Time
pattern *regexp.Regexp
region float64
}{
{TransitVenus, time.Date(2012, time.June, 6, 0, 0, 0, 0, time.UTC),
regexp.MustCompile(`class="transit-[a-z-]+-region"[^>]*fill-opacity="([0-9.]+)"`), transitMapRegionFillOpacity},
{TransitMercury, time.Date(1937, time.May, 11, 0, 0, 0, 0, time.UTC),
regexp.MustCompile(`class="transit-partial-region"[^>]*fill-opacity="([0-9.]+)"`), transitMapPartialFillOpacity},
} {
chart, ok := TransitMapSVG(test.planet, test.date, TransitMapSVGOptions{})
if !ok {
t.Fatalf("%v: render failed", test.date)
}
regions := test.pattern.FindAllStringSubmatch(chart, -1)
if len(regions) == 0 {
t.Fatalf("%v: no region fill found", test.date)
}
for _, match := range regions {
if match[1] != fmt.Sprintf("%.2f", test.region) {
t.Fatalf("%v: region fill-opacity %s, want %.2f", test.date, match[1], test.region)
}
}
legends := transitLegendSwatchPattern.FindAllStringSubmatch(chart, -1)
if len(legends) == 0 {
t.Fatalf("%v: no legend swatch found", test.date)
}
for _, match := range legends {
if match[1] != fmt.Sprintf("%.2f", transitMapLegendFillOpacity) {
t.Fatalf("%v: legend fill-opacity %s, want %.2f", test.date, match[1], transitMapLegendFillOpacity)
}
}
}
if transitMapPartialFillOpacity <= transitMapRegionFillOpacity {
t.Fatal("partial-transit footprint must be denser than ordinary regions")
}
}
+51 -8
View File
@@ -28,16 +28,17 @@ type PlanetTransitOptions struct {
// 小于等于零时使用 360,其他值限制在 [12, 1440]。
// BoundaryPoints is the horizon sample count and also sets the region longitude columns to
// max(360, BoundaryPoints), capped at 720. Values <= 0 use 360; others are clamped to [12, 1440].
// 地心无接触事件使用固定站心网格,不由 BoundaryPoints 控制 / events without geocentric contacts use a fixed topocentric grid.
BoundaryPoints int
// IsochroneStep 为正时输出一触到四触的站心接触等时线(掠凌没有二触、三触),至少一秒;零值不输出。
// A positive IsochroneStep adds topocentric C1-C4 isochrones (no C2/C3 for a grazing transit) and must be
// IsochroneStep 为正时输出一触到四触的站心接触等时线(地表没有站心内切的事件没有二触、三触),至少一秒;零值不输出。
// A positive IsochroneStep adds topocentric C1-C4 isochrones (no C2/C3 where no site has internal contact) and must be
// at least one second. The zero value adds none.
IsochroneStep time.Duration
// TimeMarkers 非空时沿一触到四触的太阳直射点轨迹追加时间标记 Point 要素。
// TimeMarkers adds time-marker Point Features along the subsolar track from C1 through C4 when non-nil.
// TimeMarkers 非空时沿太阳直射点轨迹追加时间标记;无地心接触时覆盖求解窗口。
// TimeMarkers adds subsolar-track markers over C1-C4, or over the search window when geocentric contacts are absent.
TimeMarkers *TimeMarkerOptions
// SkipRoles 列出不写进输出的 role;分区三个 role 都跳过时不做分区计算。
// SkipRoles lists roles to leave out; skipping all three region roles skips the region computation.
// SkipRoles 列出不写进输出的 role;分区 role 全部跳过时不做分区计算。
// SkipRoles lists roles to leave out; skipping every region role skips the region computation.
SkipRoles []string
}
@@ -54,6 +55,12 @@ type planetTransitInput struct {
separation float64
sunRadius float64
planetRad float64
hasExternal bool
windowStart time.Time
windowEnd time.Time
gamma float64
penumbra float64
series basic.PlanetTransitSeries
}
// MarshalMercuryTransit 将水星凌日的全球可见分区、一触/四触地平线与可选站心等时线编码为 GeoJSON / encode a Mercury transit as GeoJSON.
@@ -66,6 +73,8 @@ func (module *Generator) MarshalMercuryTransit(info mercury.TransitInfo, options
start: info.Start, c2: info.InternalStart, greatest: info.Greatest, c3: info.InternalEnd, end: info.End,
hasInternal: info.HasInternal, separation: info.MinimumSeparationArcsec,
sunRadius: info.SunSemidiameterArcsec, planetRad: info.PlanetSemidiameterArcsec,
hasExternal: !info.Start.IsZero() && !info.End.IsZero(), windowStart: info.WindowStart, windowEnd: info.WindowEnd,
gamma: info.Gamma, penumbra: info.PenumbralRadius, series: info.Series,
}, options, module)
}
@@ -79,10 +88,32 @@ func (module *Generator) MarshalVenusTransit(info venus.TransitInfo, options Pla
start: info.Start, c2: info.InternalStart, greatest: info.Greatest, c3: info.InternalEnd, end: info.End,
hasInternal: info.HasInternal, separation: info.MinimumSeparationArcsec,
sunRadius: info.SunSemidiameterArcsec, planetRad: info.PlanetSemidiameterArcsec,
hasExternal: !info.Start.IsZero() && !info.End.IsZero(), windowStart: info.WindowStart, windowEnd: info.WindowEnd,
gamma: info.Gamma, penumbra: info.PenumbralRadius, series: info.Series,
}, options, module)
}
// planetTransitHasOnlyWindow 判断输入是否只给了站心求解窗口:地心无接触,接触时刻全为零。
func planetTransitHasOnlyWindow(input planetTransitInput) bool {
if input.hasExternal || input.hasInternal {
return false
}
if !input.start.IsZero() || !input.end.IsZero() || !input.c2.IsZero() || !input.c3.IsZero() {
return false
}
if input.greatest.IsZero() || input.windowStart.IsZero() || input.windowEnd.IsZero() {
return false
}
if !input.windowStart.Before(input.greatest) || !input.greatest.Before(input.windowEnd) {
return false
}
return input.windowEnd.Sub(input.windowStart) <= 24*time.Hour
}
func validatePlanetTransitInput(input planetTransitInput) error {
if planetTransitHasOnlyWindow(input) {
return nil
}
if input.start.IsZero() || input.greatest.IsZero() || input.end.IsZero() {
return fmt.Errorf("geojson: %s transit contact times are required", input.planet)
}
@@ -140,7 +171,9 @@ func marshalPlanetTransit(input planetTransitInput, options PlanetTransitOptions
longitudes[index] = -180 + 360*float64(index)/float64(longitudePoints)
}
refraction := basic.RefractionFromApparentAltitude(0, planetTransitRefractionPressureHPa, planetTransitRefractionTemperatureC)
base := map[string]interface{}{"planet": input.planet, "has_internal": input.hasInternal}
base := map[string]interface{}{"planet": input.planet, "has_internal": input.hasInternal,
"has_geocentric_contacts": input.hasExternal, "gamma": input.gamma, "penumbral_radius": input.penumbra,
"series": input.series.Number, "series_period_years": input.series.PeriodYears}
withBase := func(extra map[string]interface{}) map[string]interface{} {
properties := cloneProperties(base)
for key, value := range extra {
@@ -148,6 +181,9 @@ func marshalPlanetTransit(input planetTransitInput, options PlanetTransitOptions
}
return properties
}
if !input.hasExternal {
return marshalPlanetTransitPartial(input, options, timeScale, withBase, module)
}
features := make([]feature, 0, 12)
columnsAt := func(at time.Time) [][]lunarLatitudeInterval {
@@ -354,6 +390,13 @@ func planetTransitRegionFeatures(
return features, nil
}
func planetTransitResultOf(input planetTransitInput) basic.PlanetTransitResult {
if !input.hasExternal {
return basic.PlanetTransitResultFromWindow(input.planetIndex, input.windowStart, input.greatest, input.windowEnd)
}
return basic.PlanetTransitResultFromTimes(input.planetIndex, input.start, input.c2, input.greatest, input.c3, input.end)
}
// planetTransitIsochroneFeatures 把每个接触的站心等时线编码为一条带 times 的 MultiLineString:
// 每段是一条支路,times 与坐标对齐,同一支路上所有点同刻。
func planetTransitIsochroneFeatures(
@@ -364,7 +407,7 @@ func planetTransitIsochroneFeatures(
withBase func(map[string]interface{}) map[string]interface{},
module *Generator,
) ([]feature, error) {
result := basic.PlanetTransitResultFromTimes(input.planetIndex, input.start, input.c2, input.greatest, input.c3, input.end)
result := planetTransitResultOf(input)
features := make([]feature, 0, 4)
for _, contact := range []struct {
kind basic.PlanetTransitContactKind
+58
View File
@@ -0,0 +1,58 @@
package geojson
import (
"fmt"
"time"
"b612.me/astro/basic"
)
func marshalPlanetTransitPartial(input planetTransitInput, options PlanetTransitOptions, scale basic.TimeScale, withBase func(map[string]interface{}) map[string]interface{}, module *Generator) ([]byte, error) {
result := planetTransitResultOf(input)
label := func(value time.Time) string { return formatTime(basic.LabelIn(scale, value.UTC())) }
features := make([]feature, 0, 4)
if !skippedLunarRole(options.SkipRoles, "transit-partial") {
columns := basic.PlanetTransitVisibilityColumnsWithInstance(result, module.inst)
longitudes := make([]float64, len(columns))
intervals := make([][]lunarLatitudeInterval, len(columns))
for index, column := range columns {
longitudes[index] = column.Longitude
for _, interval := range column.Intervals {
intervals[index] = append(intervals[index], lunarLatitudeInterval{low: interval[0], high: interval[1]})
}
}
value, err := planetTransitRegionGeometry(longitudes, intervals)
if err != nil {
return nil, fmt.Errorf("geojson: partial transit: %w", err)
}
features = append(features, newFeature(planetTransitEvent, "transit-partial", value,
withBase(map[string]interface{}{"longitude_step_degrees": 2, "latitude_sample_degrees": 1})))
}
if options.IsochroneStep > 0 {
lines, err := planetTransitIsochroneFeatures(input, options.IsochroneStep, scale, options.SkipRoles, withBase, module)
if err != nil {
return nil, err
}
features = append(features, lines...)
}
point := solarSubsolarPoint(input.greatest, module)
var err error
features, err = appendPointFeature(features, planetTransitEvent, "greatest",
pathSample{Time: basic.LabelIn(scale, input.greatest.UTC()), Longitude: point.Longitude, Latitude: point.Latitude},
withBase(map[string]interface{}{"greatest": label(input.greatest), "window_start": label(input.windowStart), "window_end": label(input.windowEnd),
"minimum_separation_arcsec": input.separation, "sun_semidiameter_arcsec": input.sunRadius, "planet_semidiameter_arcsec": input.planetRad}))
if err != nil {
return nil, err
}
if options.TimeMarkers != nil {
markers, markerErr := planetTransitTimeMarkerSamples(input.windowStart, input.windowEnd, scale, *options.TimeMarkers, module)
if markerErr != nil {
return nil, markerErr
}
features, err = appendTimeMarkerPointFeatures(features, planetTransitEvent, "subsolar-track", markers, options.TimeMarkers.Location)
if err != nil {
return nil, err
}
}
return marshalFeatureCollectionWithTimeScale(dropFeaturesByRole(features, options.SkipRoles), scale)
}
+44
View File
@@ -0,0 +1,44 @@
package geojson_test
import (
"encoding/json"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/venus"
)
func TestMarshalVenusTransitGlobalPartial(tester *testing.T) {
info := venus.NextTransit(time.Date(2854, 1, 1, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{IsochroneStep: 30 * time.Second})
if err != nil {
tester.Fatal(err)
}
collection, roles := decodeTransit(tester, data)
for _, role := range []string{"transit-partial", "greatest", "c1-isochrone", "c4-isochrone"} {
if roles[role] != 1 {
tester.Fatalf("missing %s: %v", role, roles)
}
}
for _, role := range []string{"c1-horizon", "c4-horizon", "visible-at-c1", "visible-at-c4", "c2-isochrone", "c3-isochrone", "transit-entire"} {
if roles[role] != 0 {
tester.Fatalf("fabricated %s", role)
}
}
for _, item := range collection.Features {
if item.Properties["role"] != "transit-partial" {
continue
}
var polygons [][][][]float64
if err := json.Unmarshal(item.Geometry.Coordinates, &polygons); err != nil {
tester.Fatal(err)
}
if !geoJSONMultiPolygonContains(polygons, -135, -60) || geoJSONMultiPolygonContains(polygons, 0, 45) {
tester.Fatal("partial-transit footprint does not match the topocentric geometry")
}
if item.Properties["has_geocentric_contacts"] != false || item.Properties["series"] != float64(4) || item.Properties["gamma"] == nil {
tester.Fatalf("missing global-transit metadata: %v", item.Properties)
}
}
}
+3 -1
View File
@@ -1254,6 +1254,7 @@ var lookAtPriority = map[string]bool{
"occultation-band": true,
"total-footprint": true,
"sublunar-track": true,
"transit-partial": true,
}
func lookAtElements(groups []roleGeometry) [][]element {
@@ -1386,7 +1387,7 @@ func paletteStyle(event, role string) Style {
return Style{LineColor: ColorOrange, LineWidth: 2}
case "c1-horizon", "c4-horizon", "visible-at-c1", "visible-at-c4":
return Style{LineColor: ColorOrange, LineWidth: 2}
case "transit-entire":
case "transit-entire", "transit-partial":
return Style{LineColor: transitEntireColor, FillColor: transitEntireFill, LineWidth: 1.5}
case "transit-at-sunrise":
return Style{LineColor: transitSunriseColor, FillColor: transitSunriseFill, LineWidth: 1.5}
@@ -1629,6 +1630,7 @@ var roleNames = map[string][2]string{
"visible-at-c1": {"一触可见区", "Visible at C1"},
"visible-at-c4": {"四触可见区", "Visible at C4"},
"transit-entire": {"全程可见", "Entire transit visible"},
"transit-partial": {"部分凌日可见区", "Partial transit visible"},
"transit-at-sunrise": {"日出时已在凌日", "Transit in progress at sunrise"},
"transit-at-sunset": {"日落时仍在凌日", "Transit in progress at sunset"},
"c1-isochrone": {"一触等时线", "C1 isochrones"},
+35
View File
@@ -0,0 +1,35 @@
package kml
import (
"strings"
"testing"
"time"
"b612.me/astro/geojson"
"b612.me/astro/venus"
)
func TestPlanetTransitGlobalPartialConversion(tester *testing.T) {
info := venus.NextTransit(time.Date(2854, 1, 1, 0, 0, 0, 0, time.UTC))
data, err := geojson.MarshalVenusTransit(info, geojson.PlanetTransitOptions{IsochroneStep: time.Minute})
if err != nil {
tester.Fatal(err)
}
root := convert(tester, string(data), Options{})
if !strings.Contains(root.Document.Name, "2854-12-14") {
tester.Fatal(root.Document.Name)
}
if root.Document.LookAt == nil || root.Document.LookAt.Latitude > -50 {
tester.Fatal("KML must focus on the actual partial-transit footprint")
}
roles := map[string]bool{}
for _, item := range root.Document.Folders {
roles[item.Name] = true
}
if !roles["部分凌日可见区"] || !roles["一触等时线"] || !roles["四触等时线"] || roles["二触等时线"] || roles["一触视地平线"] {
tester.Fatalf("invalid partial-transit layers: %v", roles)
}
if paletteStyle(eventPlanetTransit, "transit-partial").FillColor == "" {
tester.Fatal("missing partial-transit fill")
}
}
+4 -4
View File
@@ -78,7 +78,7 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 {
return defaultModule.BrightLimbPositionAngleN(date, n)
}
// ClosestTransit 最近一次地心水星凌日 / closest geocentric Mercury transit.
// ClosestTransit 最近一次全球水星凌日 / closest global Mercury transit.
func ClosestTransit(date time.Time) TransitInfo { return defaultModule.ClosestTransit(date) }
// CulminationTime 中天时刻,取 date 所在当地民用日 / culmination time, on the local civil day of date.
@@ -178,10 +178,10 @@ func LastSuperiorConjunction(date time.Time) time.Time {
return defaultModule.LastSuperiorConjunction(date)
}
// LastTransit 上一次地心水星凌日 / previous geocentric Mercury transit.
// LastTransit 上一次全球水星凌日 / previous global Mercury transit.
func LastTransit(date time.Time) TransitInfo { return defaultModule.LastTransit(date) }
// LocalTransit 地心凌日在观测地的站心接触与凌甚 / topocentric contacts of a transit seen from the site.
// LocalTransit 凌日在观测地的站心接触与凌甚 / topocentric contacts of a transit seen from the site.
func LocalTransit(transit TransitInfo, lon, lat, height float64) LocalTransitInfo {
return defaultModule.LocalTransit(transit, lon, lat, height)
}
@@ -227,7 +227,7 @@ func NextSuperiorConjunction(date time.Time) time.Time {
return defaultModule.NextSuperiorConjunction(date)
}
// NextTransit 下一次地心水星凌日 / next geocentric Mercury transit.
// NextTransit 下一次全球水星凌日 / next global Mercury transit.
func NextTransit(date time.Time) TransitInfo { return defaultModule.NextTransit(date) }
// ParallacticAngle 水星视差角(天顶方向角) / Mercury parallactic angle.
+35 -16
View File
@@ -6,12 +6,12 @@ import (
"b612.me/astro/basic"
)
// TransitInfo 地心水星凌日信息 / geocentric Mercury transit information.
// TransitInfo 全球水星凌日与地心接触 / global Mercury transit and geocentric contacts.
//
// Start、Greatest、End、InternalStart、InternalEnd 都保持调用者输入的时区。
// 内切接触不存在时 InternalStart / InternalEnd 为零值。
// 地心接触不存在时对应时刻为零值;WindowStart、WindowEnd 只是站心求解窗口。
// Start, Greatest, End, InternalStart, and InternalEnd preserve the caller's timezone.
// InternalStart and InternalEnd are zero values when internal contacts do not exist.
// Absent geocentric contacts are zero; WindowStart and WindowEnd are only a local search window.
type TransitInfo struct {
Valid bool
@@ -28,20 +28,26 @@ type TransitInfo struct {
SunSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
HasInternal bool
HasInternal bool
HasExternal bool
WindowStart time.Time
WindowEnd time.Time
Gamma float64
PenumbralRadius float64
Series basic.PlanetTransitSeries
}
// NextTransit 下一次地心水星凌日 / next geocentric Mercury transit.
// NextTransit 下一次全球水星凌日 / next global Mercury transit.
func (module *Mercury) NextTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.NextMercuryTransitWithInstance(basic.Date2JD(date.UTC()), module.inst), date.Location())
}
// LastTransit 上一次地心水星凌日 / previous geocentric Mercury transit.
// LastTransit 上一次全球水星凌日 / previous global Mercury transit.
func (module *Mercury) LastTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.LastMercuryTransitWithInstance(basic.Date2JD(date.UTC()), module.inst), date.Location())
}
// ClosestTransit 最近一次地心水星凌日 / closest geocentric Mercury transit.
// ClosestTransit 最近一次全球水星凌日 / closest global Mercury transit.
func (module *Mercury) ClosestTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.ClosestMercuryTransitWithInstance(basic.Date2JD(date.UTC()), module.inst), date.Location())
}
@@ -50,19 +56,25 @@ func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location)
if !result.Valid {
return TransitInfo{}
}
start := basic.JD2DateByZone(result.ExternalIngress, loc, false)
greatest := basic.JD2DateByZone(result.Greatest, loc, false)
end := basic.JD2DateByZone(result.ExternalEgress, loc, false)
info := TransitInfo{
Valid: true,
Start: start,
Greatest: greatest,
End: end,
Duration: end.Sub(start),
MinimumSeparationArcsec: result.MinimumSeparationArcsec,
SunSemidiameterArcsec: result.SunSemidiameterArcsec,
PlanetSemidiameterArcsec: result.PlanetSemidiameterArcsec,
HasInternal: result.HasInternal,
HasExternal: result.HasExternal,
WindowStart: basic.JD2DateByZone(result.WindowStart, loc, false),
WindowEnd: basic.JD2DateByZone(result.WindowEnd, loc, false),
Gamma: result.Gamma,
PenumbralRadius: result.PenumbralRadius,
Series: result.Series,
}
if result.HasExternal {
info.Start = basic.JD2DateByZone(result.ExternalIngress, loc, false)
info.End = basic.JD2DateByZone(result.ExternalEgress, loc, false)
info.Duration = info.End.Sub(info.Start)
}
if result.HasInternal {
info.InternalStart = basic.JD2DateByZone(result.InternalIngress, loc, false)
@@ -81,7 +93,7 @@ type LocalTransitInfo = basic.PlanetTransitLocalInfo
// LocalTransitContact 站心凌日接触 / topocentric transit contact.
type LocalTransitContact = basic.PlanetTransitLocalContactInfo
// LocalTransit 地心凌日 transit 在观测地的站心接触与凌甚 / topocentric contacts of transit seen from the site.
// LocalTransit 凌日 transit 在观测地的站心接触与凌甚 / topocentric contacts of transit seen from the site.
//
// lon、lat 为度,东经、北纬为正;height 为海拔米。
// lon and lat are in degrees, east and north positive; height is in meters.
@@ -89,8 +101,15 @@ func (module *Mercury) LocalTransit(transit TransitInfo, lon, lat, height float6
if !transit.Valid {
return LocalTransitInfo{}
}
result := basic.PlanetTransitResultFromTimes(1,
transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End)
local := basic.PlanetTransitLocalWithInstance(result, basic.Observer{Longitude: lon, Latitude: lat, Height: height}, module.inst)
local := basic.PlanetTransitLocalWithInstance(transitResultFromInfo(transit), basic.Observer{Longitude: lon, Latitude: lat, Height: height}, module.inst)
return basic.PlanetTransitLocalInfoFromResult(local, transit.Greatest.Location())
}
// transitResultFromInfo 按一触、四触是否为零选择地心接触或求解窗口还原 basic 结果。
func transitResultFromInfo(transit TransitInfo) basic.PlanetTransitResult {
if transit.Start.IsZero() && transit.End.IsZero() {
return basic.PlanetTransitResultFromWindow(1, transit.WindowStart, transit.Greatest, transit.WindowEnd)
}
return basic.PlanetTransitResultFromTimes(1,
transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End)
}
+4 -4
View File
@@ -78,7 +78,7 @@ func BrightLimbPositionAngleN(date time.Time, n int) float64 {
return defaultModule.BrightLimbPositionAngleN(date, n)
}
// ClosestTransit 最近一次地心金星凌日 / closest geocentric Venus transit.
// ClosestTransit 最近一次全球金星凌日 / closest global Venus transit.
func ClosestTransit(date time.Time) TransitInfo { return defaultModule.ClosestTransit(date) }
// CulminationTime 中天时刻,取 date 所在当地民用日 / culmination time, on the local civil day of date.
@@ -178,10 +178,10 @@ func LastSuperiorConjunction(date time.Time) time.Time {
return defaultModule.LastSuperiorConjunction(date)
}
// LastTransit 上一次地心金星凌日 / previous geocentric Venus transit.
// LastTransit 上一次全球金星凌日 / previous global Venus transit.
func LastTransit(date time.Time) TransitInfo { return defaultModule.LastTransit(date) }
// LocalTransit 地心凌日在观测地的站心接触与凌甚 / topocentric contacts of a transit seen from the site.
// LocalTransit 凌日在观测地的站心接触与凌甚 / topocentric contacts of a transit seen from the site.
func LocalTransit(transit TransitInfo, lon, lat, height float64) LocalTransitInfo {
return defaultModule.LocalTransit(transit, lon, lat, height)
}
@@ -227,7 +227,7 @@ func NextSuperiorConjunction(date time.Time) time.Time {
return defaultModule.NextSuperiorConjunction(date)
}
// NextTransit 下一次地心金星凌日 / next geocentric Venus transit.
// NextTransit 下一次全球金星凌日 / next global Venus transit.
func NextTransit(date time.Time) TransitInfo { return defaultModule.NextTransit(date) }
// ParallacticAngle 金星视差角(天顶方向角) / Venus parallactic angle.
+35 -16
View File
@@ -6,12 +6,12 @@ import (
"b612.me/astro/basic"
)
// TransitInfo 地心金星凌日信息 / geocentric Venus transit information.
// TransitInfo 全球金星凌日与地心接触 / global Venus transit and geocentric contacts.
//
// Start、Greatest、End、InternalStart、InternalEnd 都保持调用者输入的时区。
// 内切接触不存在时 InternalStart / InternalEnd 为零值。
// 地心接触不存在时对应时刻为零值;WindowStart、WindowEnd 只是站心求解窗口。
// Start, Greatest, End, InternalStart, and InternalEnd preserve the caller's timezone.
// InternalStart and InternalEnd are zero values when internal contacts do not exist.
// Absent geocentric contacts are zero; WindowStart and WindowEnd are only a local search window.
type TransitInfo struct {
Valid bool
@@ -28,20 +28,26 @@ type TransitInfo struct {
SunSemidiameterArcsec float64
PlanetSemidiameterArcsec float64
HasInternal bool
HasInternal bool
HasExternal bool
WindowStart time.Time
WindowEnd time.Time
Gamma float64
PenumbralRadius float64
Series basic.PlanetTransitSeries
}
// NextTransit 下一次地心金星凌日 / next geocentric Venus transit.
// NextTransit 下一次全球金星凌日 / next global Venus transit.
func (module *Venus) NextTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.NextVenusTransitWithInstance(basic.Date2JD(date.UTC()), module.inst), date.Location())
}
// LastTransit 上一次地心金星凌日 / previous geocentric Venus transit.
// LastTransit 上一次全球金星凌日 / previous global Venus transit.
func (module *Venus) LastTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.LastVenusTransitWithInstance(basic.Date2JD(date.UTC()), module.inst), date.Location())
}
// ClosestTransit 最近一次地心金星凌日 / closest geocentric Venus transit.
// ClosestTransit 最近一次全球金星凌日 / closest global Venus transit.
func (module *Venus) ClosestTransit(date time.Time) TransitInfo {
return transitInfoFromBasic(basic.ClosestVenusTransitWithInstance(basic.Date2JD(date.UTC()), module.inst), date.Location())
}
@@ -50,19 +56,25 @@ func transitInfoFromBasic(result basic.PlanetTransitResult, loc *time.Location)
if !result.Valid {
return TransitInfo{}
}
start := basic.JD2DateByZone(result.ExternalIngress, loc, false)
greatest := basic.JD2DateByZone(result.Greatest, loc, false)
end := basic.JD2DateByZone(result.ExternalEgress, loc, false)
info := TransitInfo{
Valid: true,
Start: start,
Greatest: greatest,
End: end,
Duration: end.Sub(start),
MinimumSeparationArcsec: result.MinimumSeparationArcsec,
SunSemidiameterArcsec: result.SunSemidiameterArcsec,
PlanetSemidiameterArcsec: result.PlanetSemidiameterArcsec,
HasInternal: result.HasInternal,
HasExternal: result.HasExternal,
WindowStart: basic.JD2DateByZone(result.WindowStart, loc, false),
WindowEnd: basic.JD2DateByZone(result.WindowEnd, loc, false),
Gamma: result.Gamma,
PenumbralRadius: result.PenumbralRadius,
Series: result.Series,
}
if result.HasExternal {
info.Start = basic.JD2DateByZone(result.ExternalIngress, loc, false)
info.End = basic.JD2DateByZone(result.ExternalEgress, loc, false)
info.Duration = info.End.Sub(info.Start)
}
if result.HasInternal {
info.InternalStart = basic.JD2DateByZone(result.InternalIngress, loc, false)
@@ -81,7 +93,7 @@ type LocalTransitInfo = basic.PlanetTransitLocalInfo
// LocalTransitContact 站心凌日接触 / topocentric transit contact.
type LocalTransitContact = basic.PlanetTransitLocalContactInfo
// LocalTransit 地心凌日 transit 在观测地的站心接触与凌甚 / topocentric contacts of transit seen from the site.
// LocalTransit 凌日 transit 在观测地的站心接触与凌甚 / topocentric contacts of transit seen from the site.
//
// lon、lat 为度,东经、北纬为正;height 为海拔米。
// lon and lat are in degrees, east and north positive; height is in meters.
@@ -89,8 +101,15 @@ func (module *Venus) LocalTransit(transit TransitInfo, lon, lat, height float64)
if !transit.Valid {
return LocalTransitInfo{}
}
result := basic.PlanetTransitResultFromTimes(2,
transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End)
local := basic.PlanetTransitLocalWithInstance(result, basic.Observer{Longitude: lon, Latitude: lat, Height: height}, module.inst)
local := basic.PlanetTransitLocalWithInstance(transitResultFromInfo(transit), basic.Observer{Longitude: lon, Latitude: lat, Height: height}, module.inst)
return basic.PlanetTransitLocalInfoFromResult(local, transit.Greatest.Location())
}
// transitResultFromInfo 按一触、四触是否为零选择地心接触或求解窗口还原 basic 结果。
func transitResultFromInfo(transit TransitInfo) basic.PlanetTransitResult {
if transit.Start.IsZero() && transit.End.IsZero() {
return basic.PlanetTransitResultFromWindow(2, transit.WindowStart, transit.Greatest, transit.WindowEnd)
}
return basic.PlanetTransitResultFromTimes(2,
transit.Start, transit.InternalStart, transit.Greatest, transit.InternalEnd, transit.End)
}
+35
View File
@@ -0,0 +1,35 @@
package venus
import (
"testing"
"time"
)
func TestGlobalTransitWrapper(tester *testing.T) {
zone := time.FixedZone("UTC+8", 8*3600)
info := NextTransit(time.Date(2854, 1, 1, 0, 0, 0, 0, zone))
if !info.Valid || info.Greatest.Year() != 2854 || info.HasExternal || !info.Start.IsZero() || !info.End.IsZero() || info.Duration != 0 || info.Series.Number != 4 {
tester.Fatalf("invalid global transit: %+v", info)
}
if info.Greatest.Location() != zone || info.WindowStart.Location() != zone || info.WindowEnd.Location() != zone {
tester.Fatal("global transit lost the caller's timezone")
}
local := LocalTransit(info, -135, -60, 0)
if !local.Valid || !local.Visible || local.HasInternal || local.Greatest.Time.Location() != zone {
tester.Fatalf("invalid local partial transit: %+v", local)
}
for _, result := range []TransitInfo{ClosestTransit(info.Greatest), LastTransit(info.Greatest.Add(time.Minute)), NextTransit(info.Greatest.Add(-time.Minute))} {
difference := result.Greatest.Sub(info.Greatest)
if !result.Valid || difference > time.Second || difference < -time.Second {
tester.Fatal("global event lost at a query boundary")
}
}
}
func TestLocalTransitRequiresContactsOrWindow(tester *testing.T) {
info := NextTransit(time.Date(2854, 1, 1, 0, 0, 0, 0, time.UTC))
info.WindowStart, info.WindowEnd = time.Time{}, time.Time{}
if local := LocalTransit(info, -135, -60, 0); local.Valid {
tester.Fatal("partial transit without a window must be rejected")
}
}