diff --git a/app.go b/app.go index e7386fe..743daad 100644 --- a/app.go +++ b/app.go @@ -8708,15 +8708,20 @@ func (a *App) clusterEventWorker() { // grid uses, so a spot is never one colour on screen and another on // the radio — and the index is cached, so this costs a map lookup. status := a.spotStatusName(s.DXCall, s.Band, mode, s.POTARef) - col := a.spotColorFor(status) - a.cat.SendSpot(cat.SpotInfo{ - FreqHz: s.FreqHz, - Callsign: s.DXCall, - Mode: mode, - Comment: spotComment(s.Comment, status), - Color: col.Text, - BackgroundColor: col.Bg, - }) + // A status the operator has switched off never reaches the radio. A + // skipped SEND rather than a transparent colour: a spot that is not + // drawn should not occupy one of the panadapter's slots either. + if !a.spotHidden(status) { + col := a.spotColorFor(status) + a.cat.SendSpot(cat.SpotInfo{ + FreqHz: s.FreqHz, + Callsign: s.DXCall, + Mode: mode, + Comment: spotComment(s.Comment, s.Spotter, s.Country, status), + Color: col.Text, + BackgroundColor: col.Bg, + }) + } } } } @@ -13144,6 +13149,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { // (same reasoning as the manual path — award_refs on a busy shared MySQL blocked). if a.ctx != nil { wruntime.EventsEmit(a.ctx, "qso:logged", id) + // A contact logged by WSJT-X / JTDX / MSHV is the one the operator has no + // other confirmation of: the entry form did not clear, because they never + // typed in it, and on the FT decodes tab the log grid is not even on + // screen. Its own event, carrying enough to name the station — qso:logged + // is an id, and fires twice per contact, so it can drive a refresh but not + // a message. + wruntime.EventsEmit(a.ctx, "qso:logged_external", map[string]any{ + "call": q.Callsign, "band": q.Band, "mode": q.Mode, "country": q.Country, + }) } qc := q go func() { @@ -13313,19 +13327,37 @@ func (a *App) consumeUDPEvents() { if len(lastDecodeSpot) > 4000 { lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions } - lastDecodeSpot[key] = now secs := a.catFlexDecodeSecs if secs <= 0 { secs = 120 } + // The palette governs the PANADAPTER, not one of the two things + // that feed it. A decode spot is a callsign on the waterfall + // exactly as a cluster spot is, so it answers to the same + // per-status switch and the same colours — an operator who turned + // "entity not resolved" off and still watched unresolved calls + // pile up on the radio was right to call that a bug. + status := a.spotStatusName(ev.DecodeCall, bandForHz(ev.DecodeFreqHz), ev.Mode, "") + if a.spotHidden(status) { + continue + } + lastDecodeSpot[key] = now + // Green when the palette is off — the colour this feature has always + // used, and what still tells a decode from a cluster spot. + col := a.spotColorFor(status) + textCol, bgCol := col.Text, col.Bg + if textCol == "" { + textCol, bgCol = "#FF34C759", "" + } select { case spotQ <- cat.SpotInfo{ - FreqHz: ev.DecodeFreqHz, - Callsign: ev.DecodeCall, - Mode: ev.Mode, - Color: "#FF34C759", // green — distinct from cluster orange - Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), - LifetimeSec: secs, + FreqHz: ev.DecodeFreqHz, + Callsign: ev.DecodeCall, + Mode: ev.Mode, + Color: textCol, + BackgroundColor: bgCol, + Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status), + LifetimeSec: secs, }: default: // Radio not keeping up: skip the spot rather than stall the @@ -19439,7 +19471,12 @@ func (a *App) spotStatusName(call, band, mode, potaRef string) string { case r.NewPfx: return "new-pfx" } - if r.Status == "worked" { + // WORKED means this CALLSIGN is in the log — the question an operator asks + // of a spot ("have I had this station?"), and the one DXHunter answers. + // The entity-level statuses above already say what the ENTITY is worth; an + // entity worked here whose callsign is new is not "worked", it is simply no + // news, which is what "none" means. + if r.WorkedCall { return "worked" } return "none" @@ -20177,3 +20214,16 @@ func (a *App) tidyLogbookSchema(logbookConn *sql.DB) { // if this one is ever asked to serve as the logbook again. db.DropEmptyQSOTable(a.db, filepath.Base(a.dbPath)) } + +// countryFor names the DXCC entity of a callsign, or "" when it cannot be +// resolved. Used to enrich a panadapter spot built from a WSJT-X decode, which +// arrives as a bare callsign. +func (a *App) countryFor(call string) string { + if a.dxcc == nil || strings.TrimSpace(call) == "" { + return "" + } + if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil { + return m.Entity.Name + } + return "" +} diff --git a/changelog.json b/changelog.json index a32bc0b..db1bb9a 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,42 @@ [ + { + "version": "0.26.6", + "date": "", + "en": [ + "FT decodes: the continent filter is now a multiple choice — click AF, AS, OC and SA to keep those four. It still lists only the continents actually being decoded, plus any you have selected, so a filter can never strand itself with no chip left to switch it off.", + "A QSO logged by WSJT-X, JTDX or MSHV now raises a notice in the corner naming the station. Watching the decodes, nothing else on screen said it had happened: the entry form never cleared, because nobody typed in it.", + "Icom CI-V: a reply the rig sent to another controller on the same bus is no longer mistaken for ours, and a PTT command whose acknowledgement goes missing is verified by reading the rig instead of being reported as a failure. WSJT-X and JTDX answer a failed set_ptt with a Rig Control Error and drop the link mid-over, which is what an IC-7850 was doing three times a session. The check itself waits 700 ms rather than the poll's 150: the rig has just failed to answer twice, usually because it is retuning — JTDX moves the transmit frequency and keys in the same breath — and a short probe would fail for the same reason. Both outcomes are logged, so a failure that remains can be told from a fix that did not fire.", + "FlexRadio: each spot status can now be kept off the panadapter entirely, with a switch beside its colours — and WSJT-X decode spots obey the same switches and the same colours. The palette governs the panadapter, not just one of the two things that feed it: statuses switched off still piled up on the waterfall because the decodes were pushed by another path.", + "Panadapter spot comments now read like DXHunter's: the cluster text, then the spotter, the entity and the status — \"CQ up 2 [F4BPO] [Franz Josef Land] [New Slot]\". The words were running together because SmartSDR splits its command line on spaces; non-breaking spaces get them through.", + "\"Already worked\" on a panadapter spot now means the CALLSIGN is in the log, not the entity. An entity worked on that band and mode is no news, and saying \"already worked\" of a station never contacted was wrong.", + "FT decodes: a compound MSHV message that answers two stations in one line — \"DM8BJF RR73; F4BPO +14\" — is now recognised as addressed to you when your callsign is in the second half.", + "Edit QSO: your own transmit power can be edited at last — the field was saved and written back but had no box, so a contact logged at the wrong power could never be corrected. The contacted station's extended locator gained a box for the same reason, and the power labels now say WHOSE power they are.", + "FlexRadio: a spot is no longer removed and redrawn when nothing about it has changed. A busy skimmer feed re-spots the same station every few seconds — one two-minute session sent 2128 spot adds, 88 of them for a single callsign — and the panadapter did not move a pixel for any of them.", + "Panadapter spot comments are kept within what the radio will accept: SmartSDR was cutting the longest ones mid-word, losing the entity and the status. The cluster's own words are trimmed first, the spotter, entity and status always survive. The column padding of an RBN line is collapsed to single spaces too — kept as non-breaking spaces it opened a gap wide enough to push the rest of the comment off the screen.", + "Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes. Clicking a decode to answer it is unchanged.", + "The elastic bounce at the end of a list is gone, and a two-finger horizontal swipe no longer navigates the window away from the app. Both are browser behaviours that only appear on a touchpad or a touchscreen.", + "The interface zoom now says what it is doing: a badge shows the factor while it changes, clicking it returns to 100%, and View → Zoom in / Zoom out / Actual size makes it findable. Ctrl+wheel and Ctrl+0 always worked, but nothing announced them — zoom by accident and there was no way back.", + "Icom CI-V: a frequency or mode change whose acknowledgement goes missing is now verified by reading the rig back, like the PTT already was. All three are commands WSJT-X and JTDX answer with a Rig Control Error and a dropped link, and an IC-7850 log shows a set_freq doing exactly that.", + "The log file now rotates while OpsLog is running, not only at startup. It was bounded on the belief that a session writes a few kilobytes; with the CI-V wire trace left on, one wrote 416 MB and nothing would have stopped it before the disk did." + ], + "fr": [ + "Décodes FT : le filtre par continent est maintenant multiple — clique AF, AS, OC et SA pour garder ces quatre-là. Il ne liste toujours que les continents réellement décodés, plus ceux que tu as sélectionnés, pour qu'un filtre ne puisse pas se bloquer sans pastille pour le désactiver.", + "Un QSO enregistré par WSJT-X, JTDX ou MSHV affiche maintenant une notification dans le coin, avec le nom de la station. En regardant les décodes, rien d'autre à l'écran ne le signalait : le formulaire ne se vidait pas, puisque personne n'y avait tapé.", + "Icom CI-V : une réponse que la radio adresse à un autre contrôleur du même bus n'est plus prise pour la nôtre, et un ordre de PTT dont l'accusé de réception se perd est vérifié en interrogeant la radio au lieu d'être déclaré en échec. WSJT-X et JTDX répondent à un set_ptt en échec par une erreur de contrôle radio et coupent la liaison en pleine émission — ce que faisait un IC-7850 trois fois par session. La vérification attend 700 ms et non les 150 de la scrutation : la radio vient d'échouer deux fois, en général parce qu'elle se réaccorde — JTDX décale la fréquence d'émission et passe en émission dans la foulée — et une interrogation brève échouerait pour la même raison. Les deux issues sont journalisées, pour distinguer un échec réel d'un correctif qui n'a pas agi.", + "FlexRadio : chaque statut de spot peut désormais être totalement exclu du panadapter, par un interrupteur à côté de ses couleurs — et les spots issus des décodes WSJT-X obéissent aux mêmes interrupteurs et aux mêmes couleurs. La palette régit le panadapter, pas seulement l'une des deux sources qui l'alimentent : les statuts décochés continuaient de s'entasser sur la cascade parce que les décodes passaient par un autre chemin.", + "Les commentaires des spots du panadapter se lisent maintenant comme ceux de DXHunter : le texte du cluster, puis le spotteur, l'entité et le statut — « CQ up 2 [F4BPO] [Franz Josef Land] [New Slot] ». Les mots se collaient parce que SmartSDR découpe sa ligne de commande sur les espaces ; des espaces insécables les font passer.", + "« Déjà contacté » sur un spot du panadapter désigne désormais l'INDICATIF présent dans le journal, et non l'entité. Une entité déjà faite sur cette bande et ce mode n'est pas une nouvelle, et annoncer « déjà contacté » pour une station jamais travaillée était faux.", + "Décodes FT : un message composé MSHV qui répond à deux stations sur une seule ligne — « DM8BJF RR73; F4BPO +14 » — est maintenant reconnu comme vous étant adressé quand votre indicatif est dans la seconde moitié.", + "Édition de QSO : sa propre puissance d'émission est enfin modifiable — le champ était enregistré et réécrit mais n'avait aucune case, donc un contact loggé à la mauvaise puissance ne pouvait pas être corrigé. Le locator étendu du correspondant gagne une case pour la même raison, et les libellés de puissance disent maintenant à QUI elle appartient.", + "FlexRadio : un spot n'est plus supprimé puis redessiné quand rien n'a changé. Un flux de skimmers actif re-spotte la même station toutes les quelques secondes — une session de deux minutes envoyait 2128 ajouts, dont 88 pour un seul indicatif — sans que le panadapter bouge d'un pixel.", + "Les commentaires des spots du panadapter tiennent maintenant dans ce que la radio accepte : SmartSDR coupait les plus longs en plein mot, perdant l'entité et le statut. Le texte du cluster est rogné en premier, le spotteur, l'entité et le statut survivent toujours. L'alignement en colonnes des lignes RBN est aussi ramené à une seule espace : conservé en espaces insécables, il ouvrait un trou assez large pour repousser le reste du commentaire hors de l'écran.", + "L'appel automatique est retiré — il faisait doublon avec DXHunter, qui répond déjà aux décodages. Cliquer un décodage pour répondre ne change pas.", + "Le rebond élastique en fin de liste a disparu, et un balayage horizontal à deux doigts ne fait plus quitter l'application. Deux comportements de navigateur qui n'apparaissent qu'au pavé tactile ou sur écran tactile.", + "Le zoom de l'interface s'annonce enfin : une pastille affiche le facteur pendant qu'il change, un clic dessus revient à 100 %, et le menu Affichage → Zoom avant / Zoom arrière / Taille réelle le rend trouvable. Ctrl+molette et Ctrl+0 fonctionnaient déjà, mais rien ne le disait — un zoom involontaire et on ne savait plus revenir.", + "Icom CI-V : un changement de fréquence ou de mode dont l'accusé de réception se perd est désormais vérifié en interrogeant la radio, comme l'était déjà le PTT. Les trois sont des ordres auxquels WSJT-X et JTDX répondent par une erreur de contrôle radio et une liaison coupée, et un log d'IC-7850 montre un set_freq faire précisément cela.", + "Le fichier de log tourne maintenant en cours d'exécution, et plus seulement au démarrage. Sa limite reposait sur l'idée qu'une session écrit quelques kilo-octets ; avec la trace CI-V laissée active, l'une d'elles a écrit 416 Mo sans que rien ne l'arrête avant le disque." + ] + }, { "version": "0.26.5", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 314f7eb..6311f60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1284,11 +1284,22 @@ export default function App() { // by z, so it reflows to fill that larger box and then scales back to exactly the // window (more content visible when zoomed out, like a real browser zoom). const appRootRef = useRef(null); + // The current factor, shown briefly whenever it changes. + // + // Ctrl+wheel and Ctrl+0 have worked from the start, and NOTHING said so: + // no menu entry, no readout. An operator who zoomed by accident — easy, the + // wheel is over a grid and Ctrl is already down for something else — saw the + // interface change size with no explanation and no way back. The number is + // the fix; the menu entry makes it findable without reading a manual. + const [uiZoom, setUiZoom] = useState(1); + const [zoomShown, setZoomShown] = useState(false); + const zoomHideRef = useRef(0); + const setZoomRef = useRef<(z: number) => void>(() => {}); useEffect(() => { const KEY = 'opslog.uiZoom'; let z = parseFloat(localStorage.getItem(KEY) || '1'); if (!Number.isFinite(z) || z <= 0) z = 1; - const apply = () => { + const apply = (announce = false) => { const el = appRootRef.current; if (!el) return; el.style.transformOrigin = '0 0'; @@ -1296,17 +1307,36 @@ export default function App() { el.style.width = `${100 / z}vw`; el.style.height = `${100 / z}vh`; document.documentElement.setAttribute('data-uizoom', String(z)); + setUiZoom(z); + // Only on a CHANGE: showing it at startup would put a badge over the log + // every launch, for a number that is almost always 100 %. + if (announce) { + setZoomShown(true); + window.clearTimeout(zoomHideRef.current); + zoomHideRef.current = window.setTimeout(() => setZoomShown(false), 1600); + } }; apply(); + // The menu and the badge set the factor through here, so there is one + // implementation of "zoom to z" and not three. + setZoomRef.current = (next: number) => { + z = Math.min(2.5, Math.max(0.5, Math.round(next * 10) / 10)); + localStorage.setItem(KEY, String(z)); + apply(true); + }; const onWheel = (e: WheelEvent) => { if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge) e.preventDefault(); z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10)); localStorage.setItem(KEY, String(z)); - apply(); + apply(true); }; const onKey = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(); } + if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(true); } + // Ctrl+plus / Ctrl+minus, the shortcuts every other application uses. + // '=' as well as '+': on most layouts the plus is the shifted '='. + if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) { e.preventDefault(); setZoomRef.current(z + 0.1); } + if ((e.ctrlKey || e.metaKey) && (e.key === '-')) { e.preventDefault(); setZoomRef.current(z - 0.1); } }; window.addEventListener('wheel', onWheel, { passive: false }); window.addEventListener('keydown', onKey); @@ -2274,6 +2304,9 @@ export default function App() { const [clusterMacros, setClusterMacros] = useState(loadClusterMacros); useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]); const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]); + // Auto-call is withdrawn — it duplicated DXHunter, which answers decodes from + // the same shack. This flag is the single place that says so at runtime. + const AUTO_CALL_ENABLED = false; const [autoCall, setAutoCall] = useState(loadAutoCall); useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]); // When each callsign was last answered, so a station still calling CQ is not @@ -2306,6 +2339,15 @@ export default function App() { // backstop below. const lastTxAtRef = useRef>(new Map()); useEffect(() => { + // AUTO-CALL IS WITHDRAWN — DXHunter already answers decodes, and two + // programs doing it from one shack key over each other. See lib/autocall.ts. + // + // Returning here rather than deleting the loop: the decision it implements + // is the delicate part, argued over and tested, and worth keeping intact. + // The guard is what matters — an operator whose stored preference still + // says "enabled" must not have their transmitter keyed by a feature they + // can no longer see, let alone switch off. + if (!AUTO_CALL_ENABLED) return; if (!autoCall.enabled) return; const now = Date.now(); // A QSO is in progress somewhere if ANY receiver is transmitting or still @@ -2663,8 +2705,17 @@ export default function App() { // Backend-emitted toast messages (e.g. recording auto-send result/skip). useEffect(() => { const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); }); - return () => { off(); }; - }, [showToast]); + // A QSO logged by an external app announces itself: nothing else on screen + // says it happened when the operator is watching the decodes rather than the + // entry form. + const offExt = EventsOn('qso:logged_external', (p: any) => { + const call = String(p?.call ?? '').trim(); + if (!call) return; + const bits = [p?.band, p?.mode].filter(Boolean).join(' · '); + showToast(`✅ ${t('toast.qsoLogged')} — ${call}${bits ? ` · ${bits}` : ''}`); + }); + return () => { off(); offExt(); }; + }, [showToast, t]); // Band openings — sporadic E on 6/4/2 m, detected from the spot stream. // @@ -4668,6 +4719,10 @@ export default function App() { { type: 'item', label: t('edit.prefs'), action: 'edit.prefs' }, ]}, { name: 'view', label: t('menu.view'), items: [ + { type: 'item', label: t('view.zoomIn'), action: 'view.zoomin', shortcut: 'Ctrl++' }, + { type: 'item', label: t('view.zoomOut'), action: 'view.zoomout', shortcut: 'Ctrl+-' }, + { type: 'item', label: t('view.zoomReset'), action: 'view.zoomreset', shortcut: 'Ctrl+0' }, + { type: 'separator' }, { type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' }, { type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' }, ]}, @@ -4716,6 +4771,9 @@ export default function App() { case 'file.exportCabrillo': exportCabrillo(); break; case 'file.deleteall': setShowDeleteAll(true); break; case 'file.exit': QuitApp(); break; + case 'view.zoomin': setZoomRef.current(uiZoom + 0.1); break; + case 'view.zoomout': setZoomRef.current(uiZoom - 0.1); break; + case 'view.zoomreset': setZoomRef.current(1); break; case 'view.refresh': refresh(); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; @@ -5812,14 +5870,6 @@ export default function App() { txMsgs={txMsgs} txState={txState} txStates={txStates} - autoCallOn={autoCall.enabled} - // Written through the same key Preferences uses, so the two can never - // disagree about whether the machine is armed. - onToggleAutoCall={() => setAutoCall((prev) => { - const next = { ...prev, enabled: !prev.enabled }; - writeUiPref(autoCallKey, JSON.stringify(next)); - return next; - })} spotStatus={spotStatus as any} myCall={station.callsign} // A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as @@ -6668,6 +6718,21 @@ export default function App() { )} + {/* The zoom factor, while it is changing. + Centred at the top, out of the way of the entry form and the toasts, + and clickable: the number is only half an answer if the way back to + 100 % is another thing to know. */} + {zoomShown && uiZoom !== 1 && ( + + )} + {/* Transient toasts (bottom-right). Errors stack on top of the green success toast; both auto-dismiss. */} {/* Unlock encrypted passwords (set via Settings → Security). Dismissable: diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index 321d911..f092abd 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -531,7 +531,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o }); const [bandSel, setBandSel] = usePersisted('bandSel', ''); const [modeSel, setModeSel] = usePersisted('modeSel', ''); - const [contSel, setContSel] = usePersisted('contSel', ''); + // Continents are a MULTI-selection, not one at a time: the useful filter is + // "everything except NA and EU", which a single-choice dropdown cannot say — + // it can only name one continent to keep. Stored as a list (a Set does not + // survive JSON), empty meaning no filter at all. + const [contList, setContList] = usePersisted('contList', []); + const contSel = useMemo(() => new Set(contList), [contList]); + const toggleCont = (c: string) => + setContList(contSel.has(c) ? contList.filter((x) => x !== c) : [...contList, c].sort()); const [minSnr, setMinSnr] = usePersisted('minSnr', ''); const [search, setSearch] = usePersisted('search', ''); @@ -554,8 +561,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o const calling = (txState?.dx_call ?? '').toUpperCase(); const answersMe = (msg?: string): boolean => { if (!me || !msg) return false; - const first = msg.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase(); - return !!first && first === me; + // EVERY segment, not just the first. + // + // MSHV answers two stations in one transmission and sends them as one line: + // + // DM8BJF RR73; F4BPO +14 + // + // The second half is addressed to F4BPO, and reading only the start of the + // line missed it — the operator being answered saw no badge at all, which is + // the one moment this badge exists for. Each ';' segment is its own message + // and names its own recipient first. + for (const part of msg.split(';')) { + const first = part.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase(); + if (first && first === me) return true; + } + return false; }; // The choices are built from what is actually on the feed, and a selector with @@ -576,6 +596,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o // eslint-disable-next-line react-hooks/exhaustive-deps }, [decodes, spotStatus]); + // The chips are the continents on the feed — a selector with nothing to choose + // is furniture — PLUS anything currently selected. Without that second half a + // filter can strand itself: pick AF, the last African station stops decoding, + // and the list empties with no chip left to switch it back off. + const contChips = useMemo( + () => [...new Set([...conts, ...contList])].sort(), + [conts, contList], + ); + const filtered = useMemo(() => { const q = search.trim().toUpperCase(); const floor = minSnr.trim() === '' ? null : parseInt(minSnr, 10); @@ -594,7 +623,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o for (const c of cats) if (have.has(c)) { hit = true; break; } if (!hit) return false; } - if (contSel && e?.continent !== contSel) return false; + if (contSel.size > 0 && !(e?.continent && contSel.has(e.continent))) return false; if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false; return true; }); @@ -627,11 +656,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o const resetFilters = () => { setCqOnly(false); setLotwOnly(false); setBandSel(''); - setModeSel(''); setContSel(''); setMinSnr(''); setSearch(''); + setModeSel(''); setContList([]); setMinSnr(''); setSearch(''); setCats(new Set()); try { localStorage.setItem(CAT_KEY, '[]'); } catch { /* not worth failing over */ } }; - const anyFilter = cqOnly || lotwOnly || cats.size > 0 || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim(); + const anyFilter = cqOnly || lotwOnly || cats.size > 0 || !!bandSel || !!modeSel || contSel.size > 0 || !!minSnr || !!search.trim(); const sel = 'h-8 rounded-lg border border-border bg-background px-2 text-sm'; const chip = (on: boolean, tone = 'primary') => cn( @@ -700,11 +729,28 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o {modes.map((m) => )} )} - {conts.length > 1 && ( - + {/* Toggles, like the category badges above — the same gesture for the + same kind of question. None lit is every continent, which is what an + empty filter has always meant here. */} + {contChips.length > 1 && ( + + {contChips.map((c) => { + const on = contSel.has(c); + return ( + + ); + })} + )}