fix(sat): the per-band antennas reach the satellite slices

Settings ▸ FlexRadio stores the band→antenna map keyed by the band name
in capitals — that is what the panel writes and what the entry form reads
back. applySatRadio looked its two bands up through bandForHz, which
returns the band plan's own spelling ("70cm"), so both lookups missed,
both antennas came back empty, and the early return left the pass on
whichever antenna the radio was last used on. An operator with XVTA on
2 m and XVTB on 70 cm had configured exactly the thing being ignored, and
nothing said so: the downlink ran through the wrong transverter in
silence.

The key is now computed by flexBandAntKey, which exists so the next
caller cannot make the same mistake, and a test pins the case in the
direction that broke. Both outcomes are logged — the resolved antennas,
or the bands nothing was configured for — because from the outside a
setting never made and a lookup that missed look identical.

Alongside, three things the same pass made obvious:

- Tracking shows the satellite's own azimuth, elevation, distance and
  altitude beside the frequencies. The strip held where the ANTENNA was
  pointing but not where the bird was, which is what says whether a pass
  is worth calling on. Elevation dims below the horizon so a satellite
  followed before its lever cannot be read as workable.

- Both satellite lists in the settings are sorted by name with the
  numbers taken as numbers. The available column followed the order
  birds.json happens to be written in and the followed column the order
  of the clicks, so finding one bird among sixteen meant reading all
  sixteen.

- A followed satellite that has since been renamed is resolved through
  the plan's aliases. LILACSAT-2 became LO-90, and the followed list is
  stored as plain text, so the bird the operator had chosen appeared as
  having no elements while the same satellite sat in the available
  column under its new name. Resolved in GetSatSettings rather than
  satSettings, which is read at startup before the plan is loaded.
This commit is contained in:
2026-09-10 11:00:24 +02:00
parent 580e5782f8
commit 2615365684
7 changed files with 145 additions and 18 deletions
+43 -6
View File
@@ -314,7 +314,12 @@ func (a *App) GetSatSettings() (SatSettings, error) {
if a.settings == nil { if a.settings == nil {
return SatSettings{}, fmt.Errorf("db not initialized") return SatSettings{}, fmt.Errorf("db not initialized")
} }
return a.satSettings(), nil out := a.satSettings()
// Resolved HERE and not in satSettings, which is read during startup before
// the frequency plan is loaded. Saving the panel writes the resolved list
// back, so the rename settles itself the first time anything is changed.
out.Favorites = a.satFavorites()
return out, nil
} }
// SaveSatSettings stores them. // SaveSatSettings stores them.
@@ -535,9 +540,8 @@ func (a *App) AddSatelliteElements(text string) (int, error) {
// configuration problem into a satellite that "does not exist". // configuration problem into a satellite that "does not exist".
func (a *App) GetSatelliteBirds() []SatBird { func (a *App) GetSatelliteBirds() []SatBird {
store, birds, _ := a.satParts() store, birds, _ := a.satParts()
set := a.satSettings()
fav := map[string]bool{} fav := map[string]bool{}
for _, n := range set.Favorites { for _, n := range a.satFavorites() {
fav[strings.ToUpper(n)] = true fav[strings.ToUpper(n)] = true
} }
@@ -665,15 +669,48 @@ func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
// ── Tracking ──────────────────────────────────────────────────────────────── // ── Tracking ────────────────────────────────────────────────────────────────
// satFavorites is the followed list, with every name resolved to the one the
// frequency plan uses now.
//
// A satellite is renamed when it is granted an OSCAR number — LILACSAT-2
// became LO-90 — and the plan carries the old name as an alias. The followed
// list, though, is stored as the plain text the operator picked: after such a
// rename his own choice was listed as having no elements while the same bird
// sat under its new name in the available column, so the satellite he had
// chosen had quietly become a stranger.
//
// Deduplicated on the way out, because an operator who followed both spellings
// must not now see the same bird twice.
func (a *App) satFavorites() []string {
names := a.satSettings().Favorites
_, birds, _ := a.satParts()
if birds == nil {
return names
}
out := make([]string, 0, len(names))
seen := map[string]bool{}
for _, n := range names {
if b, ok := birds.Find(n); ok {
n = b.Name
}
k := strings.ToUpper(n)
if seen[k] {
continue
}
seen[k] = true
out = append(out, n)
}
return out
}
// satNames resolves the names the UI asked for, falling back to the favourites // satNames resolves the names the UI asked for, falling back to the favourites
// and then to every planned bird we hold elements for. // and then to every planned bird we hold elements for.
func (a *App) satNames(names []string) []string { func (a *App) satNames(names []string) []string {
if len(names) > 0 { if len(names) > 0 {
return names return names
} }
set := a.satSettings() if favs := a.satFavorites(); len(favs) > 0 {
if len(set.Favorites) > 0 { return favs
return set.Favorites
} }
var out []string var out []string
for _, b := range a.GetSatelliteBirds() { for _, b := range a.GetSatelliteBirds() {
+28 -4
View File
@@ -96,6 +96,11 @@ type SatTrackStatus struct {
Az float64 `json:"az"` Az float64 `json:"az"`
El float64 `json:"el"` El float64 `json:"el"`
Visible bool `json:"visible"` Visible bool `json:"visible"`
// Where the satellite is, as opposed to where to point: an operator reads
// the distance to know whether a pass is worth calling on, and the altitude
// to know how long it will last.
RangeKm float64 `json:"range_km"`
AltKm float64 `json:"alt_km"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", "" Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"` Error string `json:"error"`
@@ -350,6 +355,7 @@ func (a *App) satTrackStep(t *satTracker) {
st.NominalDown, st.NominalUp = nominal, nomUp st.NominalDown, st.NominalUp = nominal, nomUp
st.DownHz, st.UpHz = down, up st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible st.Az, st.El, st.Visible = pos.Az, pos.El, visible
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
t.status = st t.status = st
t.mu.Unlock() t.mu.Unlock()
@@ -649,8 +655,20 @@ func satBandLetter(hz int64) string {
return "K" // 24 GHz and above return "K" // 24 GHz and above
} }
// applySatAntennas puts each satellite slice on the antenna configured for ITS // flexBandAntKey is the key a frequency has in the per-band antenna and power
// band. // maps.
//
// Those maps are keyed by the band name UPPERCASED ("70CM"), because that is
// how the settings panel writes them; bandForHz returns the band plan's own
// spelling ("70cm"). Every other caller happened to uppercase on the way in,
// the satellite tracker did not, and so it read an empty antenna out of a map
// the operator had filled in — which is not a mistake worth making twice.
func flexBandAntKey(hz int64) string {
return strings.ToUpper(bandForHz(hz))
}
// applySatRadio puts each satellite slice on the antenna configured for ITS
// band, and sets the uplink tone.
// //
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was // Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
// only ever applied by the entry form on a band change — to the active slice. // only ever applied by the entry form on a band change — to the active slice.
@@ -681,11 +699,17 @@ func (a *App) applySatRadio(tp sat.Transponder) {
} }
// The downlink is received, so it takes that band's RX antenna; the uplink // The downlink is received, so it takes that band's RX antenna; the uplink
// is transmitted, so it takes that band's TX antenna. // is transmitted, so it takes that band's TX antenna.
rxAnt := m[bandForHz(tp.DownLo)].RX downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
txAnt := m[bandForHz(tp.UpLo)].TX rxAnt := m[downBand].RX
txAnt := m[upBand].TX
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" { if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
// Worth a line: an operator who HAS configured the pair and still sees
// the wrong antenna has no other way to tell a setting he never made
// from a lookup that missed.
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
return return
} }
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand)
if err := a.cat.FlexDo(func(fc cat.FlexController) error { if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatAntennas(rxAnt, txAnt) return fc.SatAntennas(rxAnt, txAnt)
}); err != nil { }); err != nil {
+23
View File
@@ -135,3 +135,26 @@ func TestSatSidebands(t *testing.T) {
} }
} }
} }
// The per-band antenna map is keyed by the UPPERCASED band name, because that
// is what the settings panel writes. The satellite tracker looked its two bands
// up with the band plan's own spelling, matched nothing, and ran the pass on
// whichever antenna the radio was last left on — a 70 cm downlink through a 2 m
// transverter, with no error anywhere. This pins the contract in the direction
// that broke.
func TestFlexBandAntKeyIsUppercased(t *testing.T) {
for _, c := range []struct {
hz int64
want string
}{
{435_400_000, "70CM"}, // an FM bird's downlink
{145_950_000, "2M"}, // its uplink
{1_269_000_000, "23CM"}, // AO-92's L band
{29_450_000, "10M"}, // AO-7 mode A
{9_000_000_000, ""}, // nothing in the plan: no key, and no antenna
} {
if got := flexBandAntKey(c.hz); got != c.want {
t.Errorf("flexBandAntKey(%d) = %q, want %q", c.hz, got, c.want)
}
}
}
+8 -2
View File
@@ -7,14 +7,20 @@
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.", "On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.", "The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.",
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.", "LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.",
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release." "A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.",
"The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.",
"Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.",
"The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements."
], ],
"fr": [ "fr": [
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.", "SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.",
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment.", "Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment.",
"Le widget rotor nannonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop sallumait et s’éteignait toute la soirée sur une antenne qui navait pas tourné. Ce qui distingue une rotation de la météo nest pas lamplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.", "Le widget rotor nannonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop sallumait et s’éteignait toute la soirée sur une antenne qui navait pas tourné. Ce qui distingue une rotation de la météo nest pas lamplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.",
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt quen continu, ce qui, vu dune base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites quil a écartés pour cette raison sans rien garder dautre, afin que la prochaine régénération ne les passe plus sous silence.", "LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt quen continu, ce qui, vu dune base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites quil a écartés pour cette raison sans rien garder dautre, afin que la prochaine régénération ne les passe plus sous silence.",
"Une fréquence quOpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusquici ce fichier était copié au premier lancement puis appartenait à lopérateur, si bien quune erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui na jamais été modifié, et laisse intact ce qui la été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il sest effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie dabord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version." "Une fréquence quOpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusquici ce fichier était copié au premier lancement puis appartenait à lopérateur, si bien quune erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui na jamais été modifié, et laisse intact ce qui la été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il sest effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie dabord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.",
"Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que lopérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et lantenne retenues, pour distinguer un réglage jamais fait dune recherche qui a échoué.",
"Le suivi affiche désormais où est le satellite à côté de là où pointe lantenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation sestompe sous lhorizon, pour quun satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.",
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait lordre du fichier de fréquences et celle de droite lordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de lattribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore lancien nom, au lieu dy apparaître sans éléments."
] ]
}, },
{ {
+23 -2
View File
@@ -57,7 +57,7 @@ type Tuning = {
type Track = { type Track = {
on: boolean; name: string; transponder: string; mode: string; on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number; nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean; az: number; el: number; visible: boolean; range_km: number; alt_km: number;
radio: string; // "sat" | "downlink-only" | "" radio: string; // "sat" | "downlink-only" | ""
error: string; error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean; rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
@@ -719,7 +719,28 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
first thing they hide to get the map full width. */} first thing they hide to get the map full width. */}
{tracking?.on && ( {tracking?.on && (
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums"> <div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
<span className="flex items-center gap-1" title={t('sat.down')}> {/* Where the bird IS, which is not where the antenna is pointing:
these three say whether the pass is worth calling on, and the
rotator group further along says whether the mast has caught up
with them. Elevation goes dim below the horizon, so a satellite
still being tracked on its way up cannot be read as workable. */}
<span className="flex items-center gap-1.5" title={`${t('sat.tipAz')} / ${t('sat.tipEl')}`}>
<Radar className="size-3 text-muted-foreground" />
<span className={cn('font-medium', !tracking.visible && 'text-muted-foreground')}>
{Math.round(tracking.az)}° / {tracking.el.toFixed(1)}°
</span>
</span>
{tracking.range_km > 0 && (
<span className="text-muted-foreground" title={t('sat.range')}>
{Math.round(tracking.range_km).toLocaleString()} km
</span>
)}
{tracking.alt_km > 0 && (
<span className="text-muted-foreground" title={t('sat.altitude')}>
{Math.round(tracking.alt_km).toLocaleString()} km
</span>
)}
<span className="flex items-center gap-1 border-l border-border pl-2.5" title={t('sat.down')}>
<ArrowDown className="size-3 text-muted-foreground" /> <ArrowDown className="size-3 text-muted-foreground" />
<span className="font-medium">{fmtHz(tracking.down_hz)}</span> <span className="font-medium">{fmtHz(tracking.down_hz)}</span>
</span> </span>
+14 -2
View File
@@ -1468,6 +1468,13 @@ function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAu
// Following none means following every satellite that has both elements and a // Following none means following every satellite that has both elements and a
// frequency plan, which is the sensible thing for somebody who has not chosen // frequency plan, which is the sensible thing for somebody who has not chosen
// yet and the reason the list does not start out empty-handed. // yet and the reason the list does not start out empty-handed.
// byCallsign orders satellite names the way an operator reads them: alphabetical,
// but with the number taken as a number. Plain string order files AO-123 between
// AO-1 and AO-27, which is not where anybody looks for it.
function byCallsign(a: { name?: string }, b: { name?: string }) {
return String(a?.name ?? '').localeCompare(String(b?.name ?? ''), undefined, { numeric: true, sensitivity: 'base' });
}
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) { function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
const { t } = useI18n(); const { t } = useI18n();
const [all, setAll] = useState<any[]>([]); const [all, setAll] = useState<any[]>([]);
@@ -1485,8 +1492,13 @@ function SatelliteFollowList({ followed, onChange }: { followed: string[]; onCha
const needle = q.trim().toLowerCase(); const needle = q.trim().toLowerCase();
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase()) const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0) && (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
&& (needle === '' || String(b.name).toLowerCase().includes(needle))); && (needle === '' || String(b.name).toLowerCase().includes(needle))).sort(byCallsign);
const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] }); // Sorted, both columns: the left one came in the order the frequency file
// happens to be written and the right one in the order the operator clicked,
// so finding AO-91 among sixteen followed birds meant reading all sixteen.
const chosen = followed
.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] })
.sort(byCallsign);
const label = (b: any) => { const label = (b: any) => {
const bits: string[] = []; const bits: string[] = [];
+4
View File
@@ -4282,6 +4282,8 @@ export namespace main {
az: number; az: number;
el: number; el: number;
visible: boolean; visible: boolean;
range_km: number;
alt_km: number;
radio: string; radio: string;
error: string; error: string;
rot_on: boolean; rot_on: boolean;
@@ -4307,6 +4309,8 @@ export namespace main {
this.az = source["az"]; this.az = source["az"];
this.el = source["el"]; this.el = source["el"];
this.visible = source["visible"]; this.visible = source["visible"];
this.range_km = source["range_km"];
this.alt_km = source["alt_km"];
this.radio = source["radio"]; this.radio = source["radio"];
this.error = source["error"]; this.error = source["error"];
this.rot_on = source["rot_on"]; this.rot_on = source["rot_on"];