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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user