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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user