diff --git a/app.go b/app.go index 7ba3353..924ccb7 100644 --- a/app.go +++ b/app.go @@ -12712,6 +12712,7 @@ func (a *App) consumeUDPEvents() { "tr_period": ev.DecodeTRPeriod, "off_air": ev.DecodeOffAir, "source": ev.Source, + "instance": ev.ProgramID, }) // A WSJT-X decode (heard station). Render it on the FlexRadio // panadapter when the option is on; green + SNR comment, auto-expiring diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index 2f50ae5..d2319f5 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -16,7 +16,7 @@ import { useMemo, useState } from 'react'; import { Radio, Search, X, Signal, ArrowUpRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -import { activeMarkers, markerColour } from '@/lib/spotMarkers'; +import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; export type Decode = { call: string; @@ -32,6 +32,7 @@ export type Decode = { tr_period?: number; off_air?: boolean; source?: string; + instance?: string; }; export type TxMsg = { @@ -69,23 +70,43 @@ interface Props { // arrives here; a wrong guess only mis-groups, it never loses a decode. const DEFAULT_TR = 15; -// Status → the pill's look. The vocabulary is the cluster's, deliberately: the -// same fact must not be amber in one panel and green in the next. -const STATUS_STYLE: Record = { - 'new': 'bg-success text-success-foreground', - 'new-band': 'bg-warning text-warning-foreground', - 'new-mode': 'bg-info text-info-foreground', - 'new-slot': 'bg-caution text-caution-foreground', - 'new-call': 'bg-muted text-muted-foreground', +// 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 +// message — and a hard cap on the whole grid, because on a 2500 px screen a +// free-flowing row puts the country a foot away from the callsign it belongs to +// 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'; +const ROW_MAX = 'max-w-[1500px]'; + +// 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 = { + '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 = { - 'new': 'dec.stNew', - 'new-band': 'dec.stBand', - 'new-mode': 'dec.stMode', - 'new-slot': 'dec.stSlot', - 'new-call': 'dec.stCall', -}; +// 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' }, +]; // 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 @@ -101,19 +122,20 @@ function periodStart(at: string, tr: number): number { const hhmmss = (epochSec: number) => 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 // 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'; + return 'text-muted-foreground/70'; } export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Props) { const { t } = useI18n(); const [cqOnly, setCqOnly] = useState(false); const [newOnly, setNewOnly] = useState(false); + const [lotwOnly, setLotwOnly] = useState(false); const [bandSel, setBandSel] = useState(''); const [modeSel, setModeSel] = useState(''); const [contSel, setContSel] = useState(''); @@ -123,18 +145,21 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr const statusOf = (d: Decode): StatusEntry | undefined => spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`]; - // The band / mode / continent choices are built from what is actually on the - // feed, not from a fixed list: a selector offering 160 m to an operator whose - // receivers are all on 6 m is noise. - const { bands, modes, conts } = useMemo(() => { - const b = new Set(), m = new Set(), c = new Set(); + // 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(), m = new Set(), c = new Set(), i = new Set(); 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() }; + return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [decodes, spotStatus]); @@ -150,13 +175,14 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr 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; if (newOnly && !isNewSomething(e)) 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, 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 // the slot they went out in. @@ -188,78 +214,113 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr }, [filtered, txMsgs]); const resetFilters = () => { - setCqOnly(false); setNewOnly(false); setBandSel(''); setModeSel(''); - setContSel(''); setMinSnr(''); setSearch(''); + setCqOnly(false); setNewOnly(false); setLotwOnly(false); setBandSel(''); + 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 chip = (on: boolean) => cn( - 'h-7 px-2.5 rounded-full border text-xs font-medium transition-colors', - on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted', + 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 (
{/* ── Filter bar ─────────────────────────────────────────────── */} -
+
- + {t('dec.title')} - + - - - + {/* Only when there is a choice to make — see the memo above. */} + {bands.length > 1 && ( + + )} + {modes.length > 1 && ( + + )} + {conts.length > 1 && ( + + )} -
+ {/* ── Column header ──────────────────────────────────────────── */} +
+
+ + {t('dec.colCall')} + {t('dec.colSnr')} + {t('dec.colGrid')} + {t('dec.colMsg')} + {t('dec.colFlags')} + {t('dec.colCountry')} + L +
+
+ {/* ── Periods ────────────────────────────────────────────────── */}
{groups.length === 0 && ( @@ -271,105 +332,108 @@ export function DecodesPanel({ decodes, txMsgs, spotStatus, onCall, myCall }: Pr )} {groups.map((g, gi) => ( -
- {/* Period header — sticky so the slot you are reading is always named. */} -
- - {hhmmss(g.start)} - - - {t('dec.periodCount', { n: g.decodes.length })} - - {gi === 0 && ( - - - {t('dec.live')} +
+ {/* Period header — sticky, so the slot being read is always named. */} +
+
+ + {hhmmss(g.start)} - )} + + {t('dec.periodCount', { n: g.decodes.length })} + + {gi === 0 && ( + + + {t('dec.live')} + + )} +
{/* 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) => ( -
- - - {t('dec.tx')} - - {m.msg} - {m.band && {m.band}} +
+
+ + + {t('dec.tx')} + + {m.msg} + {m.band && {m.band}} +
))} {g.decodes.map((d, i) => { const e = statusOf(d); 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 hot = !!entity || extras.length > 0; return ( - + + + {e?.lotw && ( + L + )} + + +
); })}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 9a37af5..441a2ed 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -130,6 +130,9 @@ const en: Dict = { 'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message', 'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX', 'dec.count': '{shown} of {total}', 'dec.periodCount': '{n} decodes', 'dec.callTitle': 'Call {call} — fills the entry and tunes the rig', + 'dec.lotwOnly': 'LoTW only', 'dec.instances': '{n} receivers', + 'dec.colCall': 'Call', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Grid', 'dec.colMsg': 'Message', 'dec.colFlags': 'New', 'dec.colCountry': 'Country', + '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.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).', 'dec.emptyFiltered': 'No decode matches these filters.', @@ -595,6 +598,9 @@ const fr: Dict = { 'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message', 'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX', 'dec.count': '{shown} sur {total}', 'dec.periodCount': '{n} decodes', 'dec.callTitle': 'Appeler {call} — remplit la saisie et accorde le poste', + 'dec.lotwOnly': 'LoTW seulement', 'dec.instances': '{n} recepteurs', + 'dec.colCall': 'Indicatif', 'dec.colSnr': 'SNR', 'dec.colGrid': 'Locator', 'dec.colMsg': 'Message', 'dec.colFlags': 'Nouveau', 'dec.colCountry': 'Pays', + '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.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).", 'dec.emptyFiltered': 'Aucun decode ne correspond a ces filtres.', diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index fbdfa66..715c807 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -127,6 +127,12 @@ type Event struct { DecodeTRPeriod int DecodeDial int64 // dial frequency the decode was heard on, for the band DecodeOffAir bool // decoded from a file rather than off the air + // ProgramID is the sending application's own id ("WSJT-X", "MSHV", or + // "WSJT-X - 2" for a second instance started with --rig-name). It is what + // tells two receivers apart on one multicast group — and it is the address a + // Reply message would have to be sent back to, so it is carried even though + // nothing replies yet. + ProgramID string // TxMessage is what the operator's digital app is sending, with Transmitting // true while the carrier is actually up. From Status, so ~1 Hz. @@ -439,6 +445,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { ev.DecodeTRPeriod = tr ev.DecodeDial = dial ev.DecodeOffAir = w.OffAir + ev.ProgramID = w.ProgramID break } // Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet