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
+84 -11
View File
@@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref';
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
import { decoderName } from '@/lib/decoderName';
export type Decode = {
@@ -125,6 +126,9 @@ interface Props {
// waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void;
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
// so where the operator is reading the band, not only in the watchlist tab.
watchlist?: string[];
}
// The "new" categories, as toggle badges — the same idea and the same colours as
@@ -171,6 +175,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
return out;
}
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
// anything else is the whole callsign — so a decode is judged here exactly as
// the backend judges a spot. Two rules for one list is how a badge and an alert
// start disagreeing about the same station.
function isWatched(call: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0 || !call) return false;
const c = call.toUpperCase();
for (const raw of patterns) {
const p = (raw ?? '').toUpperCase().trim();
if (!p) continue;
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
}
return false;
}
const CAT_KEY = 'opslog.decodeCats';
const SPLIT_KEY = 'opslog.decodeSplit';
const FILTER_KEY = 'opslog.decodeFilters';
@@ -252,9 +271,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
// that is the case it exists for: two decoders on one band send the same
// stations twice, each with its own SNR and DT, and a merged list gave no way
// at all to tell a second receiver from a duplicate.
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
@@ -450,7 +474,7 @@ function renderMsg(msg: string, me: string, calling: string) {
//
// It is the one moving thing on the panel, and it answers the question an
// operator actually has between overs: how long until the next batch.
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
// 100 ms: smooth enough for a bar that fills in three and three quarter
@@ -466,19 +490,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
// The last fifth of a slot is when a decode is imminent and an operator
// deciding whether to answer has run out of time to think.
const closing = left <= trSec / 5;
// TRANSMITTING outranks both. The bar is the one thing on this screen that
// moves continuously, so it is what the eye is already on — and "am I on the
// air" is the state worth reading from across the room. Red, and it stays red
// for the whole over rather than turning amber near the end of it.
const tone = tx ? 'danger' : closing ? 'warning' : '';
return (
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
<Timer className={cn('size-4',
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
closing ? 'bg-warning' : 'bg-primary')}
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</span>
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
tone === 'danger' ? 'text-danger font-semibold'
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
{left.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">
@@ -552,7 +583,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, 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/
@@ -567,8 +598,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
const setColWidth = (key: ColKey, px: number) => {
const col = COLS.find((c) => c.key === key)!;
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
@@ -681,6 +710,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]);
// The receiver column is dead weight with one decoder — which is nearly
// everybody — so it is not there at all until a second one starts feeding.
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
[instances.length, splitByInstance]);
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
// All seven, always, in their usual order.
//
// The chips used to be built from the continents ON the feed, which read as a
@@ -714,6 +749,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
// What this panel is SHOWING, published to the auto-call engine.
//
// The filters are the operator's control over the transmitter as well as
// over the list: a station filtered off the screen is not called. The panel
// sends the callsigns rather than the filter settings, so there is one
// definition of "shown" and not two — the engine cannot disagree with what
// is in front of the operator.
useEffect(() => {
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
SetAutoCallVisible(calls, true).catch(() => {});
}, [filtered]);
// Closed, it publishes nothing: filters that are not on the screen cannot
// silence the engine behind the operator's back.
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
// Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in.
@@ -770,7 +821,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{/* The slot clock. Taken from the newest decode's mode, falling back to
what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} />
{/* Any receiver on the air colours it: with two decoders the shared
txState is whichever reported last, and "somebody here is
transmitting" is what the bar has to say. */}
<PeriodClock trSec={liveTr} mode={liveMode}
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
{bandDrift && (
<span
title={t('dec.bandDriftTip')}
@@ -1082,12 +1137,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}>
{COLS.map((c, i) => (
{cols.map((c, i) => (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < COLS.length - 1 && 'border-r border-border/30',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
@@ -1185,6 +1240,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{hhmmssCompact(d.at)}
</span>
{/* Which receiver heard it — present only while more than one
is feeding a merged list. */}
{cols.some((c) => c.key === 'rx') && (
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
title={d.instance ?? ''}>
{decoderName(d.instance)}
</span>
)}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
@@ -1251,6 +1315,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{e?.lotw && (
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
)}
{/* After the L, which is one letter and always in the same
place: a badge in front of it moved the whole column
sideways from row to row. */}
{isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')}
</span>
)}
{e?.worked_call && (
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
{t('dec.wkd')}