fix(sat,decodes): the left column holds position too, and drift stops lying

Two things.

The sky plot and the position share the left column now, and the button
that opens it says so the way the right one does — it was a radar icon,
left over from when it toggled a plot inside the readout column, and the
gesture is the same one on both sides. The plot and the numbers are the
same answer at two precisions: azimuth and elevation drawn, then written
out to the digit. Having them at opposite ends of the window meant
reading a bearing off one side and finding it on the other.

And the band-drift warning, reported by W4TE. It compared the decoder's
announced band against RigState.Band, which is the TRANSMIT band — so
with slice A on 20 m running its own WSJT-X, slice B on 40 m, and
transmit focus on B, the 20 m decoder was told the rig was on 40 m while
the slice it listens to had been on 20 m throughout. The panel's own
comment had accepted this as a line that setup could read past; it is
worse than that, because the warning names a band and asserts something
false about the radio.

RigState now carries RxBands: every band the rig has a receiver on. One
entry on a single-VFO rig, one per slice on a Flex, the transmit band
always included so it cannot come back empty while the rig is on a
frequency. The warning fires only when the decoder announces a band
NOTHING on the radio is on, which is what it was always for and what a
lost CAT link actually looks like.
This commit is contained in:
2026-09-10 15:25:54 +02:00
parent 3fe14c2c77
commit 7e6e1335e3
8 changed files with 240 additions and 94 deletions
+3
View File
@@ -6644,6 +6644,9 @@ export default function App() {
// Only while CAT is actually connected: an empty band means "nothing to
// compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''}
// Every band the radio is listening on, so a second slice on a second
// band does not read as a decoder that has lost CAT.
rigBands={catState.connected ? (catState.rx_bands ?? []) : []}
myCall={station.callsign}
myGrid={station.my_grid}
// A DOUBLE click answers the station: it hands the decode back to
+16 -3
View File
@@ -102,6 +102,9 @@ interface Props {
// The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning.
rigBand?: string;
// Every band the radio has a receiver on. A Flex running two slices has
// two, and a decoder on either of them is not drifting.
rigBands?: string[];
onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall.
@@ -641,7 +644,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, rigBands, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -778,9 +781,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past.
//
// Compared against every band the radio is RECEIVING on, not the transmit
// band. Two slices on two bands with a decoder on each is a normal setup,
// and it made this warning lie: with slice A on 20 m, slice B on 40 m and
// transmit focus on B, the 20 m decoder was told the rig was on 40 m while
// the slice it listens to was on 20 m all along. The warning is for a
// decoder announcing a band NOTHING on the radio is on, which is what a
// lost CAT link actually looks like.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
.map((b) => b.toLowerCase());
const bandDrift = onAir.length > 0 && !!decoderBand
&& !onAir.includes(decoderBand.toLowerCase());
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
+87 -75
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass,
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen,
PanelLeftClose, PanelLeftOpen, Radar, Compass,
ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
@@ -857,13 +858,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
>
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
{/* The left column, with the same kind of control as the right one.
It was a Radar icon, from when this toggled a plot inside the
readout column. It now opens and shuts a column of the window, so
it says so the way the other one does — the two are the same
gesture and an operator should not have to learn them twice. */}
<Button
variant="ghost" size="sm"
className={cn('h-7 px-1.5', skyShown && 'text-success')}
variant="ghost" size="sm" className="h-7 px-1.5"
onClick={() => setSkyShown((v) => !v)}
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
>
<Radar className="size-3.5" />
{skyShown ? <PanelLeftClose className="size-3.5" /> : <PanelLeftOpen className="size-3.5" />}
</Button>
{/* Put the whole window on the map. On a laptop the readout takes a
third of the screen, and there are moments — watching a footprint
@@ -880,17 +886,23 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
<div className="flex gap-1 flex-1 min-h-0">
{/* The sky, seen from underneath it: the centre is straight up, the
rim is the horizon, north is at the top. One glance says whether
the pass comes over the roof or along the treeline.
{/* WHERE IT IS: the sky plot and the position, in a column of their
own to the left of the map.
To the LEFT of the map, in a column of its own. It was stacked in
the readout column, where a plot that wants to be square competed
for width with the numbers and pushed the pass table off the
bottom of the screen — while the margin on this side of the map
sat empty the whole time. */}
The plot is the sky seen from underneath — the centre is
straight up, the rim is the horizon, north is at the top — and
one glance says whether the pass comes over the roof or along
the treeline. The numbers below it are the same answer to the
digit: azimuth, elevation, distance, height. They belong
together, and having them apart meant reading a bearing off one
side of the window and finding it on the other.
Both were in the readout column, where a plot that wants to be
square competed for width with everything else and pushed the
pass table off the bottom of the screen — while the margin on
this side of the map sat empty the whole time. */}
{skyShown && (
<div className="shrink-0 flex flex-col gap-1 min-h-0" style={{ width: skyW }}>
<div className="shrink-0 flex flex-col gap-1 min-h-0 overflow-y-auto" style={{ width: skyW }}>
<div className="rounded-lg border border-border bg-card p-2">
<SkyPlot
track={sky}
@@ -900,6 +912,68 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
visible={!!tuning?.visible}
/>
</div>
{/* Where it is, right now. Its elevation goes in the heading: above
or below the horizon is the one thing worth knowing with the
block shut. */}
<Section
title={t('sat.secWhere')}
icon={Crosshair}
open={secOpen.where}
onToggle={() => toggleSec('where')}
right={tuning && !secOpen.where ? (
<Pill tone={tuning.visible ? 'success' : 'muted'}>
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
</Pill>
) : undefined}
>
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</Section>
</div>
)}
{skyShown && (
@@ -1004,68 +1078,6 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)}
</Section>
{/* Where it is, right now. Its elevation goes in the heading: above
or below the horizon is the one thing worth knowing with the
block shut. */}
<Section
title={t('sat.secWhere')}
icon={Crosshair}
open={secOpen.where}
onToggle={() => toggleSec('where')}
right={tuning && !secOpen.where ? (
<Pill tone={tuning.visible ? 'success' : 'muted'}>
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
</Pill>
) : undefined}
>
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</Section>
{/* What to tune. */}
<Section
title={t('sat.secTune')}
+2 -2
View File
@@ -625,7 +625,7 @@ const en: Dict = {
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top',
'sat.showSky': 'Show the sky plot', 'sat.hideSky': 'Hide the sky plot',
'sat.showSky': 'Show the sky and position', 'sat.hideSky': 'Hide the sky and position',
'sec.satellite': 'Satellites',
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
'satset.gridPlaceholder': 'e.g. JN18cx',
@@ -1251,7 +1251,7 @@ const fr: Dict = {
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est lhorizon, le nord en haut',
'sat.showSky': 'Afficher la vue du ciel', 'sat.hideSky': 'Masquer la vue du ciel',
'sat.showSky': 'Afficher le ciel et la position', 'sat.hideSky': 'Masquer le ciel et la position',
'sec.satellite': 'Satellites',
'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.',
'satset.gridPlaceholder': 'ex. JN18cx',
+2
View File
@@ -1154,6 +1154,7 @@ export namespace cat {
split?: boolean;
mode?: string;
band?: string;
rx_bands?: string[];
vfo?: string;
error?: string;
// Go type: time
@@ -1175,6 +1176,7 @@ export namespace cat {
this.split = source["split"];
this.mode = source["mode"];
this.band = source["band"];
this.rx_bands = source["rx_bands"];
this.vfo = source["vfo"];
this.error = source["error"];
this.updated_at = this.convertValues(source["updated_at"], null);