feat(ui): the FT decodes panel, two DXHunter themes, and the maps

Decodes:
- one click SELECTS, two transmit. A single click handed the decode
  straight to WSJT-X as a reply, so brushing a row while reading the
  band started calling a station.
- the list empties for a receiver that changes band, and a receiver
  column appears when more than one is feeding one merged list.
- the period clock turns red while transmitting: it is the one thing on
  the screen that moves, so it is where the eye already is.
- a WL badge, after the LoTW "L" — one letter, always in the same
  place, so the column does not shift from row to row.
- the auto-call switch, its target and its count, and the chase list:
  naming the station you are waiting for is done while watching the
  band, not in a settings tree.

Themes: DXHunter's slate with its own blue, and the same slate with
OpsLog's orange. Counted across its sources rather than guessed from
one panel — blue is 132 uses to violet's 25, and the violet is the PSK
Reporter panel alone.

Watchlist: drawn as DXHunter draws it — the callsign in the interface
font rather than monospaced, which is the difference that shows with
the two windows side by side.

FT Map: arcs no longer run off the side of the map. The map shows one
world, and a path crossing the antimeridian was drawn past 180° into
the blank space beside it — from VK that is most of them.

Cluster: "superfox", "fox/hound" and "F/H" in a comment are read as
FT8. They are WSJT-X's DXpedition transmit modes, and the comment fell
through to the band plan and came out DATA — which then decided the
band+mode verdict.

A decoder is named by what it IS: Nexus sends its packets as "Tempo",
the engine inside it, and OpsLog showed a program nobody has heard of.
This commit is contained in:
2026-09-05 21:46:06 +02:00
parent e4a5d42b85
commit 8dbc4b7e62
7 changed files with 321 additions and 41 deletions
+55 -6
View File
@@ -54,7 +54,7 @@ import {
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
@@ -123,6 +123,7 @@ import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
import { decoderName } from '@/lib/decoderName';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
@@ -2406,6 +2407,17 @@ export default function App() {
// the watchlist panel: a call can be added from the cluster or the
// DXpeditions tab, and the old inline message lived on a page nobody was
// looking at.
// The watch list as patterns, for the decodes badge. Loaded once and kept in
// step with the same event the notice below listens to — a call added from
// the cluster has to light up in the decodes list too.
const [watchPatterns, setWatchPatterns] = useState<string[]>([]);
const loadWatchPatterns = useCallback(() => {
WatchlistEntries()
.then((es: any[]) => setWatchPatterns((es ?? []).map((e: any) => String(e?.callsign ?? '')).filter(Boolean)))
.catch(() => {});
}, []);
useEffect(() => { loadWatchPatterns(); }, [loadWatchPatterns]);
useEffect(() => EventsOn('watchlist:changed', () => loadWatchPatterns()), [loadWatchPatterns]);
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
const wlNoticeTimer = useRef<number | undefined>(undefined);
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
@@ -2597,6 +2609,9 @@ export default function App() {
// Staged like the cluster's, so a period arriving as one burst of fifty
// packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]);
// The band each receiver was last decoding on. A band change empties that
// receiver's list — see the flush below.
const decoderBandRef = useRef<Map<string, string>>(new Map());
const pendingDecodeTimer = useRef<number | undefined>(undefined);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
@@ -3718,9 +3733,42 @@ export default function App() {
});
}
} catch { /* status unresolved — the decode still shows, just unflagged */ }
// A BAND CHANGE empties that receiver's list.
//
// What is on 20 m says nothing about 40 m, and half a screen of stations
// that are no longer reachable is worse than an empty one: the period
// headings still march on, the rows still carry status badges, and the
// operator reads a band they have left. Per receiver — in a split view
// the other one has not moved — and only when the new band is known.
const moved = new Map<string, string>();
for (const d of batch) {
const inst = d.instance ?? '';
const band = (d.band ?? '').toLowerCase();
if (!band) continue;
const was = decoderBandRef.current.get(inst);
decoderBandRef.current.set(inst, band);
if (was && was !== band) moved.set(inst, band);
}
if (moved.size > 0) {
for (const [inst, band] of moved) {
LogUIError('decodes', `${decoderName(inst) || 'decoder'} moved to ${band.toUpperCase()} — its earlier decodes cleared`, '');
}
setTxMsgs((arr) => arr.filter((m) => !moved.has(m.instance ?? '')));
}
setDecodes((arr) => {
const cutoff = Date.now() - DECODE_KEEP_MS;
const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff);
// Rows from a receiver that has just changed band go with it — the
// batch itself is already on the new band.
const kept = moved.size === 0 ? arr : arr.filter((d) => !moved.has(d.instance ?? ''));
// The batch can straddle the change — a 300 ms flush can hold the last
// decodes of the old band and the first of the new. Only the new band
// survives for a receiver that moved.
const fresh = moved.size === 0 ? batch
: batch.filter((d) => {
const b2 = moved.get(d.instance ?? '');
return !b2 || (d.band ?? '').toLowerCase() === b2;
});
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
return next;
});
};
@@ -6385,15 +6433,16 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case.
onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started — and it
// clears the engine's state, so a target it had given up on is not
// still sitting there when the operator switches it back on.
ResetAutoCall().catch(() => {});
// Halt is a verdict on the station being called: it is set aside for
// the session rather than released, or the next period would call it
// straight back — which is exactly what the operator just stopped.
HaltAutoCall().catch(() => {});
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}}
autoCallOn={!!autoCallStatus?.enabled}
onToggleAutoCall={toggleAutoCall}
autoCall={autoCallStatus}
watchlist={watchPatterns}
autoCallOnly={autoCallStatus?.only ?? ''}
onSetAutoCallOnly={(list) => {
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));