From 4f77d51ffe7ac542d960f25726d8c7591237f5cc Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 18 Aug 2026 06:04:28 +0200 Subject: [PATCH] refactor(decodes): real columns, spelled-out flags, bigger type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pass on the panel from operating feedback. Columns are a grid template shared by the header row and every data row, so the two cannot drift and the eye has a rail to follow. It is capped at 1500 px and centred: free-flowing, a 2500 px window put the country a foot from the callsign it belonged to and left a hole in the middle of every line. "New" gets a COLUMN. It was only a coloured edge before, which says something is special without saying what — and every one of these is a reason to break off what you are doing and call. The entity verdict is a solid badge, the orthogonal ones (park, grid, prefix, county) are outlined in the colours markerColour already gives the cluster list and the band map, so a new park is the same green in all three. Applied inline because those are categorical --chart-* custom properties, which the theme does not expose as Tailwind colour utilities: written as border-chart-7 the badge would simply have had no colour. Band and mode selectors now appear only when the feed actually carries more than one of each. One MSHV is one band and one mode, so for most operators they were furniture; they show up the day a second instance puts a second band on the link, which is the only day they mean anything. Same rule for continent, and a receiver count when more than one instance is feeding. Added a LoTW-only filter, and raised the type throughout (call and message to 14 px, secondary to 12 px, badges to 11 px) with more room per row. The decode payload now carries the sending application's own id. It tells two receivers apart on one multicast group — and it is the address a WSJT-X Reply message would have to go back to, so it is carried now rather than requiring another trip through the parser later. --- app.go | 1 + frontend/src/components/DecodesPanel.tsx | 338 ++++++++++++++--------- frontend/src/lib/i18n.tsx | 6 + internal/integrations/udp/server.go | 7 + 4 files changed, 215 insertions(+), 137 deletions(-) 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