diff --git a/basic/api_default.go b/basic/api_default.go
index 8048031..83edbe5 100644
--- a/basic/api_default.go
+++ b/basic/api_default.go
@@ -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)
diff --git a/basic/planet_transit.go b/basic/planet_transit.go
index 00040ef..9385351 100644
--- a/basic/planet_transit.go
+++ b/basic/planet_transit.go
@@ -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)
}
diff --git a/basic/planet_transit_geometry.go b/basic/planet_transit_geometry.go
new file mode 100644
index 0000000..7fb160c
--- /dev/null
+++ b/basic/planet_transit_geometry.go
@@ -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
+}
diff --git a/basic/planet_transit_global_test.go b/basic/planet_transit_global_test.go
new file mode 100644
index 0000000..d30eb09
--- /dev/null
+++ b/basic/planet_transit_global_test.go
@@ -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)
+ }
+}
diff --git a/basic/planet_transit_local.go b/basic/planet_transit_local.go
index c25c7d5..7a0380b 100644
--- a/basic/planet_transit_local.go
+++ b/basic/planet_transit_local.go
@@ -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 {
diff --git a/basic/planet_transit_prediction.go b/basic/planet_transit_prediction.go
new file mode 100644
index 0000000..c6cb89f
--- /dev/null
+++ b/basic/planet_transit_prediction.go
@@ -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
+}
diff --git a/basic/planet_transit_visibility.go b/basic/planet_transit_visibility.go
new file mode 100644
index 0000000..2347618
--- /dev/null
+++ b/basic/planet_transit_visibility.go
@@ -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
+}
diff --git a/doc/img/transit-mercury-1937-partial-en.svg b/doc/img/transit-mercury-1937-partial-en.svg
new file mode 100644
index 0000000..0418278
--- /dev/null
+++ b/doc/img/transit-mercury-1937-partial-en.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/doc/img/transit-mercury-1937-partial.svg b/doc/img/transit-mercury-1937-partial.svg
new file mode 100644
index 0000000..e003f9f
--- /dev/null
+++ b/doc/img/transit-mercury-1937-partial.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/doc/img/transit-mercury-beijing-2032-en.svg b/doc/img/transit-mercury-beijing-2032-en.svg
index 0df19f4..18c5230 100644
--- a/doc/img/transit-mercury-beijing-2032-en.svg
+++ b/doc/img/transit-mercury-beijing-2032-en.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-mercury-beijing-2032-global-en.svg b/doc/img/transit-mercury-beijing-2032-global-en.svg
index 8bbe5e9..58e9a8e 100644
--- a/doc/img/transit-mercury-beijing-2032-global-en.svg
+++ b/doc/img/transit-mercury-beijing-2032-global-en.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-mercury-beijing-2032-global.svg b/doc/img/transit-mercury-beijing-2032-global.svg
index 28af4dd..564ad62 100644
--- a/doc/img/transit-mercury-beijing-2032-global.svg
+++ b/doc/img/transit-mercury-beijing-2032-global.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-mercury-beijing-2032.svg b/doc/img/transit-mercury-beijing-2032.svg
index 3c762b9..facb623 100644
--- a/doc/img/transit-mercury-beijing-2032.svg
+++ b/doc/img/transit-mercury-beijing-2032.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-venus-beijing-2012-en.svg b/doc/img/transit-venus-beijing-2012-en.svg
index 532f534..739957e 100644
--- a/doc/img/transit-venus-beijing-2012-en.svg
+++ b/doc/img/transit-venus-beijing-2012-en.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-venus-beijing-2012-global-en.svg b/doc/img/transit-venus-beijing-2012-global-en.svg
index 1dd0ed6..7eec6f0 100644
--- a/doc/img/transit-venus-beijing-2012-global-en.svg
+++ b/doc/img/transit-venus-beijing-2012-global-en.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-venus-beijing-2012-global.svg b/doc/img/transit-venus-beijing-2012-global.svg
index 7750fa1..72bc097 100644
--- a/doc/img/transit-venus-beijing-2012-global.svg
+++ b/doc/img/transit-venus-beijing-2012-global.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/img/transit-venus-beijing-2012.svg b/doc/img/transit-venus-beijing-2012.svg
index 6f7c2ab..0035c01 100644
--- a/doc/img/transit-venus-beijing-2012.svg
+++ b/doc/img/transit-venus-beijing-2012.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/doc/manual/eclipse.md b/doc/manual/eclipse.md
index 9437645..1deaaa5 100644
--- a/doc/manual/eclipse.md
+++ b/doc/manual/eclipse.md
@@ -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


+
+### 特例:地心无接触的部分凌日
+
+1937-05-11 水星凌日即为一例,可见区横跨南大西洋、非洲南端与印度洋,东至澳大利亚东侧。`2854-12-14` 金星凌日同属此类,可见区跨越南极圈,集中在南纬 50° 以南。
+
+
diff --git a/doc/manual/en/eclipse.md b/doc/manual/en/eclipse.md
index e017d4b..3b231b9 100644
--- a/doc/manual/en/eclipse.md
+++ b/doc/manual/en/eclipse.md
@@ -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`


+
+### 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.
+
+
diff --git a/doc/manual/en/map-geojson.md b/doc/manual/en/map-geojson.md
index 9f3054f..56b6289 100644
--- a/doc/manual/en/map-geojson.md
+++ b/doc/manual/en/map-geojson.md
@@ -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:
diff --git a/doc/manual/en/planets.md b/doc/manual/en/planets.md
index f57f5c1..bed91bc 100644
--- a/doc/manual/en/planets.md
+++ b/doc/manual/en/planets.md
@@ -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`.
diff --git a/doc/manual/map-geojson.md b/doc/manual/map-geojson.md
index 732cd2e..b134df6 100644
--- a/doc/manual/map-geojson.md
+++ b/doc/manual/map-geojson.md
@@ -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 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` 的零值字段沿用默认样式:
diff --git a/doc/manual/planets.md b/doc/manual/planets.md
index fcb0d92..3b1e360 100644
--- a/doc/manual/planets.md
+++ b/doc/manual/planets.md
@@ -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` 版)当天没有几何升/落时返回下面的哨兵错误,时刻为零值。
diff --git a/eclipse/svg/data_source_test.go b/eclipse/svg/data_source_test.go
index c8f550f..c92d797 100644
--- a/eclipse/svg/data_source_test.go
+++ b/eclipse/svg/data_source_test.go
@@ -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))
diff --git a/eclipse/svg/doc.go b/eclipse/svg/doc.go
index 81ee315..4143474 100644
--- a/eclipse/svg/doc.go
+++ b/eclipse/svg/doc.go
@@ -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,
}
diff --git a/eclipse/svg/transit_local.go b/eclipse/svg/transit_local.go
index 4b9b766..930248e 100644
--- a/eclipse/svg/transit_local.go
+++ b/eclipse/svg/transit_local.go
@@ -175,13 +175,31 @@ func renderTransitLocalSVG(
fmt.Fprintf(&b, `%s`,
width/2, html.EscapeString(titleText))
for index, line := range []string{site, summary} {
+ line = svgchart.EllipsizeText(line, width-80, 13)
fmt.Fprintf(&b, `%s`,
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(``)
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, ``, cx, cy-reach, cx, cy+reach)
fmt.Fprintf(b, ``, 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, `%s`,
baselines[index], html.EscapeString(line))
diff --git a/eclipse/svg/transit_map.go b/eclipse/svg/transit_map.go
index 8306a3e..075d043 100644
--- a/eclipse/svg/transit_map.go
+++ b/eclipse/svg/transit_map.go
@@ -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, `