From 659e33676afdc0c777fb75eaa1a395b08942c108 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 7 Sep 2026 23:09:03 +0200 Subject: [PATCH] feat(decodes): sort a slot by SNR, frequency, distance, country or status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click the heading. Within each PERIOD and never across them: the slots are what this panel is — what was on the air in one fifteen-second window — and a list sorted end to end would mix three minutes of decodes into one column of numbers with no way to tell which window any of them came from. One click sorts the way that column is worth reading — strongest signal, lowest frequency, furthest DX, A to Z, most wanted — the second reverses it, and the third gives arrival order back. Arrival order stays the default and stays one click away, because it mirrors the decoder's own window line for line, which is what makes the two screens comparable at a glance. Status ranks by the cluster's own order, so the two views rank the same things the same way, with the markers that are orthogonal to the entity — a new county on a worked country — sorted above the plain duplicates. A station that never sent a grid cannot be placed, and an unresolved country is not a name: both sort to the end whichever way the column goes, rather than pretending to a distance of zero and heading the list under "nearest first". --- changelog.json | 6 +- frontend/src/components/DecodesPanel.tsx | 153 +++++++++++++++++++---- frontend/src/lib/i18n.tsx | 4 +- 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/changelog.json b/changelog.json index 99ba50c..2d29501 100644 --- a/changelog.json +++ b/changelog.json @@ -16,7 +16,8 @@ "Twenty-five satellites ship with a frequency plan instead of eleven: the eight Tevel FM cubesats, EO-88, AO-109, CAS-4A and 4B, TO-108, GreenCube's digipeater, and QO-100's wideband transponder beside its narrowband one. The list is still a starting point in a file you can correct — and the satellite picker will show every bird in the element set, plan or no plan, if you untick 'only those with a frequency plan'.", "Satellites no longer asks for a locator you have already given. Passes are predicted from your station locator; the field is now folded away under 'the satellite antenna is at another site', which is the only case it was ever for.", "The pass list is a proper table: headings that say what the numbers are, a satellite column as wide as the longest name instead of eight characters, a coloured dot for FM or SSB — which decides whether you reach for a handheld or the whole station — and the maximum elevation coloured by how good the pass is. A pass in progress is green, one starting within five minutes is amber. The rotator's baud rate is a dropdown like every other in OpsLog.", - "A sky plot, the way every tracker draws one: the centre is straight up, the rim is the horizon, north is at the top. The whole pass is drawn as a dashed track with arrows for the direction of travel, a hollow circle where it rises and a filled one where it sets, and a cross where the satellite is now — green above the horizon, grey below. One glance says whether the bird comes over the roof or along the treeline, which no amount of azimuth and elevation digits conveys. The radar button in the header folds it away." + "A sky plot, the way every tracker draws one: the centre is straight up, the rim is the horizon, north is at the top. The whole pass is drawn as a dashed track with arrows for the direction of travel, a hollow circle where it rises and a filled one where it sets, and a cross where the satellite is now — green above the horizon, grey below. One glance says whether the bird comes over the roof or along the treeline, which no amount of azimuth and elevation digits conveys. The radar button in the header folds it away.", + "The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero." ], "fr": [ "[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode.", @@ -32,7 +33,8 @@ "Vingt-cinq satellites livrés avec un plan de fréquences au lieu de onze : les huit cubesats FM Tevel, EO-88, AO-109, CAS-4A et 4B, TO-108, le digipeater de GreenCube, et le transpondeur large bande de QO-100 à côté de son bande étroite. La liste reste un point de départ, dans un fichier que vous pouvez corriger — et le sélecteur affiche tous les satellites du jeu d'éléments, avec plan ou non, si vous décochez « seulement ceux avec un plan de fréquences ».", "Satellites ne redemande plus un locator déjà renseigné. Les passages sont calculés depuis le locator de votre station ; le champ est replié sous « l'antenne satellite est sur un autre site », le seul cas pour lequel il existait.", "La liste des passages est un vrai tableau : des en-têtes qui disent ce que sont les chiffres, une colonne satellite à la largeur du nom le plus long au lieu de huit caractères, une pastille de couleur pour FM ou SSB — qui décide si l'on prend un portatif ou toute la station — et l'élévation maximale colorée selon la qualité du passage. Un passage en cours est vert, un passage dans moins de cinq minutes est orange. Le débit du rotor est une liste déroulante comme partout ailleurs dans OpsLog.", - "Une vue du ciel, comme la dessine n'importe quel tracker : le centre est à la verticale, le bord est l'horizon, le nord en haut. Le passage entier est tracé en pointillés avec des flèches donnant le sens de déplacement, un cercle vide au lever et un cercle plein au coucher, et une croix là où se trouve le satellite — verte au-dessus de l'horizon, grise en dessous. Un coup d'œil dit si le satellite passe au-dessus du toit ou le long des arbres, ce qu'aucune succession de chiffres d'azimut et d'élévation ne transmet. Le bouton radar de l'en-tête la replie." + "Une vue du ciel, comme la dessine n'importe quel tracker : le centre est à la verticale, le bord est l'horizon, le nord en haut. Le passage entier est tracé en pointillés avec des flèches donnant le sens de déplacement, un cercle vide au lever et un cercle plein au coucher, et une croix là où se trouve le satellite — verte au-dessus de l'horizon, grise en dessous. Un coup d'œil dit si le satellite passe au-dessus du toit ou le long des arbres, ce qu'aucune succession de chiffres d'azimut et d'élévation ne transmet. Le bouton radar de l'en-tête la replie.", + "Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle." ] }, { diff --git a/frontend/src/components/DecodesPanel.tsx b/frontend/src/components/DecodesPanel.tsx index 3f99686..e0be688 100644 --- a/frontend/src/components/DecodesPanel.tsx +++ b/frontend/src/components/DecodesPanel.tsx @@ -12,7 +12,7 @@ // Status flags (new entity / band / mode / slot / grid / prefix / POTA / county) // come from the same resolver the cluster uses, so a call means the same thing in // both panels rather than being judged twice by two rules. -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -320,6 +320,39 @@ const US_STATES: Record = { WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia', }; +// The columns worth sorting on. Not every column: time is what the periods +// already are, and sorting a slot by callsign or message answers no question an +// operator has. +type SortKey = 'snr' | 'freq' | 'dist' | 'country' | 'status'; +const SORTABLE: SortKey[] = ['snr', 'freq', 'dist', 'country', 'status']; + +// Which way each column is worth reading FIRST — strongest signal, lowest +// frequency, furthest DX, A to Z, most wanted. Clicking again reverses it. +const SORT_FIRST: Record = { + snr: 'desc', freq: 'asc', dist: 'desc', country: 'asc', status: 'desc', +}; + +// How wanted a station is, as a number to sort by. The cluster's own order, +// most wanted first — a new entity above a new band above a new slot — so the +// two views rank the same things the same way. +const STATUS_RANK: Record = { + 'new': 100, 'new-band-mode': 90, 'new-band': 80, 'new-mode': 70, 'new-slot': 60, + 'new-call': 30, 'worked': 10, +}; +function statusRank(e?: StatusEntry): number { + if (!e) return 0; + let r = STATUS_RANK[e.status ?? ''] ?? 0; + // The markers that are orthogonal to the entity: a new county on a worked + // country is still something to chase, and should not sort with the plain + // duplicates. + if (e.new_pota) r = Math.max(r, 50); + if (e.new_county) r = Math.max(r, 45); + if (e.new_grid) r = Math.max(r, 44); + if (e.new_state) r = Math.max(r, 43); + if (e.new_pfx) r = Math.max(r, 42); + return r; +} + const COL_MAX = 600; const COLW_KEY = 'opslog.decodeColWidths'; @@ -671,6 +704,68 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r const statusOf = (d: Decode): StatusEntry | undefined => spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`]; + // ── Sorting, inside a period ────────────────────────────────────────── + // + // WITHIN each slot and never across them. The periods are the point of this + // panel — what was on the air in one fifteen-second window — and a list + // sorted end to end by signal would mix three minutes of decodes into one + // column of numbers with no way to tell which slot any of them came from. + // + // Arrival order stays the default and stays one click away, because it + // mirrors the decoder's own window line for line, which is what makes the + // two screens comparable at a glance. + const [sortSpec, setSortSpec] = usePersisted('sort', ''); + const [sortKey, sortDir] = useMemo(() => { + const [k, d] = String(sortSpec || '').split(':'); + return [SORTABLE.includes(k as SortKey) ? (k as SortKey) : '', d === 'asc' ? 'asc' : 'desc'] as const; + }, [sortSpec]); + + // One click sorts the way that column is worth reading — strongest signal, + // furthest DX, lowest frequency, A to Z, most wanted. The second reverses it, + // the third gives arrival order back. + const toggleSort = (k: SortKey) => { + if (sortKey !== k) { setSortSpec(`${k}:${SORT_FIRST[k]}`); return; } + if (sortDir === SORT_FIRST[k]) { setSortSpec(`${k}:${SORT_FIRST[k] === 'asc' ? 'desc' : 'asc'}`); return; } + setSortSpec(''); + }; + + const sortValue = useCallback((d: Decode, k: SortKey): number | string => { + const e = statusOf(d); + switch (k) { + case 'snr': return d.snr; + case 'freq': return d.freq_hz ?? 0; + case 'dist': { + const g = d.grid || e?.grid || ''; + const path = myGrid && g ? pathBetween(myGrid, g) : null; + // A station that never sent a grid cannot be placed. Sorted to the end + // whichever way round the column goes, rather than pretending to a + // distance of zero and sitting at the top of "nearest first". + return path ? path.distanceShort : Number.NaN; + } + case 'country': return (e?.country ?? '').toUpperCase(); + case 'status': return statusRank(e); + } + }, [spotStatus, myGrid]); + + const sortDecodes = useCallback((list: Decode[]): Decode[] => { + if (!sortKey) return list; + const sign = sortDir === 'asc' ? 1 : -1; + return [...list].sort((a, b) => { + const va = sortValue(a, sortKey), vb = sortValue(b, sortKey); + const na = typeof va === 'number' && Number.isNaN(va); + const nb = typeof vb === 'number' && Number.isNaN(vb); + if (na !== nb) return na ? 1 : -1; // unknowns last, both ways + if (na && nb) return 0; + if (typeof va === 'string' || typeof vb === 'string') { + const sa = String(va), sb = String(vb); + // An empty country is an unknown too, not a name that sorts first. + if (!sa !== !sb) return sa ? -1 : 1; + return sign * sa.localeCompare(sb); + } + return sign * ((va as number) - (vb as number)); + }); + }, [sortKey, sortDir, sortValue]); + // The mode currently on the air, for the slot clock. The newest decode knows // best; between overs the transmit state still does. // A decoder that has lost its CAT link keeps announcing the last dial @@ -802,20 +897,24 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r // Each pane cuts its own periods: the bands differ, so the slot boundaries and // the transmit messages belong to one receiver and not the other. const panes = useMemo(() => { + // The sort is applied to each period's decodes, never to the periods + // themselves: the slots stay newest-first, which is what the panel is. + const sorted = (ps: ReturnType) => + sortKey ? ps.map((p) => ({ ...p, decodes: sortDecodes(p.decodes) })) : ps; if (!splitByInstance || instances.length < 2) { - return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }]; + return [{ key: '', label: '', tx: txState ?? undefined, periods: sorted(buildPeriods(filtered, txMsgs)) }]; } return instances.map((inst) => ({ key: inst, // What the program is called, not the id it announces — see decoderName. label: decoderName(inst), tx: txStates?.[inst], - periods: buildPeriods( + periods: sorted(buildPeriods( filtered.filter((d) => (d.instance ?? '') === inst), txMsgs.filter((m) => (m.instance ?? '') === inst), - ), + )), })); - }, [filtered, txMsgs, splitByInstance, instances, txState, txStates]); + }, [filtered, txMsgs, splitByInstance, instances, txState, txStates, sortKey, sortDecodes]); const resetFilters = () => { @@ -1198,23 +1297,33 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
- {cols.map((c, i) => ( - - {c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)} - setColWidth(c.key, colw[c.key] + dx)} - onReset={() => setColWidth(c.key, c.def)} - /> - - ))} + {cols.map((c, i) => { + const sortable = SORTABLE.includes(c.key as SortKey); + const active = sortable && sortKey === c.key; + return ( + toggleSort(c.key as SortKey) : undefined} + title={sortable ? t('dec.sortTip') + : c.key === 'dt' ? t('dec.colDtTitle') + : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}> + {c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)} + {active && {sortDir === 'asc' ? '▲' : '▼'}} + setColWidth(c.key, colw[c.key] + dx)} + onReset={() => setColWidth(c.key, c.def)} + /> + + ); + })}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 429262c..3c8a222 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -199,7 +199,7 @@ const en: Dict = { 'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)', 'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.', // FTx decodes panel (Tools -> FT decodes) - 'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', + 'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.', 'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents', 'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message', 'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX', @@ -811,7 +811,7 @@ const fr: Dict = { 'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)', 'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants qu’aucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès qu’elle est spottée. Les virgules et les espaces marchent aussi.', // Panneau des decodes FTx (Outils -> Decodes FT) - 'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', + 'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.', 'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents', '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',