refactor(decodes): column rules, left-aligned grid, no CQ stutter
Three things from reading it on a real screen. Column rules. The grid alone was not enough to follow a line across: cells now carry a right border and the row stretches, so the rules run unbroken from the header to the bottom of the list. That is what turns rows of text into a table. Left-aligned. The previous pass centred the grid inside a maximum width, which on a wide screen opened a dead margin down the left before the first callsign - trading the hole in the middle for a bigger one at the edge. Now it fills the width and the slack lands in the message column, which is the one that can use it and the one bounded by rules on both sides, so it reads as a cell rather than a gap. "CQ CQ PE1NAO JO32" - a green CQ badge in front of a message whose own first word is CQ. The badge is gone; the word already in the line is picked out instead, which scans the same and stutters not at all.
This commit is contained in:
@@ -71,12 +71,20 @@ interface Props {
|
|||||||
const DEFAULT_TR = 15;
|
const DEFAULT_TR = 15;
|
||||||
|
|
||||||
// ROW is the column template, shared by the header and every row so the two can
|
// ROW is the column template, shared by the header and every row so the two can
|
||||||
// never drift. Fixed widths for the short fields, one flexible column for the
|
// never drift. Full width and left-aligned — an earlier pass centred it inside a
|
||||||
// message — and a hard cap on the whole grid, because on a 2500 px screen a
|
// maximum width, which on a wide screen opened a huge dead margin down the left
|
||||||
// free-flowing row puts the country a foot away from the callsign it belongs to
|
// before the first callsign.
|
||||||
// and the eye has to travel the gap on every line.
|
//
|
||||||
const ROW = 'grid grid-cols-[3px_112px_56px_60px_1fr_260px_170px_28px] gap-x-3 items-center';
|
// Message is the one elastic column, with a floor so it does not collapse; the
|
||||||
const ROW_MAX = 'max-w-[1500px]';
|
// slack lands there rather than between two fixed columns, which is what read as
|
||||||
|
// a hole in the middle of every line.
|
||||||
|
const ROW = 'grid grid-cols-[3px_120px_64px_68px_minmax(280px,1fr)_230px_180px_36px] 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-3 border-r border-border/40';
|
||||||
|
const CELL_LAST = 'flex items-center justify-center min-w-0 px-2';
|
||||||
|
|
||||||
// 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
|
||||||
@@ -122,6 +130,23 @@ function periodStart(at: string, tr: number): number {
|
|||||||
const hhmmss = (epochSec: number) =>
|
const hhmmss = (epochSec: number) =>
|
||||||
new Date(epochSec * 1000).toISOString().slice(11, 19);
|
new Date(epochSec * 1000).toISOString().slice(11, 19);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const m = /^(CQ(?:\s+DX)?)\s+(.*)$/i.exec(msg);
|
||||||
|
if (!m) return <span className="text-muted-foreground">{msg}</span>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="font-bold text-success">{m[1].toUpperCase()}</span>
|
||||||
|
<span className="text-muted-foreground">{' ' + m[2]}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -308,16 +333,16 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Column header ──────────────────────────────────────────── */}
|
{/* ── Column header ──────────────────────────────────────────── */}
|
||||||
<div className="px-4 shrink-0 border-b border-border bg-background">
|
<div className="shrink-0 border-b border-border bg-background">
|
||||||
<div className={cn(ROW, ROW_MAX, 'mx-auto py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
<div className={cn(ROW, 'h-8 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground')}>
|
||||||
<span />
|
<span />
|
||||||
<span>{t('dec.colCall')}</span>
|
<span className={CELL}>{t('dec.colCall')}</span>
|
||||||
<span className="text-right">{t('dec.colSnr')}</span>
|
<span className={cn(CELL, 'justify-end')}>{t('dec.colSnr')}</span>
|
||||||
<span>{t('dec.colGrid')}</span>
|
<span className={CELL}>{t('dec.colGrid')}</span>
|
||||||
<span>{t('dec.colMsg')}</span>
|
<span className={CELL}>{t('dec.colMsg')}</span>
|
||||||
<span>{t('dec.colFlags')}</span>
|
<span className={CELL}>{t('dec.colFlags')}</span>
|
||||||
<span>{t('dec.colCountry')}</span>
|
<span className={CELL}>{t('dec.colCountry')}</span>
|
||||||
<span className="text-center" title="LoTW">L</span>
|
<span className={CELL_LAST} title="LoTW">L</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -334,8 +359,8 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|||||||
{groups.map((g, gi) => (
|
{groups.map((g, gi) => (
|
||||||
<section key={g.start}>
|
<section key={g.start}>
|
||||||
{/* Period header — sticky, so the slot being read is always named. */}
|
{/* Period header — sticky, so the slot being read is always named. */}
|
||||||
<header className="sticky top-0 z-10 px-4 bg-muted/95 backdrop-blur border-y border-border/60">
|
<header className="sticky top-0 z-10 px-3 bg-muted/95 backdrop-blur border-y border-border/60">
|
||||||
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5')}>
|
<div className="flex items-center gap-2.5 py-1.5">
|
||||||
<span className={cn('font-mono text-sm font-bold tabular-nums',
|
<span className={cn('font-mono text-sm font-bold tabular-nums',
|
||||||
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
||||||
{hhmmss(g.start)}
|
{hhmmss(g.start)}
|
||||||
@@ -355,8 +380,8 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|||||||
{/* The operator's own transmission, at the top of its slot: it is what
|
{/* The operator's own transmission, at the top of its slot: it is what
|
||||||
the stations below were answering (or ignoring). */}
|
the stations below were answering (or ignoring). */}
|
||||||
{g.tx.map((m, i) => (
|
{g.tx.map((m, i) => (
|
||||||
<div key={`tx-${i}`} className="px-4 bg-primary/10">
|
<div key={`tx-${i}`}
|
||||||
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5 border-l-2 border-primary pl-2')}>
|
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" />
|
<ArrowUpRight className="size-4 text-primary shrink-0" />
|
||||||
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
|
<span className="text-[11px] font-bold uppercase tracking-wider text-primary shrink-0">
|
||||||
{t('dec.tx')}
|
{t('dec.tx')}
|
||||||
@@ -364,7 +389,6 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|||||||
<span className="font-mono text-sm text-foreground truncate">{m.msg}</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>}
|
{m.band && <span className="ml-auto text-xs text-muted-foreground shrink-0">{m.band}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{g.decodes.map((d, i) => {
|
{g.decodes.map((d, i) => {
|
||||||
@@ -375,39 +399,38 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|||||||
const mine = myCall && d.call === myCall.toUpperCase();
|
const mine = myCall && d.call === myCall.toUpperCase();
|
||||||
const hot = !!entity || extras.length > 0;
|
const hot = !!entity || extras.length > 0;
|
||||||
return (
|
return (
|
||||||
<div key={`${d.call}-${d.freq_hz}-${i}`}
|
|
||||||
className={cn('px-4 transition-colors', mine ? 'bg-info/10' : 'hover:bg-muted/50')}>
|
|
||||||
<button
|
<button
|
||||||
|
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||||
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, ROW_MAX, 'mx-auto w-full py-1.5 text-left')}
|
className={cn(ROW, 'w-full min-h-[26px] text-left border-b border-border/25 transition-colors',
|
||||||
|
mine ? 'bg-info/10' : 'hover:bg-muted/50')}
|
||||||
>
|
>
|
||||||
{/* A thin accent, kept only as a hint that the row carries a
|
{/* A thin accent, kept only as a hint that the row carries a
|
||||||
reason — the reasons themselves are spelled out in the
|
reason — the reasons themselves are spelled out in the
|
||||||
flags column, where they can be read. */}
|
flags column, where they can be read. */}
|
||||||
<span className={cn('h-5 rounded-full', hot ? 'bg-success' : 'bg-transparent')} />
|
<span className={hot ? 'bg-success' : 'bg-transparent'} />
|
||||||
|
|
||||||
<span className={cn('font-mono text-sm font-bold truncate',
|
<span className={cn(CELL, 'font-mono text-sm font-bold',
|
||||||
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
|
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
|
||||||
{d.call}
|
<span className="truncate">{d.call}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={cn('font-mono text-sm text-right tabular-nums', snrTone(d.snr))}>
|
<span className={cn(CELL, 'justify-end font-mono text-sm tabular-nums', snrTone(d.snr))}>
|
||||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className={cn(CELL, 'font-mono text-xs text-muted-foreground')}>
|
||||||
{d.grid ?? ''}
|
{d.grid ?? ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="font-mono text-sm truncate min-w-0">
|
<span className={cn(CELL, 'font-mono text-sm')}>
|
||||||
{d.cq && <span className="font-bold text-success mr-1.5">CQ</span>}
|
<span className="truncate">{renderMsg(d.msg ?? '')}</span>
|
||||||
<span className="text-muted-foreground">{d.msg}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Flags — the column this panel exists for. */}
|
{/* Flags — the column this panel exists for. */}
|
||||||
<span className="flex items-center gap-1 overflow-hidden">
|
<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('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0', entity.cls)}>
|
||||||
{t(entity.label)}
|
{t(entity.label)}
|
||||||
@@ -422,18 +445,17 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className={cn(CELL, 'text-xs text-muted-foreground')}>
|
||||||
{e?.country ?? ''}
|
<span className="truncate">{e?.country ?? ''}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-center">
|
<span className={CELL_LAST}>
|
||||||
{e?.lotw && (
|
{e?.lotw && (
|
||||||
<span className="text-[11px] font-bold px-1.5 py-0.5 rounded bg-info-muted text-info-muted-foreground"
|
<span className="text-[11px] font-bold px-1.5 py-0.5 rounded bg-info-muted text-info-muted-foreground"
|
||||||
title="LoTW">L</span>
|
title="LoTW">L</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user