|
|
@@ -16,7 +16,7 @@ import { useMemo, useState } from 'react';
|
|
|
|
import { Radio, Search, X, Signal, ArrowUpRight } from 'lucide-react';
|
|
|
|
import { Radio, Search, X, Signal, ArrowUpRight } from 'lucide-react';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
import { activeMarkers, markerColour } from '@/lib/spotMarkers';
|
|
|
|
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
|
|
|
|
|
|
|
|
|
|
|
export type Decode = {
|
|
|
|
export type Decode = {
|
|
|
|
call: string;
|
|
|
|
call: string;
|
|
|
@@ -32,6 +32,7 @@ export type Decode = {
|
|
|
|
tr_period?: number;
|
|
|
|
tr_period?: number;
|
|
|
|
off_air?: boolean;
|
|
|
|
off_air?: boolean;
|
|
|
|
source?: string;
|
|
|
|
source?: string;
|
|
|
|
|
|
|
|
instance?: string;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type TxMsg = {
|
|
|
|
export type TxMsg = {
|
|
|
@@ -69,23 +70,43 @@ interface Props {
|
|
|
|
// arrives here; a wrong guess only mis-groups, it never loses a decode.
|
|
|
|
// arrives here; a wrong guess only mis-groups, it never loses a decode.
|
|
|
|
const DEFAULT_TR = 15;
|
|
|
|
const DEFAULT_TR = 15;
|
|
|
|
|
|
|
|
|
|
|
|
// Status → the pill's look. The vocabulary is the cluster's, deliberately: the
|
|
|
|
// ROW is the column template, shared by the header and every row so the two can
|
|
|
|
// same fact must not be amber in one panel and green in the next.
|
|
|
|
// never drift. Fixed widths for the short fields, one flexible column for the
|
|
|
|
const STATUS_STYLE: Record<string, string> = {
|
|
|
|
// message — and a hard cap on the whole grid, because on a 2500 px screen a
|
|
|
|
'new': 'bg-success text-success-foreground',
|
|
|
|
// free-flowing row puts the country a foot away from the callsign it belongs to
|
|
|
|
'new-band': 'bg-warning text-warning-foreground',
|
|
|
|
// and the eye has to travel the gap on every line.
|
|
|
|
'new-mode': 'bg-info text-info-foreground',
|
|
|
|
const ROW = 'grid grid-cols-[3px_112px_56px_60px_1fr_260px_170px_28px] gap-x-3 items-center';
|
|
|
|
'new-slot': 'bg-caution text-caution-foreground',
|
|
|
|
const ROW_MAX = 'max-w-[1500px]';
|
|
|
|
'new-call': 'bg-muted text-muted-foreground',
|
|
|
|
|
|
|
|
|
|
|
|
// 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' },
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
|
|
// The orthogonal ones: a station already worked for its entity can still be a
|
|
|
|
'new': 'dec.stNew',
|
|
|
|
// new grid, a new prefix or a park never logged.
|
|
|
|
'new-band': 'dec.stBand',
|
|
|
|
//
|
|
|
|
'new-mode': 'dec.stMode',
|
|
|
|
// The colour comes from markerColour, the table the cluster list and the band
|
|
|
|
'new-slot': 'dec.stSlot',
|
|
|
|
// map read — so a new park is the same green in all three. Applied inline
|
|
|
|
'new-call': 'dec.stCall',
|
|
|
|
// 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' },
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// periodStart floors a decode's own timestamp to its slot. Its OWN timestamp,
|
|
|
|
// periodStart floors a decode's own timestamp to its slot. Its OWN timestamp,
|
|
|
|
// not arrival: a period's decodes reach us in one burst a second or two after
|
|
|
|
// not arrival: a period's decodes reach us in one burst a second or two after
|
|
|
@@ -101,19 +122,20 @@ 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);
|
|
|
|
|
|
|
|
|
|
|
|
// snrTone colours the report by readability rather than by 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.
|
|
|
|
function snrTone(snr: number): string {
|
|
|
|
function snrTone(snr: number): string {
|
|
|
|
if (snr >= -5) return 'text-success';
|
|
|
|
if (snr >= -5) return 'text-success';
|
|
|
|
if (snr >= -15) return 'text-foreground';
|
|
|
|
if (snr >= -15) return 'text-foreground';
|
|
|
|
return 'text-muted-foreground';
|
|
|
|
return 'text-muted-foreground/70';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) {
|
|
|
|
export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) {
|
|
|
|
const { t } = useI18n();
|
|
|
|
const { t } = useI18n();
|
|
|
|
const [cqOnly, setCqOnly] = useState(false);
|
|
|
|
const [cqOnly, setCqOnly] = useState(false);
|
|
|
|
const [newOnly, setNewOnly] = useState(false);
|
|
|
|
const [newOnly, setNewOnly] = useState(false);
|
|
|
|
|
|
|
|
const [lotwOnly, setLotwOnly] = useState(false);
|
|
|
|
const [bandSel, setBandSel] = useState('');
|
|
|
|
const [bandSel, setBandSel] = useState('');
|
|
|
|
const [modeSel, setModeSel] = useState('');
|
|
|
|
const [modeSel, setModeSel] = useState('');
|
|
|
|
const [contSel, setContSel] = useState('');
|
|
|
|
const [contSel, setContSel] = useState('');
|
|
|
@@ -123,18 +145,21 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|
|
|
const statusOf = (d: Decode): StatusEntry | undefined =>
|
|
|
|
const statusOf = (d: Decode): StatusEntry | undefined =>
|
|
|
|
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
|
|
|
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
|
|
|
|
|
|
|
|
|
|
|
// The band / mode / continent choices are built from what is actually on the
|
|
|
|
// The choices are built from what is actually on the feed, and a selector with
|
|
|
|
// feed, not from a fixed list: a selector offering 160 m to an operator whose
|
|
|
|
// nothing to choose is HIDDEN. One MSHV is one band and one mode, so those two
|
|
|
|
// receivers are all on 6 m is noise.
|
|
|
|
// dropdowns were pure furniture for most operators; they appear the day a
|
|
|
|
const { bands, modes, conts } = useMemo(() => {
|
|
|
|
// second instance puts a second band on the link, which is the only day they
|
|
|
|
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>();
|
|
|
|
// 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) {
|
|
|
|
for (const d of decodes) {
|
|
|
|
if (d.band) b.add(d.band);
|
|
|
|
if (d.band) b.add(d.band);
|
|
|
|
if (d.mode) m.add(d.mode);
|
|
|
|
if (d.mode) m.add(d.mode);
|
|
|
|
|
|
|
|
if (d.instance) i.add(d.instance);
|
|
|
|
const ct = statusOf(d)?.continent;
|
|
|
|
const ct = statusOf(d)?.continent;
|
|
|
|
if (ct) c.add(ct);
|
|
|
|
if (ct) c.add(ct);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort() };
|
|
|
|
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
}, [decodes, spotStatus]);
|
|
|
|
}, [decodes, spotStatus]);
|
|
|
|
|
|
|
|
|
|
|
@@ -150,13 +175,14 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|
|
|
if (modeSel && d.mode !== modeSel) return false;
|
|
|
|
if (modeSel && d.mode !== modeSel) return false;
|
|
|
|
if (floor != null && Number.isFinite(floor) && d.snr < floor) return false;
|
|
|
|
if (floor != null && Number.isFinite(floor) && d.snr < floor) return false;
|
|
|
|
const e = statusOf(d);
|
|
|
|
const e = statusOf(d);
|
|
|
|
|
|
|
|
if (lotwOnly && !e?.lotw) return false;
|
|
|
|
if (newOnly && !isNewSomething(e)) return false;
|
|
|
|
if (newOnly && !isNewSomething(e)) return false;
|
|
|
|
if (contSel && e?.continent !== contSel) 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;
|
|
|
|
if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false;
|
|
|
|
return true;
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
}, [decodes, spotStatus, cqOnly, newOnly, bandSel, modeSel, contSel, minSnr, search]);
|
|
|
|
}, [decodes, spotStatus, cqOnly, newOnly, lotwOnly, bandSel, modeSel, contSel, minSnr, search]);
|
|
|
|
|
|
|
|
|
|
|
|
// Group into periods, newest first, and drop the operator's transmissions into
|
|
|
|
// Group into periods, newest first, and drop the operator's transmissions into
|
|
|
|
// the slot they went out in.
|
|
|
|
// the slot they went out in.
|
|
|
@@ -188,78 +214,113 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|
|
|
}, [filtered, txMsgs]);
|
|
|
|
}, [filtered, txMsgs]);
|
|
|
|
|
|
|
|
|
|
|
|
const resetFilters = () => {
|
|
|
|
const resetFilters = () => {
|
|
|
|
setCqOnly(false); setNewOnly(false); setBandSel(''); setModeSel('');
|
|
|
|
setCqOnly(false); setNewOnly(false); setLotwOnly(false); setBandSel('');
|
|
|
|
setContSel(''); setMinSnr(''); setSearch('');
|
|
|
|
setModeSel(''); setContSel(''); setMinSnr(''); setSearch('');
|
|
|
|
};
|
|
|
|
};
|
|
|
|
const anyFilter = cqOnly || newOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
|
|
|
|
const anyFilter = cqOnly || newOnly || lotwOnly || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
|
|
|
|
|
|
|
|
|
|
|
|
const sel = 'h-7 rounded-md border border-border bg-background px-2 text-xs';
|
|
|
|
const sel = 'h-8 rounded-lg border border-border bg-background px-2 text-sm';
|
|
|
|
const chip = (on: boolean) => cn(
|
|
|
|
const chip = (on: boolean, tone = 'primary') => cn(
|
|
|
|
'h-7 px-2.5 rounded-full border text-xs font-medium transition-colors',
|
|
|
|
'h-8 px-3 rounded-full border text-sm font-medium transition-colors',
|
|
|
|
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted',
|
|
|
|
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 (
|
|
|
|
return (
|
|
|
|
<div className="flex flex-col h-full min-h-0">
|
|
|
|
<div className="flex flex-col h-full min-h-0">
|
|
|
|
{/* ── Filter bar ─────────────────────────────────────────────── */}
|
|
|
|
{/* ── Filter bar ─────────────────────────────────────────────── */}
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5 px-3 py-2 border-b border-border bg-muted/30 shrink-0">
|
|
|
|
<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" />
|
|
|
|
<Radio className="size-4 text-primary shrink-0" />
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mr-1">
|
|
|
|
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mr-1">
|
|
|
|
{t('dec.title')}
|
|
|
|
{t('dec.title')}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
<button type="button" className={chip(cqOnly)} onClick={() => setCqOnly((v) => !v)}>
|
|
|
|
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly((v) => !v)}>
|
|
|
|
{t('dec.cqOnly')}
|
|
|
|
{t('dec.cqOnly')}
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
<button type="button" className={chip(newOnly)} onClick={() => setNewOnly((v) => !v)}>
|
|
|
|
<button type="button" className={chip(newOnly)} onClick={() => setNewOnly((v) => !v)}>
|
|
|
|
{t('dec.newOnly')}
|
|
|
|
{t('dec.newOnly')}
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button type="button" className={chip(lotwOnly)} onClick={() => setLotwOnly((v) => !v)}>
|
|
|
|
|
|
|
|
{t('dec.lotwOnly')}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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)}>
|
|
|
|
<select className={sel} value={bandSel} onChange={(e) => setBandSel(e.target.value)}>
|
|
|
|
<option value="">{t('dec.allBands')}</option>
|
|
|
|
<option value="">{t('dec.allBands')}</option>
|
|
|
|
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
|
|
|
{bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
|
|
|
</select>
|
|
|
|
</select>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
{modes.length > 1 && (
|
|
|
|
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
|
|
|
|
<select className={sel} value={modeSel} onChange={(e) => setModeSel(e.target.value)}>
|
|
|
|
<option value="">{t('dec.allModes')}</option>
|
|
|
|
<option value="">{t('dec.allModes')}</option>
|
|
|
|
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
|
|
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
|
|
</select>
|
|
|
|
</select>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
{conts.length > 1 && (
|
|
|
|
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
|
|
|
|
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
|
|
|
|
<option value="">{t('dec.allConts')}</option>
|
|
|
|
<option value="">{t('dec.allConts')}</option>
|
|
|
|
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
|
|
|
|
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
|
|
|
|
</select>
|
|
|
|
</select>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
|
|
<Signal className="size-3.5" />
|
|
|
|
<Signal className="size-4" />
|
|
|
|
<input
|
|
|
|
<input
|
|
|
|
type="number" placeholder="dB" value={minSnr}
|
|
|
|
type="number" placeholder="dB" value={minSnr}
|
|
|
|
onChange={(e) => setMinSnr(e.target.value)}
|
|
|
|
onChange={(e) => setMinSnr(e.target.value)}
|
|
|
|
className="h-7 w-16 rounded-md border border-border bg-background px-1.5 text-xs tabular-nums"
|
|
|
|
className="h-8 w-20 rounded-lg border border-border bg-background px-2 text-sm tabular-nums"
|
|
|
|
title={t('dec.minSnrTitle')}
|
|
|
|
title={t('dec.minSnrTitle')}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</label>
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="relative">
|
|
|
|
<div className="relative">
|
|
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
|
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
|
|
|
<input
|
|
|
|
<input
|
|
|
|
value={search} onChange={(e) => setSearch(e.target.value)}
|
|
|
|
value={search} onChange={(e) => setSearch(e.target.value)}
|
|
|
|
placeholder={t('dec.searchPh')}
|
|
|
|
placeholder={t('dec.searchPh')}
|
|
|
|
className="h-7 w-44 rounded-md border border-border bg-background pl-7 pr-2 text-xs"
|
|
|
|
className="h-8 w-56 rounded-lg border border-border bg-background pl-8 pr-2 text-sm"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{anyFilter && (
|
|
|
|
{anyFilter && (
|
|
|
|
<button type="button" onClick={resetFilters}
|
|
|
|
<button type="button" onClick={resetFilters}
|
|
|
|
className="h-7 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted inline-flex items-center gap-1">
|
|
|
|
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" /> {t('dec.clearFilters')}
|
|
|
|
<X className="size-3.5" /> {t('dec.clearFilters')}
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<span className="flex-1" />
|
|
|
|
<span className="flex-1" />
|
|
|
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
|
|
|
{/* Which receivers are feeding this. Only with more than one — with a
|
|
|
|
|
|
|
|
single MSHV it is a label stating the obvious. */}
|
|
|
|
|
|
|
|
{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 })}
|
|
|
|
{t('dec.count', { shown: filtered.length, total: decodes.length })}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Column header ──────────────────────────────────────────── */}
|
|
|
|
|
|
|
|
<div className="px-4 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')}>
|
|
|
|
|
|
|
|
<span />
|
|
|
|
|
|
|
|
<span>{t('dec.colCall')}</span>
|
|
|
|
|
|
|
|
<span className="text-right">{t('dec.colSnr')}</span>
|
|
|
|
|
|
|
|
<span>{t('dec.colGrid')}</span>
|
|
|
|
|
|
|
|
<span>{t('dec.colMsg')}</span>
|
|
|
|
|
|
|
|
<span>{t('dec.colFlags')}</span>
|
|
|
|
|
|
|
|
<span>{t('dec.colCountry')}</span>
|
|
|
|
|
|
|
|
<span className="text-center" title="LoTW">L</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Periods ────────────────────────────────────────────────── */}
|
|
|
|
{/* ── Periods ────────────────────────────────────────────────── */}
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
|
|
{groups.length === 0 && (
|
|
|
|
{groups.length === 0 && (
|
|
|
@@ -271,105 +332,108 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{groups.map((g, gi) => (
|
|
|
|
{groups.map((g, gi) => (
|
|
|
|
<section key={g.start} className="border-b border-border/60">
|
|
|
|
<section key={g.start}>
|
|
|
|
{/* Period header — sticky so the slot you are reading is always named. */}
|
|
|
|
{/* Period header — sticky, so the slot being read is always named. */}
|
|
|
|
<header className={cn(
|
|
|
|
<header className="sticky top-0 z-10 px-4 bg-muted/95 backdrop-blur border-y border-border/60">
|
|
|
|
'sticky top-0 z-10 flex items-center gap-2 px-3 py-1 backdrop-blur',
|
|
|
|
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5')}>
|
|
|
|
'bg-muted/80 border-b border-border/60',
|
|
|
|
<span className={cn('font-mono text-sm font-bold tabular-nums',
|
|
|
|
)}>
|
|
|
|
|
|
|
|
<span className={cn('font-mono text-xs font-bold tabular-nums',
|
|
|
|
|
|
|
|
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
|
|
|
gi === 0 ? 'text-primary' : 'text-foreground')}>
|
|
|
|
{hhmmss(g.start)}
|
|
|
|
{hhmmss(g.start)}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
<span className="text-[11px] text-muted-foreground">
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
{t('dec.periodCount', { n: g.decodes.length })}
|
|
|
|
{t('dec.periodCount', { n: g.decodes.length })}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
{gi === 0 && (
|
|
|
|
{gi === 0 && (
|
|
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-success">
|
|
|
|
<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" />
|
|
|
|
<span className="size-1.5 rounded-full bg-success animate-pulse" />
|
|
|
|
{t('dec.live')}
|
|
|
|
{t('dec.live')}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</header>
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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}`}
|
|
|
|
<div key={`tx-${i}`} className="px-4 bg-primary/10">
|
|
|
|
className="flex items-center gap-2 px-3 py-1 bg-primary/10 border-l-2 border-primary">
|
|
|
|
<div className={cn(ROW_MAX, 'mx-auto flex items-center gap-2.5 py-1.5 border-l-2 border-primary pl-2')}>
|
|
|
|
<ArrowUpRight className="size-3.5 text-primary shrink-0" />
|
|
|
|
<ArrowUpRight className="size-4 text-primary shrink-0" />
|
|
|
|
<span className="text-[10px] 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')}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
<span className="font-mono text-xs 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-[10px] 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) => {
|
|
|
|
const e = statusOf(d);
|
|
|
|
const e = statusOf(d);
|
|
|
|
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
|
|
|
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
|
|
|
const markers = activeMarkers(e as any);
|
|
|
|
const entity = st ? ENTITY_BADGE[st] : undefined;
|
|
|
|
|
|
|
|
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
|
|
|
const mine = myCall && d.call === myCall.toUpperCase();
|
|
|
|
const mine = myCall && d.call === myCall.toUpperCase();
|
|
|
|
|
|
|
|
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(
|
|
|
|
className={cn(ROW, ROW_MAX, 'mx-auto w-full py-1.5 text-left')}
|
|
|
|
'w-full flex items-center gap-2 px-3 py-1 text-left transition-colors',
|
|
|
|
|
|
|
|
'hover:bg-muted/60 active:scale-[0.998]',
|
|
|
|
|
|
|
|
mine && 'bg-info/10',
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
>
|
|
|
|
>
|
|
|
|
{/* Marker rail: one segment per orthogonal flag, same colours as
|
|
|
|
{/* A thin accent, kept only as a hint that the row carries a
|
|
|
|
the cluster list and the band map. */}
|
|
|
|
reason — the reasons themselves are spelled out in the
|
|
|
|
<span className="flex flex-col w-1 h-5 rounded-full overflow-hidden shrink-0">
|
|
|
|
flags column, where they can be read. */}
|
|
|
|
{markers.length === 0
|
|
|
|
<span className={cn('h-5 rounded-full', hot ? 'bg-success' : 'bg-transparent')} />
|
|
|
|
? <span className="flex-1 bg-transparent" />
|
|
|
|
|
|
|
|
: markers.map((m) => (
|
|
|
|
|
|
|
|
<span key={m.key} className="flex-1" style={{ background: markerColour(m.key) }} />
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<span className={cn('font-mono text-xs font-bold w-[92px] shrink-0 truncate',
|
|
|
|
<span className={cn('font-mono text-sm font-bold truncate',
|
|
|
|
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
|
|
|
|
e?.worked_call ? 'text-muted-foreground' : 'text-foreground')}>
|
|
|
|
{d.call}
|
|
|
|
{d.call}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
<span className={cn('font-mono text-xs w-11 text-right tabular-nums shrink-0', snrTone(d.snr))}>
|
|
|
|
<span className={cn('font-mono text-sm text-right 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-[11px] text-muted-foreground w-14 shrink-0">
|
|
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
|
|
{d.grid ?? ''}
|
|
|
|
{d.grid ?? ''}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{d.cq && (
|
|
|
|
<span className="font-mono text-sm truncate min-w-0">
|
|
|
|
<span className="text-[10px] font-bold text-success shrink-0">CQ</span>
|
|
|
|
{d.cq && <span className="font-bold text-success mr-1.5">CQ</span>}
|
|
|
|
)}
|
|
|
|
<span className="text-muted-foreground">{d.msg}</span>
|
|
|
|
|
|
|
|
|
|
|
|
<span className="font-mono text-[11px] text-muted-foreground truncate flex-1 min-w-0">
|
|
|
|
|
|
|
|
{d.msg}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
{e?.country && (
|
|
|
|
{/* Flags — the column this panel exists for. */}
|
|
|
|
<span className="text-[11px] text-muted-foreground truncate max-w-[130px] shrink-0 hidden xl:block">
|
|
|
|
<span className="flex items-center gap-1 overflow-hidden">
|
|
|
|
{e.country}
|
|
|
|
{entity && (
|
|
|
|
|
|
|
|
<span className={cn('text-[11px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0', entity.cls)}>
|
|
|
|
|
|
|
|
{t(entity.label)}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
{extras.map((b) => (
|
|
|
|
|
|
|
|
<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"
|
|
|
|
|
|
|
|
style={{ borderColor: markerColour(b.marker), color: markerColour(b.marker) }}>
|
|
|
|
|
|
|
|
{t(b.label)}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
|
|
|
|
|
|
{e?.country ?? ''}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<span className="text-center">
|
|
|
|
{e?.lotw && (
|
|
|
|
{e?.lotw && (
|
|
|
|
<span className="text-[9px] font-bold px-1 rounded bg-info-muted text-info-muted-foreground shrink-0"
|
|
|
|
<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>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
{st && (
|
|
|
|
|
|
|
|
<span className={cn('text-[9px] font-bold uppercase tracking-wide px-1.5 py-px rounded shrink-0',
|
|
|
|
|
|
|
|
STATUS_STYLE[st] ?? 'bg-muted text-muted-foreground')}>
|
|
|
|
|
|
|
|
{t(STATUS_LABEL[st] ?? 'dec.stNew')}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
})}
|
|
|
|
</section>
|
|
|
|
</section>
|
|
|
|