feat(decodes): sort a slot by SNR, frequency, distance, country or status

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".
This commit is contained in:
2026-09-07 23:09:03 +02:00
parent cf44b37bf4
commit 659e33676a
3 changed files with 137 additions and 26 deletions
+131 -22
View File
@@ -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<string, string> = {
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<SortKey, 'asc' | 'desc'> = {
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<string, number> = {
'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<typeof buildPeriods>) =>
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
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}>
{cols.map((c, i) => (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
<ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)}
/>
</span>
))}
{cols.map((c, i) => {
const sortable = SORTABLE.includes(c.key as SortKey);
const active = sortable && sortKey === c.key;
return (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end',
sortable && 'cursor-pointer select-none hover:text-foreground',
active && 'text-primary')}
onClick={sortable ? () => toggleSort(c.key as SortKey) : undefined}
title={sortable ? t('dec.sortTip')
: c.key === 'dt' ? t('dec.colDtTitle')
: c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
{active && <span className="ml-0.5 shrink-0">{sortDir === 'asc' ? '▲' : '▼'}</span>}
<ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)}
/>
</span>
);
})}
</div>
</div>
+2 -2
View File
@@ -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 quaucun 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 quelle 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 nest 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 nest 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 lordre 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',