refactor(decodes): read the list the way DXHunter's reads
Compared side by side, DXHunter's decode list is plainly easier to read, and three things account for the gap. The Call column repeated what the message already said. Every FT8 line opens with the callsigns — "CQ A93MO LL56", "PG5FRL JH3CUL PM74" — so a column in front of it printed the same token twice and pushed the line everyone actually reads to the middle of the row. It is gone; the message is the identity, and the tokens worth finding are picked out inside it: green for CQ and for our own call when someone answers, red for the station we are calling. The grid went with it, being the message's last token. Time is on every row now, compact, no separators. A decode belongs to a period and the section heading names it — but once a slot runs past a screenful the heading is somewhere above, and an instant you cannot read where the decode is is not an instant you have. Band and mode became their own columns, band as a chip, and the status flags moved into one column at the right edge with LoTW and a "worked" marker beside them. Rows are tighter: 13 px for the message and the report, 11 px for the figures, 10 px for the badges, and the column rules run through the lot.
This commit is contained in:
@@ -152,13 +152,13 @@ function trSeconds(mode?: string, reported?: number): number {
|
|||||||
// Message is the one elastic column, with a floor so it does not collapse; the
|
// Message is the one elastic column, with a floor so it does not collapse; the
|
||||||
// slack lands there rather than between two fixed columns, which is what read as
|
// slack lands there rather than between two fixed columns, which is what read as
|
||||||
// a hole in the middle of every line.
|
// a hole in the middle of every line.
|
||||||
const ROW = 'grid grid-cols-[3px_120px_58px_54px_62px_64px_minmax(240px,1fr)_222px_160px_34px] items-stretch';
|
const ROW = 'grid grid-cols-[64px_50px_44px_56px_50px_44px_minmax(240px,1fr)_140px_186px] items-stretch';
|
||||||
|
|
||||||
// CELL draws the column rule. items-stretch above plus a right border here is
|
// 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 —
|
// 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.
|
// the thing that turns rows of text into a table you can follow across.
|
||||||
const CELL = 'flex items-center min-w-0 px-3 border-r border-border/40';
|
const CELL = 'flex items-center min-w-0 px-2 border-r border-border/30';
|
||||||
const CELL_LAST = 'flex items-center justify-center min-w-0 px-2';
|
const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
||||||
|
|
||||||
// The "new" badges. Every one of these is a REASON TO CALL, which is why they
|
// 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
|
// get a column of their own rather than a coloured edge: a stripe says something
|
||||||
@@ -219,13 +219,22 @@ function periodLabel(ms: number, trSec: number): string {
|
|||||||
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
|
// 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
|
// the same thing twice. Highlighting the word already in the line keeps the
|
||||||
// scannability and drops the stutter.
|
// scannability and drops the stutter.
|
||||||
function renderMsg(msg: string) {
|
function renderMsg(msg: string, me: string, calling: string) {
|
||||||
const m = /^(CQ(?:\s+DX)?)\s+(.*)$/i.exec(msg);
|
if (!msg) return null;
|
||||||
if (!m) return <span className="text-muted-foreground">{msg}</span>;
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="font-bold text-success">{m[1].toUpperCase()}</span>
|
{parts.map((tok, i) => {
|
||||||
<span className="text-muted-foreground">{' ' + m[2]}</span>
|
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>;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,6 +285,17 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// operator scanning a period wants "workable" to jump out, and -24 dB is not
|
||||||
// three shades worse than -6, it is a different decision.
|
// three shades worse than -6, it is a different decision.
|
||||||
@@ -570,17 +590,16 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
|||||||
|
|
||||||
{/* ── Column header ──────────────────────────────────────────── */}
|
{/* ── Column header ──────────────────────────────────────────── */}
|
||||||
<div className="shrink-0 border-b border-border bg-background">
|
<div className="shrink-0 border-b border-border bg-background">
|
||||||
<div className={cn(ROW, 'h-8 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
||||||
<span />
|
<span className={CELL}>{t('dec.colTime')}</span>
|
||||||
<span className={CELL}>{t('dec.colCall')}</span>
|
|
||||||
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
|
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
|
||||||
<span className={cn(CELL, 'justify-end')} title={t('dec.colDtTitle')}>{t('dec.colDt')}</span>
|
<span className={cn(CELL, 'justify-end')} title={t('dec.colDtTitle')}>{t('dec.colDt')}</span>
|
||||||
<span className={cn(CELL, 'justify-end')} title={t('dec.colFreqTitle')}>{t('dec.colFreq')}</span>
|
<span className={cn(CELL, 'justify-end')} title={t('dec.colFreqTitle')}>{t('dec.colFreq')}</span>
|
||||||
<span className={CELL}>{t('dec.colGrid')}</span>
|
<span className={CELL}>{t('dec.colBand')}</span>
|
||||||
|
<span className={CELL}>{t('dec.colMode')}</span>
|
||||||
<span className={CELL}>{t('dec.colMsg')}</span>
|
<span className={CELL}>{t('dec.colMsg')}</span>
|
||||||
<span className={CELL}>{t('dec.colFlags')}</span>
|
|
||||||
<span className={CELL}>{t('dec.colCountry')}</span>
|
<span className={CELL}>{t('dec.colCountry')}</span>
|
||||||
<span className={CELL_LAST} title="LoTW">L</span>
|
<span className={CELL_LAST}>{t('dec.colStatus')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -647,84 +666,88 @@ export function DecodesPanel({ decodes, txMsgs, txState, spotStatus, onCall, myC
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCall(d)}
|
onClick={() => onCall(d)}
|
||||||
title={t('dec.callTitle', { call: d.call })}
|
title={t('dec.callTitle', { call: d.call })}
|
||||||
className={cn(ROW, 'w-full min-h-[26px] text-left border-b border-border/25 transition-colors',
|
className={cn(ROW, 'w-full text-left border-b border-border/20 transition-colors',
|
||||||
replying ? 'bg-success/20 hover:bg-success/25'
|
replying ? 'bg-success/20 hover:bg-success/25'
|
||||||
: worked ? 'bg-danger/10 hover:bg-danger/15'
|
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||||
: mine ? 'bg-info/10'
|
: mine ? 'bg-info/10'
|
||||||
: 'hover:bg-muted/50')}
|
: 'hover:bg-muted/50')}
|
||||||
>
|
>
|
||||||
{/* A thin accent. Green while someone is answering us, red on
|
{/* Time on EVERY row, compact. A decode belongs to a slot, but
|
||||||
the station we are calling, and otherwise only a hint that
|
it also has an instant, and reading one off a section
|
||||||
the row carries a reason — the reasons themselves are
|
heading three screens up is not reading it. */}
|
||||||
spelled out in the flags column, where they can be read. */}
|
<span className={cn(CELL, 'font-mono text-[11px] tabular-nums',
|
||||||
<span className={cn(
|
replying ? 'text-success' : worked ? 'text-danger' : 'text-muted-foreground/70')}>
|
||||||
replying ? 'bg-success' : worked ? 'bg-danger' : hot ? 'bg-success/60' : 'bg-transparent',
|
{hhmmssCompact(d.at)}
|
||||||
)} />
|
</span>
|
||||||
|
|
||||||
<span className={cn(CELL, 'font-mono text-sm font-bold',
|
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
||||||
worked ? 'text-danger'
|
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||||
: replying ? 'text-success'
|
</span>
|
||||||
: e?.worked_call ? 'text-muted-foreground'
|
|
||||||
: 'text-foreground')}>
|
{/* Past about two seconds a station is drifting out of the
|
||||||
<span className="truncate">{d.call}</span>
|
window, which is worth a tint rather than a shrug. */}
|
||||||
{replying && (
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums',
|
||||||
<span className="ml-1.5 text-[10px] font-bold uppercase tracking-wider text-success shrink-0">
|
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground/70')}>
|
||||||
{t('dec.toYou')}
|
{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>
|
</span>
|
||||||
|
|
||||||
<span className={cn(CELL, 'justify-end font-mono text-sm tabular-nums', snrTone(d.snr))}>
|
<span className={cn(CELL, 'font-mono text-[10px] text-muted-foreground/70')}>
|
||||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
{d.mode ?? ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* DT — how far into the slot the transmission started. Past
|
{/* The message carries the callsign already — which is why
|
||||||
about ±2 s a station is drifting out of the window, so the
|
there is no column repeating it. */}
|
||||||
figure earns a warning tint rather than staying grey. */}
|
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
||||||
<span className={cn(CELL, 'justify-end font-mono text-xs tabular-nums',
|
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
||||||
Math.abs(d.dt ?? 0) > 2 ? 'text-warning' : 'text-muted-foreground')}>
|
|
||||||
{d.dt == null ? '' : d.dt.toFixed(1)}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* The audio offset inside the passband, which is what WSJT-X
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||||
calls Freq — not the RF frequency, which is the same for
|
<span className="truncate">{e?.country ?? ''}</span>
|
||||||
every station in the list and would say nothing. */}
|
|
||||||
<span className={cn(CELL, 'justify-end font-mono text-xs text-muted-foreground tabular-nums')}>
|
|
||||||
{d.audio_hz ?? ''}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={cn(CELL, 'font-mono text-xs text-muted-foreground')}>
|
{/* Status: everything worth acting on, in one place at the
|
||||||
{d.grid ?? ''}
|
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>
|
</span>
|
||||||
|
)}
|
||||||
<span className={cn(CELL, 'font-mono text-sm')}>
|
|
||||||
<span className="truncate">{renderMsg(d.msg ?? '')}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Flags — the column this panel exists for. */}
|
|
||||||
<span className={cn(CELL, 'gap-1 overflow-hidden')}>
|
|
||||||
{entity && (
|
{entity && (
|
||||||
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0', entity.cls)}>
|
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
|
||||||
{t(entity.label)}
|
{t(entity.label)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{extras.map((b) => (
|
{extras.map((b) => (
|
||||||
<span key={b.key as string}
|
<span key={b.key as string}
|
||||||
className="text-[11px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border bg-transparent shrink-0"
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||||
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
|
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
|
||||||
{t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<span className={cn(CELL, 'text-xs text-muted-foreground')}>
|
|
||||||
<span className="truncate">{e?.country ?? ''}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className={CELL_LAST}>
|
|
||||||
{e?.lotw && (
|
|
||||||
<span className="text-[11px] font-bold px-1.5 py-0.5 rounded bg-info-muted text-info-muted-foreground"
|
|
||||||
title="LoTW">L</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const en: Dict = {
|
|||||||
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
|
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
|
||||||
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
|
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
|
||||||
'dec.txUnknown': 'this application does not report its transmit text',
|
'dec.txUnknown': 'this application does not report its transmit text',
|
||||||
'dec.colCall': 'Call', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Grid', 'dec.colMsg': 'Message', 'dec.colFlags': 'New', 'dec.colCountry': 'Country',
|
'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.wkd': 'Wkd',
|
||||||
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
|
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
|
||||||
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
|
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
|
||||||
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
|
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
|
||||||
@@ -609,7 +609,7 @@ const fr: Dict = {
|
|||||||
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
|
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
|
||||||
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
|
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
|
||||||
'dec.txUnknown': 'ce logiciel ne communique pas son texte d emission',
|
'dec.txUnknown': 'ce logiciel ne communique pas son texte d emission',
|
||||||
'dec.colCall': 'Indicatif', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Locator', 'dec.colMsg': 'Message', 'dec.colFlags': 'Nouveau', 'dec.colCountry': 'Pays',
|
'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.wkd': 'Fait',
|
||||||
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
|
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
|
||||||
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
|
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
|
||||||
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",
|
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",
|
||||||
|
|||||||
Reference in New Issue
Block a user