feat(sat): a sky plot — the pass seen from underneath it

The map answers "where is the satellite over the earth". This answers
"where do I look", which during a pass is the question that matters.

The projection is the one every tracker uses and every operator already
reads: the centre is the zenith, the rim is the horizon, north is up. So
the radius is (90 − elevation), not the elevation — a bird overhead is a
dot in the middle, and a pass that hugs the rim is one that never rises.
Whether it comes over the roof or along the treeline is something no
amount of azimuth and elevation digits conveys, and one glance settles.

The whole pass is drawn: a dashed track with arrowheads for the direction
of travel, a hollow circle where it rises, a filled one where it sets,
and a cross where the satellite is now — green above the horizon, grey
below, because the numbers are still right down there and nothing can be
worked through the earth.

The track is fetched once a minute, not once a second: the SHAPE of a
pass does not change while it happens. Only the marker moves, and that
rides on the tuning poll that was already running.
This commit is contained in:
2026-09-07 22:50:29 +02:00
parent 37dadeda84
commit cf44b37bf4
9 changed files with 295 additions and 4 deletions
+56
View File
@@ -805,6 +805,62 @@ func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error)
return passes, nil
}
// SatSkyPoint is one moment of a pass as the antenna sees it.
type SatSkyPoint struct {
At time.Time `json:"at"`
Az float64 `json:"az"`
El float64 `json:"el"`
}
// GetSatelliteSkyTrack is the pass drawn as a path across the sky.
//
// The map answers "where is it over the earth"; this answers "where do I look",
// which on a pass is the question that matters. An operator reading a polar
// plot knows in one glance whether the bird comes over the top or clips the
// horizon behind the house — something no amount of azimuth and elevation
// digits conveys.
func (a *App) GetSatelliteSkyTrack(name string, points int) ([]SatSkyPoint, error) {
if points < 8 || points > 400 {
points = 120
}
p, err := a.GetSatelliteNextPass(name)
if err != nil {
return nil, err
}
if !p.HasPass {
return nil, nil
}
obs, err := a.satObserver()
if err != nil {
return nil, err
}
real, ok := a.satResolve(name)
if !ok {
return nil, fmt.Errorf("%s is not in the element set", name)
}
store, _, _ := a.satParts()
span := p.LOS.Sub(p.AOS)
if span <= 0 {
return nil, nil
}
out := make([]SatSkyPoint, 0, points+1)
for i := 0; i <= points; i++ {
at := p.AOS.Add(time.Duration(float64(span) * float64(i) / float64(points)))
pos, err := store.Track(real, obs, at)
if err != nil {
return nil, err
}
// Below the horizon at the very ends, by a fraction of a degree, because
// the pass boundaries come from a coarser search than this sampling. A
// negative elevation would draw the track outside the horizon circle.
if pos.El < 0 {
pos.El = 0
}
out = append(out, SatSkyPoint{At: at.UTC(), Az: pos.Az, El: pos.El})
}
return out, nil
}
// GetSatelliteNextPass is the pass in progress, or the next one to come.
//
// The one question that decides whether an operator sits down at the radio, and