1107 lines
53 KiB
TypeScript
1107 lines
53 KiB
TypeScript
// DecodesPanel — every FTx decode from the inbound UDP feed, grouped by T/R period.
|
|
//
|
|
// The point of this panel, and what makes it different from the cluster list, is
|
|
// the PERIOD. FT8 is a sequence of fifteen-second slots, and an operator reads a
|
|
// band by watching them go by: who called CQ this period, who answered, what I
|
|
// was sending while they did. A flat list sorted by time loses exactly that —
|
|
// the slot boundaries are where the information is.
|
|
//
|
|
// So the list is grouped, one section per period, newest first, with the
|
|
// operator's own transmission shown INSIDE the period it went out in.
|
|
//
|
|
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
|
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
|
// both panels rather than being judged twice by two rules.
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
|
import { writeUiPref } from '@/lib/uiPref';
|
|
|
|
export type Decode = {
|
|
call: string;
|
|
grid?: string;
|
|
snr: number;
|
|
freq_hz: number;
|
|
dial_hz?: number;
|
|
band?: string;
|
|
mode?: string;
|
|
msg?: string;
|
|
cq?: boolean;
|
|
at: string;
|
|
tr_period?: number;
|
|
off_air?: boolean;
|
|
source?: string;
|
|
instance?: string;
|
|
dt?: number;
|
|
audio_hz?: number;
|
|
// Replayed verbatim when answering the station — see AnswerDecode.
|
|
ms?: number;
|
|
low_conf?: boolean;
|
|
// The mode field as the Decode carried it (the "~"/"+" marker). `mode` above
|
|
// is the resolved name, for display and for the log; a Reply has to send this
|
|
// one back or the receiving application finds no matching decode.
|
|
mode_raw?: string;
|
|
// The message as sent, untrimmed — replayed in a Reply for the same reason.
|
|
msg_raw?: string;
|
|
};
|
|
|
|
export type TxMsg = {
|
|
msg: string;
|
|
de_call?: string;
|
|
dx_call?: string;
|
|
mode?: string;
|
|
band?: string;
|
|
freq_hz?: number;
|
|
instance?: string;
|
|
transmitting?: boolean;
|
|
// The sender's Enable Tx toggle. The watchdog clears it while leaving the DX
|
|
// call set — the only sign an exchange was abandoned rather than paused.
|
|
tx_enabled?: boolean;
|
|
at: string;
|
|
};
|
|
|
|
type StatusEntry = {
|
|
status?: string;
|
|
country?: string;
|
|
continent?: string;
|
|
worked_call?: boolean;
|
|
worked_slot?: boolean;
|
|
new_county?: boolean;
|
|
new_pota?: boolean;
|
|
new_pfx?: boolean;
|
|
new_grid?: boolean;
|
|
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
|
grid_state?: string;
|
|
grid?: string;
|
|
lotw?: boolean;
|
|
};
|
|
|
|
interface Props {
|
|
decodes: Decode[];
|
|
txMsgs: TxMsg[];
|
|
// txState is the LIVE transmit state — what is going out right now and to
|
|
// whom. Separate from txMsgs, which is the history threaded into the periods.
|
|
txState?: TxMsg | null;
|
|
// The same, keyed by instance. In a split view the shared txState is whichever
|
|
// receiver reported last, which is a coin toss — each pane needs its own.
|
|
txStates?: Record<string, TxMsg>;
|
|
spotStatus: Record<string, StatusEntry>;
|
|
onCall: (d: Decode) => void;
|
|
myCall?: string;
|
|
// Drop every decode and transmit message held for this panel. The list is a
|
|
// live view, not data — clearing it costs nothing but the seconds until the
|
|
// next period lands.
|
|
// instance = clear just that receiver's decodes; empty = all of them. A split
|
|
// view needs the narrow form: clearing both because you wanted one is the same
|
|
// loss of context the split exists to prevent.
|
|
onClear?: (instance?: string) => void;
|
|
// Stop the decoding application transmitting, mid-over. Routed to one
|
|
// instance by program id — see HaltDecodeTx.
|
|
onHalt?: (instance: string) => void;
|
|
// Auto-call on/off, mirrored here as well as in Preferences: it is the one
|
|
// setting an operator wants to reach WITHOUT leaving the band — turning the
|
|
// machine off is not something to go hunting through a settings tree for.
|
|
autoCallOn?: boolean;
|
|
onToggleAutoCall?: () => void;
|
|
}
|
|
|
|
// The "new" categories, as toggle badges — the same idea and the same colours as
|
|
// the Chase New panel, so an operator who has learned one has learned both.
|
|
//
|
|
// All off means no filtering at all: this is a decode LOG first, and a panel
|
|
// that starts by hiding most of the band would be lying about what is on it.
|
|
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
|
|
|
|
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
|
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
|
{ key: 'band', label: 'dec.stBand', colour: 'var(--warning)' },
|
|
{ key: 'mode', label: 'dec.stMode', colour: 'var(--info)' },
|
|
{ key: 'slot', label: 'dec.stSlot', colour: 'var(--caution)' },
|
|
{ key: 'pota', label: 'dec.bgPota', colour: markerColour('new_pota') },
|
|
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
|
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
|
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
|
];
|
|
|
|
// catsOf lists everything a decode is new for. A station can be several at once
|
|
// — a new entity that is also a new park — so this is a set, not a verdict.
|
|
function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
|
const out = new Set<NewCat>();
|
|
if (!e) return out;
|
|
switch (e.status) {
|
|
case 'new': out.add('dxcc'); break;
|
|
case 'new-band': out.add('band'); break;
|
|
case 'new-mode': out.add('mode'); break;
|
|
case 'new-slot': out.add('slot'); break;
|
|
}
|
|
if (e.new_pota) out.add('pota');
|
|
if (e.new_grid) out.add('grid');
|
|
if (e.new_pfx) out.add('pfx');
|
|
if (e.new_county) out.add('cty');
|
|
return out;
|
|
}
|
|
|
|
const CAT_KEY = 'opslog.decodeCats';
|
|
const SPLIT_KEY = 'opslog.decodeSplit';
|
|
const FILTER_KEY = 'opslog.decodeFilters';
|
|
|
|
// usePersisted is useState that survives the panel being unmounted.
|
|
//
|
|
// All the filters share ONE stored object rather than a key each: they are read
|
|
// and written together, and a dozen localStorage entries for one filter bar is a
|
|
// dozen things to keep in step when one of them is renamed.
|
|
function readFilters(): Record<string, unknown> {
|
|
try {
|
|
const v = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
|
|
return v && typeof v === 'object' ? v : {};
|
|
} catch { return {}; }
|
|
}
|
|
function usePersisted<T>(key: string, fallback: T): [T, (v: T) => void] {
|
|
const [v, setV] = useState<T>(() => {
|
|
const stored = readFilters()[key];
|
|
return (typeof stored === typeof fallback ? stored as T : fallback);
|
|
});
|
|
const set = (next: T) => {
|
|
setV(next);
|
|
try {
|
|
localStorage.setItem(FILTER_KEY, JSON.stringify({ ...readFilters(), [key]: next }));
|
|
} catch { /* quota / private mode — the filter still works this session */ }
|
|
};
|
|
return [v, set];
|
|
}
|
|
|
|
// DEFAULT_TR is the slot length assumed when nothing better is known. Fifteen
|
|
// seconds is FT8, the overwhelming majority of what arrives here; a wrong guess
|
|
// only mis-groups, it never loses a decode.
|
|
const DEFAULT_TR = 15;
|
|
|
|
// MODE_TR is the authority on slot length, ahead of what the sender reports.
|
|
//
|
|
// Status carries the T/R period as a whole number of seconds, so FT4 arrives as
|
|
// 7 or 8 depending on which way the sender rounded — and a period that is out by
|
|
// half a second walks across the real boundary until decodes land in the wrong
|
|
// slot entirely. The mode name gives the exact figure, and the halving sequence
|
|
// is the whole family: 15, 7.5, 3.75.
|
|
const MODE_TR: Record<string, number> = {
|
|
FT8: 15,
|
|
FT4: 7.5,
|
|
FT2: 3.75,
|
|
JT65: 60,
|
|
JT9: 60,
|
|
JS8: 15,
|
|
};
|
|
|
|
// trSeconds picks the slot length for a decode: the mode's own figure when we
|
|
// know it, the sender's rounded one otherwise, and FT8 as the last resort.
|
|
function trSeconds(mode?: string, reported?: number): number {
|
|
const m = MODE_TR[(mode ?? '').toUpperCase()];
|
|
if (m) return m;
|
|
if (reported && reported > 0) return reported;
|
|
return DEFAULT_TR;
|
|
}
|
|
|
|
// ROW is the grid shell, shared by the header and every row so the two can
|
|
// never drift. The column TEMPLATE is no longer baked in: every column has a
|
|
// width the operator can drag, so the table takes exactly the room it needs and
|
|
// no more.
|
|
//
|
|
// Message used to be an elastic 1fr column. On a wide screen that handed it
|
|
// every spare pixel — a message column hundreds of pixels wider than its longest
|
|
// line, with Country and Status marooned against the far edge. Fixed widths
|
|
// throughout mean the slack stays OUTSIDE the table, which is where a second
|
|
// panel can go.
|
|
const ROW = 'grid items-stretch';
|
|
|
|
// CELL draws the column rule. items-stretch above plus a right border here is
|
|
// what makes the lines run unbroken from the header to the bottom of the list —
|
|
// the thing that turns rows of text into a table you can follow across.
|
|
const CELL = 'flex items-center min-w-0 px-2 border-r border-border/30';
|
|
const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
|
|
|
// ── Columns ──────────────────────────────────────────────────────────────────
|
|
// 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' | 'country' | 'status';
|
|
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
|
{ key: 'time', tkey: 'dec.colTime', def: 64, 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 },
|
|
{ key: 'band', tkey: 'dec.colBand', def: 50, min: 36 },
|
|
{ key: 'mode', tkey: 'dec.colMode', def: 44, min: 36 },
|
|
// 300 px holds the longest real FT8 line ("<3B9/SQ9UM> LZ3BS KN22") with room
|
|
// to spare; anything past that was the dead space this replaces.
|
|
{ key: 'msg', tkey: 'dec.colMsg', def: 300, min: 120 },
|
|
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
|
// an operator chasing squares could see the verdict but never the square.
|
|
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
|
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
|
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
|
];
|
|
const COL_MAX = 600;
|
|
const COLW_KEY = 'opslog.decodeColWidths';
|
|
|
|
type ColWidths = Record<ColKey, number>;
|
|
const defaultWidths = (): ColWidths =>
|
|
Object.fromEntries(COLS.map((c) => [c.key, c.def])) as ColWidths;
|
|
|
|
// Stored widths are merged ONTO the defaults rather than used as-is: a saved set
|
|
// from before a column existed would otherwise leave that column at zero width.
|
|
function loadWidths(): ColWidths {
|
|
const out = defaultWidths();
|
|
try {
|
|
const raw = localStorage.getItem(COLW_KEY);
|
|
if (!raw) return out;
|
|
const saved = JSON.parse(raw) as Partial<Record<ColKey, unknown>>;
|
|
for (const c of COLS) {
|
|
const v = saved[c.key];
|
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
out[c.key] = Math.min(COL_MAX, Math.max(c.min, Math.round(v)));
|
|
}
|
|
}
|
|
} catch { /* corrupt value — the defaults are a fine answer */ }
|
|
return out;
|
|
}
|
|
|
|
// The drag handle sitting on a column's right edge. Pointer capture, so a fast
|
|
// drag that outruns the cursor still tracks instead of dropping the column.
|
|
// Double-click restores that one column's default width.
|
|
function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onReset: () => void }) {
|
|
return (
|
|
<span
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
onDoubleClick={(e) => { e.stopPropagation(); onReset(); }}
|
|
onPointerDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const startX = e.clientX;
|
|
const el = e.currentTarget;
|
|
el.setPointerCapture(e.pointerId);
|
|
const move = (ev: PointerEvent) => onResize(ev.clientX - startX);
|
|
const up = () => {
|
|
el.removeEventListener('pointermove', move);
|
|
el.removeEventListener('pointerup', up);
|
|
};
|
|
el.addEventListener('pointermove', move);
|
|
el.addEventListener('pointerup', up);
|
|
}}
|
|
// Wider than it looks: a 1px target is unusable, so the hit area is 7px
|
|
// with only the hairline painted on hover.
|
|
className="absolute top-0 right-0 z-10 h-full w-[7px] translate-x-[3px] cursor-col-resize
|
|
before:absolute before:inset-y-0 before:left-1/2 before:w-px before:bg-transparent
|
|
hover:before:bg-primary"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// The "new" badges. Every one of these is a REASON TO CALL, which is why they
|
|
// get a column of their own rather than a coloured edge: a stripe says something
|
|
// is special, a badge says what, and the operator is deciding whether to break
|
|
// off what they are doing.
|
|
//
|
|
// Colours match the cluster list and the band map — the same fact must not be
|
|
// amber in one panel and green in the next.
|
|
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
|
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
|
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
|
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
|
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
|
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
|
};
|
|
|
|
// The orthogonal ones: a station already worked for its entity can still be a
|
|
// new grid, a new prefix or a park never logged.
|
|
//
|
|
// The colour comes from markerColour, the table the cluster list and the band
|
|
// map read — so a new park is the same green in all three. Applied inline
|
|
// because those are categorical --chart-* variables, which the theme exposes as
|
|
// CSS custom properties and not as Tailwind colour utilities; every other place
|
|
// that paints with them does the same.
|
|
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
|
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
|
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
|
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
|
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
|
];
|
|
|
|
// periodStartMs floors an instant to its slot, in MILLISECONDS.
|
|
//
|
|
// Milliseconds, not seconds, because FT4's slot is seven and a half of them and
|
|
// FT2's three and three quarters: flooring to whole seconds put two different
|
|
// FT4 periods in one bucket and split others down the middle.
|
|
//
|
|
// The instant is the decode's OWN timestamp, never its arrival: a period's
|
|
// decodes reach us in one burst a second or two after the slot closes, so
|
|
// arrival time would pile a whole period into the next one.
|
|
function periodStartMs(atMs: number, trSec: number): number {
|
|
const p = Math.max(0.5, trSec) * 1000;
|
|
return Math.floor(atMs / p) * p;
|
|
}
|
|
|
|
// periodLabel names a slot. Sub-second slots get a decimal, or two FT4 periods
|
|
// inside the same second would print the same heading twice.
|
|
function periodLabel(ms: number, trSec: number): string {
|
|
const base = new Date(ms).toISOString().slice(11, 19);
|
|
if (Number.isInteger(trSec)) return base;
|
|
const tenths = Math.round((ms % 1000) / 100);
|
|
return tenths ? `${base}.${tenths}` : base;
|
|
}
|
|
|
|
// renderMsg prints the decoded line with its leading CQ picked out.
|
|
//
|
|
// There used to be a separate green "CQ" badge in front of the message, which
|
|
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
|
|
// the same thing twice. Highlighting the word already in the line keeps the
|
|
// scannability and drops the stutter.
|
|
function renderMsg(msg: string, me: string, calling: string) {
|
|
if (!msg) return null;
|
|
// Split on whitespace and colour the tokens that matter, rather than the
|
|
// whole line: an operator scanning a slot is looking for their own call in
|
|
// the first position (someone answering) and for the station being called.
|
|
const parts = msg.split(/(s+)/);
|
|
return (
|
|
<>
|
|
{parts.map((tok, i) => {
|
|
if (/^s+$/.test(tok)) return tok;
|
|
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
|
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
|
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
|
if (calling && bare === calling) return <span key={i} className="font-bold text-danger">{tok}</span>;
|
|
return <span key={i} className="text-foreground/85">{tok}</span>;
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// PeriodClock shows where the current T/R slot is, against the UTC clock.
|
|
//
|
|
// Slots are anchored to UTC, not to when OpsLog started or when the last decode
|
|
// landed, so this is computed from the wall clock and nothing else — which also
|
|
// means it keeps running when the band is dead and there is nothing to group.
|
|
//
|
|
// 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 }) {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
|
// seconds at the fastest, cheap enough to leave running.
|
|
const id = window.setInterval(() => setNow(Date.now()), 100);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
const p = Math.max(0.5, trSec) * 1000;
|
|
const into = now % p;
|
|
const left = (p - into) / 1000;
|
|
const pct = (into / p) * 100;
|
|
// 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;
|
|
|
|
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')} />
|
|
<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')}
|
|
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')}>
|
|
{left.toFixed(1)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{mode ? `${mode} ${trSec}s` : `${trSec}s`}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// hhmmssCompact is the per-row time, HHMMSS with no separators.
|
|
//
|
|
// Every row carries it. A decode belongs to a period, and the section heading
|
|
// names that period — but once a slot runs past a screenful, the heading is
|
|
// somewhere above and the instant is no longer readable where the decode is.
|
|
function hhmmssCompact(at: string): string {
|
|
const ms = Date.parse(at);
|
|
if (!Number.isFinite(ms)) return '';
|
|
return new Date(ms).toISOString().slice(11, 19).replace(/:/g, '');
|
|
}
|
|
|
|
// snrTone colours the report by readability rather than as a gradient: an
|
|
// operator scanning a period wants "workable" to jump out, and -24 dB is not
|
|
// three shades worse than -6, it is a different decision.
|
|
function snrTone(snr: number): string {
|
|
if (snr >= -5) return 'text-success';
|
|
if (snr >= -15) return 'text-foreground';
|
|
return 'text-muted-foreground/70';
|
|
}
|
|
|
|
// buildPeriods cuts a decode list into T/R periods. A plain function, not a
|
|
// memo, because it is run ONCE PER PANE: with two receivers on two bands the
|
|
// panel shows two independent period lists side by side, and each has to be cut
|
|
// with its own slot boundaries.
|
|
function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|
const by = new Map<number, { decodes: Decode[]; tx: TxMsg[] }>();
|
|
for (const d of filtered) {
|
|
const at = Date.parse(d.at);
|
|
if (!Number.isFinite(at)) continue;
|
|
const k = periodStartMs(at, trSeconds(d.mode, d.tr_period));
|
|
let g = by.get(k);
|
|
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
|
|
g.decodes.push(d);
|
|
}
|
|
for (const m of txMsgs) {
|
|
const at = Date.parse(m.at);
|
|
if (!Number.isFinite(at)) continue;
|
|
const k = periodStartMs(at, trSeconds(m.mode, undefined));
|
|
// A transmit slot CREATES its period when there is none.
|
|
//
|
|
// This is the whole alternation, and getting it wrong hid the feature
|
|
// completely: FT8 transmits and receives in opposite slots, so the period
|
|
// you were sending in is exactly the one with no decodes in it. Dropping
|
|
// the message when its period was empty meant it never appeared at all.
|
|
let g = by.get(k);
|
|
if (!g) { g = { decodes: [], tx: [] }; by.set(k, g); }
|
|
g.tx.push(m);
|
|
}
|
|
return [...by.entries()]
|
|
.sort((a, b) => b[0] - a[0])
|
|
.map(([start, g]) => ({
|
|
start,
|
|
// The slot length this period was cut with, so its heading is labelled
|
|
// the same way it was grouped.
|
|
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
|
tx: g.tx,
|
|
// Strongest first inside a period: the eye should land on what is
|
|
// workable, and time within a slot means nothing — they were all
|
|
// transmitting simultaneously.
|
|
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
|
}));
|
|
}
|
|
|
|
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: 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/
|
|
// like every other portable preference.
|
|
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
|
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)));
|
|
setColw((prev) => {
|
|
if (prev[key] === w) return prev;
|
|
const next = { ...prev, [key]: w };
|
|
writeUiPref(COLW_KEY, JSON.stringify(next));
|
|
return next;
|
|
});
|
|
};
|
|
// Split the table into one column per receiver. Off by default: it only means
|
|
// anything with two instances running, and a single-receiver operator should
|
|
// not have to turn something off to get the layout they had.
|
|
const [splitByInstance, setSplitByInstance] = useState(() => localStorage.getItem(SPLIT_KEY) === '1');
|
|
// Every filter below is persisted, NOT just the NEW badges. The panel is
|
|
// remounted on a tab change, so anything held only in React state comes back
|
|
// cleared — which left the badges lit and the rest silently reset, a list that
|
|
// looks filtered and is not.
|
|
const [cqOnly, setCqOnly] = usePersisted('cqOnly', false);
|
|
const [lotwOnly, setLotwOnly] = usePersisted('lotwOnly', false);
|
|
const [cats, setCats] = useState<Set<NewCat>>(() => {
|
|
try {
|
|
const raw = JSON.parse(localStorage.getItem(CAT_KEY) || '[]');
|
|
return new Set(Array.isArray(raw) ? raw : []);
|
|
} catch { return new Set(); }
|
|
});
|
|
const toggleCat = (k: NewCat) => setCats((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(k)) next.delete(k); else next.add(k);
|
|
try { localStorage.setItem(CAT_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ }
|
|
return next;
|
|
});
|
|
const [bandSel, setBandSel] = usePersisted('bandSel', '');
|
|
const [modeSel, setModeSel] = usePersisted('modeSel', '');
|
|
const [contSel, setContSel] = usePersisted('contSel', '');
|
|
const [minSnr, setMinSnr] = usePersisted('minSnr', '');
|
|
const [search, setSearch] = usePersisted('search', '');
|
|
|
|
const statusOf = (d: Decode): StatusEntry | undefined =>
|
|
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
|
|
|
// The mode currently on the air, for the slot clock. The newest decode knows
|
|
// best; between overs the transmit state still does.
|
|
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
|
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
|
|
|
// Who I am calling, and what an answer to me looks like.
|
|
//
|
|
// These are the two lines on the screen that are not about the band but about
|
|
// the QSO in progress, and they are what an operator is actually watching for
|
|
// — the rest is context. A reply is addressed to us by name: the decoded line
|
|
// opens with our callsign, sometimes bracketed when the sender compressed a
|
|
// non-standard call.
|
|
const me = (myCall ?? '').toUpperCase();
|
|
const calling = (txState?.dx_call ?? '').toUpperCase();
|
|
const answersMe = (msg?: string): boolean => {
|
|
if (!me || !msg) return false;
|
|
const first = msg.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase();
|
|
return !!first && first === me;
|
|
};
|
|
|
|
// The choices are built from what is actually on the feed, and a selector with
|
|
// nothing to choose is HIDDEN. One MSHV is one band and one mode, so those two
|
|
// dropdowns were pure furniture for most operators; they appear the day a
|
|
// second instance puts a second band on the link, which is the only day they
|
|
// mean anything.
|
|
const { bands, modes, conts, instances } = useMemo(() => {
|
|
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
|
|
for (const d of decodes) {
|
|
if (d.band) b.add(d.band);
|
|
if (d.mode) m.add(d.mode);
|
|
if (d.instance) i.add(d.instance);
|
|
const ct = statusOf(d)?.continent;
|
|
if (ct) c.add(ct);
|
|
}
|
|
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [decodes, spotStatus]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toUpperCase();
|
|
const floor = minSnr.trim() === '' ? null : parseInt(minSnr, 10);
|
|
return decodes.filter((d) => {
|
|
if (cqOnly && !d.cq) return false;
|
|
if (bandSel && d.band !== bandSel) return false;
|
|
if (modeSel && d.mode !== modeSel) return false;
|
|
if (floor != null && Number.isFinite(floor) && d.snr < floor) return false;
|
|
const e = statusOf(d);
|
|
if (lotwOnly && !e?.lotw) return false;
|
|
// Any badge lit narrows the list to the things it names; none lit shows
|
|
// the band as it is.
|
|
if (cats.size > 0) {
|
|
const have = catsOf(e);
|
|
let hit = false;
|
|
for (const c of cats) if (have.has(c)) { hit = true; break; }
|
|
if (!hit) return false;
|
|
}
|
|
if (contSel && e?.continent !== contSel) return false;
|
|
if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false;
|
|
return true;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
|
|
|
|
// Group into periods, newest first, and drop the operator's transmissions into
|
|
// the slot they went out in.
|
|
// One PANE per receiver when the split is on. Two receivers on two bands is
|
|
// the day this panel earns its keep, and merging their decodes into one list
|
|
// throws away the only thing that tells them apart.
|
|
//
|
|
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
|
|
// the transmit messages belong to one receiver and not the other.
|
|
const panes = useMemo(() => {
|
|
if (!splitByInstance || instances.length < 2) {
|
|
return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }];
|
|
}
|
|
return instances.map((inst) => ({
|
|
key: inst,
|
|
label: inst,
|
|
tx: txStates?.[inst],
|
|
periods: buildPeriods(
|
|
filtered.filter((d) => (d.instance ?? '') === inst),
|
|
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
|
),
|
|
}));
|
|
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
|
|
|
|
|
|
const resetFilters = () => {
|
|
setCqOnly(false); setLotwOnly(false); setBandSel('');
|
|
setModeSel(''); setContSel(''); setMinSnr(''); setSearch('');
|
|
setCats(new Set());
|
|
try { localStorage.setItem(CAT_KEY, '[]'); } catch { /* not worth failing over */ }
|
|
};
|
|
const anyFilter = cqOnly || lotwOnly || cats.size > 0 || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
|
|
|
|
const sel = 'h-8 rounded-lg border border-border bg-background px-2 text-sm';
|
|
const chip = (on: boolean, tone = 'primary') => cn(
|
|
'h-8 px-3 rounded-full border text-sm font-medium transition-colors',
|
|
on
|
|
? tone === 'success'
|
|
? 'border-success bg-success text-success-foreground'
|
|
: 'border-primary bg-primary text-primary-foreground'
|
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
{/* ── Filter bar ─────────────────────────────────────────────── */}
|
|
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-border bg-muted/30 shrink-0">
|
|
<Radio className="size-4 text-primary shrink-0" />
|
|
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mr-1">
|
|
{t('dec.title')}
|
|
</span>
|
|
|
|
{/* 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} />
|
|
<span className="w-px h-5 bg-border/60 mx-1" />
|
|
|
|
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
|
{t('dec.cqOnly')}
|
|
</button>
|
|
<button type="button" className={chip(lotwOnly)} onClick={() => setLotwOnly(!lotwOnly)}>
|
|
{t('dec.lotwOnly')}
|
|
</button>
|
|
|
|
{/* Per-category badges, in the colours of the flags they select — the
|
|
same vocabulary as the Chase New panel. */}
|
|
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
|
{NEW_CATS.map((c) => {
|
|
const on = cats.has(c.key);
|
|
return (
|
|
<button
|
|
key={c.key}
|
|
type="button"
|
|
onClick={() => toggleCat(c.key)}
|
|
className={cn(
|
|
'rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide transition-all',
|
|
on ? 'border-transparent' : 'border-border text-muted-foreground opacity-50 hover:opacity-100',
|
|
)}
|
|
style={on ? { color: c.colour, borderColor: c.colour } : undefined}
|
|
>
|
|
{t(c.label)}
|
|
</button>
|
|
);
|
|
})}
|
|
</span>
|
|
|
|
{/* Only when there is a choice to make — see the memo above. */}
|
|
{bands.length > 1 && (
|
|
<select className={sel} value={bandSel} onChange={(e) => setBandSel(e.target.value)}>
|
|
<option value="">{t('dec.allBands')}</option>
|
|
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
|
</select>
|
|
)}
|
|
{modes.length > 1 && (
|
|
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
|
|
<option value="">{t('dec.allModes')}</option>
|
|
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
</select>
|
|
)}
|
|
{conts.length > 1 && (
|
|
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
|
|
<option value="">{t('dec.allConts')}</option>
|
|
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
)}
|
|
|
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Signal className="size-4" />
|
|
<input
|
|
type="number" placeholder="dB" value={minSnr}
|
|
onChange={(e) => setMinSnr(e.target.value)}
|
|
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-sm tabular-nums"
|
|
title={t('dec.minSnrTitle')}
|
|
/>
|
|
</label>
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
value={search} onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('dec.searchPh')}
|
|
className="h-8 w-56 rounded-lg border border-border bg-background pl-8 pr-2 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{anyFilter && (
|
|
<button type="button" onClick={resetFilters}
|
|
className="h-8 px-2.5 rounded-lg text-sm text-muted-foreground hover:bg-muted hover:text-foreground inline-flex items-center gap-1">
|
|
<X className="size-3.5" /> {t('dec.clearFilters')}
|
|
</button>
|
|
)}
|
|
|
|
<span className="flex-1" />
|
|
{/* Which receivers are feeding this. Only with more than one — with a
|
|
single MSHV it is a label stating the obvious. */}
|
|
{/* Only with two receivers running: with one it would be a control that
|
|
does nothing, which is worse than no control. */}
|
|
{instances.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSplitByInstance((v) => { try { localStorage.setItem(SPLIT_KEY, v ? '0' : '1'); } catch { /* quota */ } return !v; })}
|
|
title={t('dec.splitTip')}
|
|
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
|
splitByInstance
|
|
? 'border-primary bg-primary text-primary-foreground'
|
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
|
>
|
|
<Columns2 className="size-3.5" /> {t('dec.split')}
|
|
</button>
|
|
)}
|
|
{instances.length > 1 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('dec.instances', { n: instances.length })}
|
|
</span>
|
|
)}
|
|
<span className="text-sm text-muted-foreground tabular-nums">
|
|
{t('dec.count', { shown: filtered.length, total: decodes.length })}
|
|
</span>
|
|
|
|
{/* Halt and Clear sit together at the right, away from the filters: one
|
|
talks to the radio, the other only to this list, and neither is a
|
|
filter. Halt goes LAST — it is the one that must be findable without
|
|
reading, and the end of the row is the one position that never moves
|
|
as filters come and go. */}
|
|
{onToggleAutoCall && (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleAutoCall}
|
|
title={t('dec.autoCallTip')}
|
|
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
|
// Lit when it is armed, because what matters at a glance is not
|
|
// "where is the switch" but "is this thing about to transmit".
|
|
autoCallOn
|
|
? 'border-warning bg-warning text-warning-foreground'
|
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
|
>
|
|
<Bot className="size-3.5" /> {t('dec.autoCall')}
|
|
</button>
|
|
)}
|
|
{onHalt && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onHalt(txState?.instance ?? '')}
|
|
title={t('dec.haltTitle')}
|
|
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
|
// Hot while the carrier is actually up: that is when the button is
|
|
// urgent, and an always-red control stops being read as a warning.
|
|
txState?.transmitting
|
|
? 'border-danger bg-danger text-danger-foreground hover:brightness-110'
|
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
|
>
|
|
<Ban className="size-3.5" /> {t('dec.halt')}
|
|
</button>
|
|
)}
|
|
{onClear && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onClear()}
|
|
disabled={decodes.length === 0 && txMsgs.length === 0}
|
|
title={t('dec.clearAllTitle')}
|
|
className="h-8 px-2.5 rounded-lg text-sm text-muted-foreground border border-border
|
|
hover:bg-muted hover:text-foreground inline-flex items-center gap-1.5
|
|
disabled:opacity-40 disabled:pointer-events-none"
|
|
>
|
|
<Trash2 className="size-3.5" /> {t('dec.clearAll')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── What I am sending, and to whom ─────────────────────────── */}
|
|
{/*
|
|
Its own strip rather than a line in the list: it is the one thing on this
|
|
screen that is about the operator and not about the band, and while a
|
|
period scrolls away this stays put. It appears as soon as a Status
|
|
arrives, so it says who is being called even before anything is sent.
|
|
*/}
|
|
{txState && (txState.msg || txState.dx_call) && (
|
|
<div className={cn('flex items-center gap-3 px-3 py-2 shrink-0 border-b',
|
|
txState.transmitting ? 'bg-primary/15 border-primary/40' : 'bg-muted/40 border-border')}>
|
|
<span className={cn('inline-flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider shrink-0',
|
|
txState.transmitting ? 'text-primary' : 'text-muted-foreground')}>
|
|
{txState.transmitting && <span className="size-2 rounded-full bg-primary animate-pulse" />}
|
|
{txState.transmitting ? t('dec.txNow') : t('dec.txIdle')}
|
|
</span>
|
|
{/* An EMPTY tx_message is the ordinary state between overs — WSJT-X,
|
|
JTDX and MSHV all send Status about once a second with nothing in
|
|
that field when nothing is queued. Reading it as "this application
|
|
does not report its transmit text" accused the sender of a
|
|
shortcoming on the strength of one empty string, and said so for
|
|
most of every cycle. Only a sender that is KEYED and still tells us
|
|
nothing has actually withheld anything. */}
|
|
{txState.msg
|
|
? <span className="font-mono text-base font-semibold text-foreground truncate">{txState.msg}</span>
|
|
: <span className="text-sm text-muted-foreground italic">
|
|
{txState.transmitting ? t('dec.txUnknown') : t('dec.txNothing')}
|
|
</span>}
|
|
{txState.dx_call && (
|
|
<span className="flex items-center gap-1.5 shrink-0">
|
|
<span className="text-xs text-muted-foreground uppercase tracking-wider">{t('dec.working')}</span>
|
|
<span className="font-mono text-base font-bold text-warning">{txState.dx_call}</span>
|
|
</span>
|
|
)}
|
|
<span className="flex-1" />
|
|
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
|
{txState.instance && instances.length > 1 && (
|
|
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* One table per receiver when the split is on, side by side. Each pane
|
|
carries its OWN column header: they are independent tables at their own
|
|
widths, and one shared header row would line up with none of them. */}
|
|
<div className="flex-1 min-h-0 flex gap-px bg-border/40">
|
|
{panes.map((pane) => (
|
|
<div key={pane.key}
|
|
className={cn('flex-1 min-w-0 flex flex-col min-h-0 bg-card',
|
|
pane.tx?.transmitting && 'ring-2 ring-primary ring-inset')}>
|
|
{pane.label && (
|
|
<div className={cn('shrink-0 flex items-center gap-1.5 px-2 py-1 border-b border-border',
|
|
pane.tx?.transmitting ? 'bg-primary/15 border-primary/40' : 'bg-muted/40')}>
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">
|
|
{pane.label}
|
|
</span>
|
|
{/* This pane's OWN transmit line. The shared strip shows whichever
|
|
instance reported last, which in a split view is a coin toss. */}
|
|
{pane.tx?.msg && (
|
|
<span className={cn('font-mono text-[11px] truncate',
|
|
pane.tx.transmitting ? 'text-primary font-semibold' : 'text-muted-foreground')}>
|
|
{pane.tx.transmitting && (
|
|
<span className="inline-flex items-center gap-1 mr-1.5 font-sans font-bold uppercase tracking-wider">
|
|
<span className="inline-block size-1.5 rounded-full bg-primary animate-pulse" />
|
|
{t('dec.txNow')}
|
|
</span>
|
|
)}
|
|
{pane.tx.msg}
|
|
</span>
|
|
)}
|
|
<span className="flex-1" />
|
|
{onHalt && (
|
|
<button type="button"
|
|
onClick={() => onHalt(pane.key)}
|
|
title={t('dec.haltOne', { n: pane.label })}
|
|
className={cn('h-6 px-1.5 rounded text-[11px] inline-flex items-center gap-1 border',
|
|
pane.tx?.transmitting
|
|
? 'border-danger bg-danger text-danger-foreground'
|
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}>
|
|
<Ban className="size-3" /> {t('dec.halt')}
|
|
</button>
|
|
)}
|
|
{onClear && (
|
|
<button type="button"
|
|
onClick={() => onClear(pane.key)}
|
|
title={t('dec.clearOne', { n: pane.label })}
|
|
className="h-6 px-1.5 rounded text-[11px] inline-flex items-center gap-1 border border-border text-muted-foreground hover:bg-muted hover:text-foreground">
|
|
<Trash2 className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* ── Column header ──────────────────────────────────────────── */}
|
|
<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) => (
|
|
<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',
|
|
// The three numeric columns label their own right edge, where the
|
|
// figures are.
|
|
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
|
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
|
<span className="truncate">{t(c.tkey)}</span>
|
|
<ColResizer
|
|
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
|
onReset={() => setColWidth(c.key, c.def)}
|
|
/>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Periods ────────────────────────────────────────────────── */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
|
{pane.periods.length === 0 && (
|
|
<div className="h-full flex items-center justify-center px-6 text-center">
|
|
<p className="text-sm text-muted-foreground max-w-md">
|
|
{decodes.length === 0 ? t('dec.empty') : t('dec.emptyFiltered')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{pane.periods.map((g, gi) => (
|
|
<section key={g.start}>
|
|
{/* Period header — sticky, so the slot being read is always named. */}
|
|
<header className="sticky top-0 z-10 px-3 bg-muted/95 backdrop-blur border-y border-border/60">
|
|
<div className="flex items-center gap-2.5 py-1.5">
|
|
<span className={cn('font-mono text-sm font-bold tabular-nums',
|
|
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
|
{periodLabel(g.start, g.tr)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('dec.periodCount', { n: g.decodes.length })}
|
|
</span>
|
|
{gi === 0 && (
|
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-success">
|
|
<span className="size-1.5 rounded-full bg-success animate-pulse" />
|
|
{t('dec.live')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* The operator's own transmission, at the top of its slot: it is what
|
|
the stations below were answering (or ignoring). */}
|
|
{g.tx.map((m, i) => (
|
|
<div key={`tx-${i}`}
|
|
className="flex items-center gap-2.5 py-1.5 pl-2 pr-3 bg-primary/10 border-l-2 border-primary">
|
|
<ArrowUpRight className="size-4 text-primary shrink-0" />
|
|
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
|
|
{t('dec.tx')}
|
|
</span>
|
|
<span className="font-mono text-sm text-foreground truncate">{m.msg}</span>
|
|
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
|
|
</div>
|
|
))}
|
|
|
|
{g.decodes.map((d, i) => {
|
|
const e = statusOf(d);
|
|
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
|
const entity = st ? ENTITY_BADGE[st] : undefined;
|
|
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
|
const mine = !!me && d.call === me;
|
|
const hot = !!entity || extras.length > 0;
|
|
// Someone answering us outranks everything else on the screen.
|
|
const replying = answersMe(d.msg);
|
|
// The station we are calling, so it can be picked out of a slot
|
|
// holding thirty others.
|
|
const worked = !!calling && d.call === calling;
|
|
return (
|
|
<button
|
|
key={`${d.call}-${d.freq_hz}-${i}`}
|
|
type="button"
|
|
onClick={() => onCall(d)}
|
|
title={t('dec.callTitle', { call: d.call })}
|
|
style={{ gridTemplateColumns: template, width: tableW }}
|
|
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
|
replying ? 'bg-success/20 hover:bg-success/25'
|
|
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
|
: mine ? 'bg-info/10'
|
|
: 'hover:bg-muted/50')}
|
|
>
|
|
{/* Time on EVERY row, compact. A decode belongs to a slot, but
|
|
it also has an instant, and reading one off a section
|
|
heading three screens up is not reading it. */}
|
|
<span className={cn(CELL, 'font-mono text-[11px] tabular-nums',
|
|
replying ? 'text-success' : worked ? 'text-danger' : 'text-muted-foreground/70')}>
|
|
{hhmmssCompact(d.at)}
|
|
</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>
|
|
|
|
{/* Past about two seconds a station is drifting out of the
|
|
window, which is worth a tint rather than a shrug. */}
|
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums',
|
|
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground/70')}>
|
|
{d.dt == null ? '' : d.dt.toFixed(1)}
|
|
</span>
|
|
|
|
{/* The audio offset in the passband — what WSJT-X calls Freq.
|
|
Not the RF frequency, which is the same for every station
|
|
here and would say nothing. */}
|
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] text-muted-foreground/70 tabular-nums')}>
|
|
{d.audio_hz ?? ''}
|
|
</span>
|
|
|
|
<span className={CELL}>
|
|
{d.band && (
|
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground">
|
|
{d.band}
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
<span className={cn(CELL, 'font-mono text-[10px] text-muted-foreground/70')}>
|
|
{d.mode ?? ''}
|
|
</span>
|
|
|
|
{/* The message carries the callsign already — which is why
|
|
there is no column repeating it. */}
|
|
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
|
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
|
</span>
|
|
|
|
{/* The decode's own grid first, the remembered one as the
|
|
fallback: this period's message is more current than
|
|
whatever was cached from an earlier one. Dimmed when it is
|
|
the remembered value, so "heard it now" and "known from
|
|
before" are not read as the same thing. */}
|
|
<span className={cn(CELL, 'font-mono text-[11px]',
|
|
d.grid ? 'text-foreground/85' : 'text-muted-foreground/70')}>
|
|
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
|
</span>
|
|
|
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
|
<span className="truncate">{e?.country ?? ''}</span>
|
|
</span>
|
|
|
|
{/* Status: everything worth acting on, in one place at the
|
|
right edge, shortest first so the eye can scan the column
|
|
rather than read it. */}
|
|
<span className={CELL_LAST}>
|
|
{e?.lotw && (
|
|
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</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')}
|
|
</span>
|
|
)}
|
|
{entity && (
|
|
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
|
|
{t(entity.label)}
|
|
</span>
|
|
)}
|
|
{extras.map((b) => {
|
|
// A square WORKED but not yet confirmed is a different job
|
|
// from one never worked: a QSL to chase, not a QSO to make.
|
|
// Same hue, held back — the badge reads as "less" of the
|
|
// same thing, and its label says which. One badge for both
|
|
// is what made a station worked an hour ago read as new.
|
|
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
|
|
const c = markerColour(b.marker);
|
|
return (
|
|
<span key={b.key as string}
|
|
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
|
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
|
style={unconf
|
|
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
|
: { borderColor: c, color: c }}>
|
|
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
|
</span>
|
|
);
|
|
})}
|
|
{replying && (
|
|
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-success text-success-foreground shrink-0">
|
|
{t('dec.toYou')}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</section>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|