Files
OpsLog/frontend/src/components/ClusterGrid.tsx
T
rouggy bd8719ce99 feat(cluster): chase switches for POTA and SOTA
An operator who does not chase parks does not want NEW POTA shouting from every
activator spot. Two switches (Settings → DX Cluster, on by default): off, the
marker is withdrawn at the display layer — applySpotDisplay, the same chokepoint
the grid, the band map and the filter read through — so the badge, the colour,
the filter chip and the reference column all go quiet together, and a new-band +
new-POTA spot reads NEW BAND alone. The facts keep being computed; only the
telling stops, so ticking the box back on needs no rescan.
2026-08-28 23:48:22 +02:00

818 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { markerColour } from '@/lib/spotMarkers';
import { applySpotDisplay, chasePota, chaseSota, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = hamlogGridTheme;
export type ClusterSpot = {
source_id: number;
source_name: string;
spotter: string;
dx_call: string;
freq_khz: number;
freq_hz: number;
band?: string;
comment?: string;
locator?: string;
time_utc?: string;
country?: string;
continent?: string;
cqz?: number;
ituz?: number;
distance_km?: number;
sp_deg?: number;
lp_deg?: number;
received_at: string;
raw: string;
repeats?: number;
pota_ref?: string;
sota_ref?: string;
pota_name?: string;
};
export type SpotStatusEntry = {
status?: string;
country?: string;
continent?: string;
worked_call?: boolean;
// worked_slot: this exact callsign already worked on THIS band and mode.
// Always slot-scoped, unlike worked_call which follows the "same slot" option.
worked_slot?: boolean;
new_county?: boolean;
// The resolved US county and state, so the grid can SHOW them and not merely
// flag them. Filled from the offline ULS store, US callsigns only.
county?: string;
state?: string;
new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean;
spotter_continent?: string;
grid?: string;
new_grid?: boolean;
grid_state?: string;
};
type Props = {
rows: ClusterSpot[];
spotStatus: Record<string, SpotStatusEntry>;
// A DOUBLE click works the spot: QSY, mode, callsign, the lot.
onSpotClick?: (s: ClusterSpot) => void;
// A SINGLE click only fills the callsign. Reading the cluster means passing
// over dozens of lines, and every pass used to drag the radio with it — one
// stray click while looking took the operator off the station they were
// working. Looking and going are now two different gestures.
onSpotSelect?: (s: ClusterSpot) => void;
// Anything the caller wants on the LEFT of the toolbar — the pane's title, its
// live count, its Filters button. Docked in a pane, the title used to sit on a
// row of its own above this one, so the cluster ate two lines of a short pane
// where Recent QSOs beside it ate one. Reported by VK4DX.
headerLeft?: React.ReactNode;
};
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
// Extracts the prefix from a callsign — drops portable suffixes (/P, /MM
// etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing
// digits after the last letter group (DL2SBY → DL2).
function fmtPfx(call: string): string {
if (!call) return '';
const c = call.trim().toUpperCase();
const base = c.includes('/') ? c.split('/')[0] : c;
// If "base" is a callsign rather than a bare prefix (like DL2SBY), cut
// at the last digit to get DL2.
let lastDigit = -1;
for (let i = 0; i < base.length; i++) {
if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
}
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}
// Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC
// "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style.
function fmtDateTimeUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return String(s);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
// worked-call) for an ag-Grid cell's row.
function statusFor(p: any): SpotStatusEntry | undefined {
const s = p?.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
];
// One chokepoint: the colour, the badges and the Status text all read through
// here, so the two display options are applied once rather than in each
// renderer — and the band map applies the very same function.
return applySpotDisplay(s, readSpotDisplayOptions());
}
// Spot status is shown on the CELL that carries the fact — filled background for
// a novelty, tinted text otherwise — never by a pill or a badge.
//
// The pills were dropped and stay dropped: a rounded box has its own height and
// padding, so it sat off the row's baseline and the callsign inside it no longer
// lined up with the plain callsigns above and below. A whole column of them read
// as chrome rather than as data. Same reasoning — and the same semantic tokens —
// as the Y/N/R QSL columns in lib/qslStatus.ts.
//
// Which cell is marked IS the message:
//
// filled call → new DXCC filled band → new band filled mode → new mode
// filled pfx → new prefix filled POTA → new park filled county → new county
// blue call → already worked (not a novelty, so text only)
const NEW = 'var(--warning)'; // yellow: something here is new
// NEW SLOT gets its own hue. It shared the amber NEW family while the NEW PFX
// marker sits in caution yellow — two different facts, two near-identical
// colours in the same cell. Sky cyan, the exact colour the panadapter palette
// ships for new-slot (#5AC8FA), so the two views tell one story — and clearly
// apart from the POTA green the first attempt (aqua) sat next to.
const NEWSLOT = '#5AC8FA';
const WKD = 'var(--info)'; // blue: this callsign is already in the log
// FILLING the cell that carries the fact, rather than only tinting its text.
//
// This is not a return to the pills that were dropped. A pill is a box INSIDE
// the cell: it has its own height and padding, so it sat off the row baseline
// and the callsign inside it stopped lining up with the plain callsigns above
// and below. A cell background has no geometry of its own — the text does not
// move by a single pixel — and it reads from across the room, which a tinted
// glyph does not.
//
// Which cell is filled is still the whole message: filled Band means new band,
// filled Pfx means new prefix, filled County means new county.
//
// The colours come from the same places they already came from — the semantic
// status tokens and lib/spotMarkers — so a fact keeps one colour across the
// grid, the band map and the filter chips. 22 % is the wash that survived both
// themes: enough to see at a glance, not enough to fight the text on it.
const fillStyle = (colour: string) => ({
background: `color-mix(in srgb, ${colour} 22%, transparent)`,
color: colour,
fontWeight: 700,
});
// cellText renders a cell value in an optional colour. An empty value keeps the
// muted dash the grid used before, so blank cells still read as "nothing here"
// rather than as a gap.
function cellText(value: any, color: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
}
// statusColor is the colour for a resolved status in the Status column. County,
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
// DXCC story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null {
switch (s?.status) {
case 'new':
case 'new-band-mode':
case 'new-band':
case 'new-mode':
case 'new-call':
return NEW;
case 'new-slot':
return NEWSLOT;
default:
return s?.worked_call ? WKD : null;
}
}
// isDull reports a spot that "represents nothing" under the current worked/slot
// rules: the entity/band/mode is already worked (status resolved, not new in any
// dimension), the callsign itself isn't in the log, and there's no POTA / county
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
// what counts as dull follows those settings automatically. Unresolved statuses
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
function isDull(s: SpotStatusEntry | undefined): boolean {
if (!s || !s.status) return false;
if (s.status === 'new' || s.status === 'new-band-mode' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
}
const makeColCatalog = (t: TFn): ColEntry[] => [
{
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true,
sort: 'desc',
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
// below "2359Z", so spots received just after midnight fell to the bottom
// and looked like the cluster had stopped. received_at is a full datetime
// that keeps ordering correct across 0000Z.
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
return ta - tb;
},
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// NEW DXCC fills the call cell. Already worked only tints the text blue:
// 'worked' is not a novelty, and filling it would wash most of the rows.
cellStyle: (p: any) => (statusFor(p)?.status === 'new' ? fillStyle('var(--danger)') : null) as any,
cellRenderer: (p: any) => {
const s = statusFor(p);
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
return (
<span style={{ color: color ?? undefined, fontWeight: 700 }}>
{p.value ?? ''}
{/* A single letter rather than a word: the column is 120 px and holds a
callsign. It stays the muted-blue of a confirmation, never a status
colour — whether a station uploads to LoTW says nothing about
whether the spot is worth chasing. */}
{s?.lotw ? (
<span title={t('clu.lotwBadge')} style={{
marginLeft: 4, padding: '0 3px', borderRadius: 3, fontSize: 9, verticalAlign: 'middle',
background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)',
}}>L</span>
) : null}
</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
},
},
{
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true,
// Spells the status out in words so NEW SLOT (and the others) is obvious at
// the row level, not just a single coloured cell — NEW SLOT in particular
// colours no cell at all, since neither the band nor the mode is new on its
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
valueGetter: (p: any) => {
const s = statusFor(p);
const parts: string[] = [];
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
else if (s?.status === 'new-band-mode') parts.push(t('clg2.newBandMode'));
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
else if (s?.status === 'new-call') parts.push(t('clg2.newCall'));
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
if (s?.new_grid) parts.push(s.grid_state === 'unconf' ? t('clg2.newGridUnconf') : t('clg2.newGrid'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const s = statusFor(p);
const parts: { text: string; color: string; dim?: boolean }[] = [];
const main = statusColor(s);
if (main) {
const label = s?.status === 'new' ? t('clg2.newDxcc')
: s?.status === 'new-band-mode' ? t('clg2.newBandMode')
: s?.status === 'new-band' ? t('clg2.newBand')
: s?.status === 'new-mode' ? t('clg2.newMode')
: s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
}
// Colours from lib/spotMarkers — shared with the band map so a marker is
// never one colour here and another there.
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
// held back, so it reads as "less" of the same thing rather than a
// different fact — and the label says which.
if (s?.new_grid) {
parts.push(s.grid_state === 'unconf'
? { text: t('clg2.newGridUnconf'), color: markerColour('new_grid'), dim: true }
: { text: t('clg2.newGrid'), color: markerColour('new_grid') });
}
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ whiteSpace: 'nowrap' }}>
{parts.map((pt, i) => (
<span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
{i > 0 ? ' · ' : ''}{pt.text}
</span>
))}
</span>
);
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
if (s?.status === 'new-band-mode') return t('clg2.tipNewBandMode');
if (s?.status === 'new-band') return t('clg2.tipNewBand');
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
if (s?.status === 'new-call') return t('clg2.tipNewCall');
if (s?.worked_call) return t('clg2.tipWorkedCall');
return undefined;
},
},
{
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
headerName: t('clg2.c.pota'), width: 92, cellClass: 'font-mono',
// Through a valueGetter so the chase switch empties the column live.
valueGetter: (p: any) => (chasePota() ? p.data?.pota_ref ?? '' : ''),
defaultVisible: true,
cellStyle: (p: any) => (statusFor(p)?.new_pota
? fillStyle(markerColour('new_pota'))
: { color: 'var(--success)' }) as any,
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
},
{
// SOTA sits next to POTA and reads the same way. The reference comes from the
// spot's comment, so it is present on the SOTA feeds and empty elsewhere —
// which is why the column is off by default rather than an empty column for
// everyone who does not watch summits.
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
headerName: t('clg2.c.sota'), width: 100, cellClass: 'font-mono',
valueGetter: (p: any) => (chaseSota() ? p.data?.sota_ref ?? '' : ''),
defaultVisible: false,
cellStyle: () => ({ color: 'var(--success)' }) as any,
},
{
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Spot', label: t('clg2.c.band'), colId: 'band',
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → the band cell is filled. NEW BAND+MODE fills
// it too: the band really is new, and leaving it plain would say the
// opposite of the status column beside it.
cellStyle: (p: any) => {
const st = statusFor(p)?.status;
return (st === 'new-band' || st === 'new-band-mode' ? fillStyle(NEW) : null) as any;
},
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
return st === 'new-band-mode' ? t('clg2.tipNewBandMode') : st === 'new-band' ? t('clg2.tipNewBand') : undefined;
},
},
{
group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
defaultVisible: true,
cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
// NEW MODE and NEW BAND+MODE pill the mode cell — in both the mode itself is
// genuinely new for the entity. NEW SLOT means band AND mode were each worked
// before (just not together), so highlighting the mode cell would wrongly
// imply "CW is new"; that case is signalled by the Status badge alone.
cellStyle: (p: any) => {
const st = statusFor(p)?.status;
return (st === 'new-mode' || st === 'new-band-mode' ? fillStyle('var(--caution)') : null) as any;
},
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-band-mode') return t('clg2.tipNewBandMode');
if (st === 'new-mode') return t('clg2.tipNewMode');
if (st === 'new-slot') return t('clg2.tipNewSlot');
return undefined;
},
},
{
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
// NEW PFX fills the prefix cell, in the prefix marker's own colour.
cellStyle: (p: any) => (statusFor(p)?.new_pfx
? fillStyle(markerColour('new_pfx'))
: { color: 'var(--muted-foreground)' }) as any,
tooltipValueGetter: (p: any) => (statusFor(p)?.new_pfx ? t('clg2.tipNewPfx') : undefined),
},
{
group: 'Geo', label: t('clg2.c.grid'), colId: 'grid',
headerName: t('clg2.c.grid'), width: 70, cellClass: 'font-mono',
// Not on the spot: the square a station announced in a CQ that THIS receiver
// decoded over the WSJT-X link, so it is filled for the digital watering
// hole being monitored and empty everywhere else. A cluster line carries the
// spotter's grid at best, never the DX's.
valueGetter: (p: any) => statusFor(p)?.grid ?? '',
cellStyle: (p: any) => (statusFor(p)?.new_grid ? fillStyle(markerColour('new_grid')) : null) as any,
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => (statusFor(p)?.new_grid ? t('clg2.tipNewGrid') : undefined),
},
{
group: 'Geo', label: t('clg2.c.county'), colId: 'county',
headerName: t('clg2.c.county'), width: 130, cellClass: 'font-mono',
// Not on the spot: resolved per callsign from the offline ULS store, so it
// stays empty for non-US calls and until that database is downloaded.
valueGetter: (p: any) => {
const s = statusFor(p);
if (!s?.county) return '';
return s.state ? s.county + ', ' + s.state : s.county;
},
cellStyle: (p: any) => (statusFor(p)?.new_county ? fillStyle(markerColour('new_county')) : null) as any,
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => (statusFor(p)?.new_county ? t('clg2.tipNewCounty') : undefined),
},
{
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
},
{
group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
},
{
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
// The header carries the unit, so the cells stay bare numbers and the
// column still sorts on the km the backend sent — converting the VALUE
// would sort miles as if they were kilometres either way, but it would
// also round twice.
headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
group: 'Spot', label: t('clg2.c.country'), colId: 'country',
headerName: t('clg2.c.country'), width: 140,
defaultVisible: true,
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.country ?? '',
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
defaultVisible: true,
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
]?.continent ?? '',
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
},
{
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
defaultVisible: true,
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
defaultVisible: true,
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
},
{
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
defaultVisible: true,
cellStyle: { color: 'var(--muted-foreground)' },
},
{
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
valueFormatter: (p) => fmtDateTimeUTC(p.value),
},
{
group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
},
];
const GROUP_ORDER = ['Spot', 'Geo'];
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, headerLeft }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Localized column catalog — rebuilt when the language changes.
const [distUnit, setDistUnit] = useState(distanceUnit);
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
// matching column events. Without this guard those events were persisted, so a
// single language change overwrote the saved cluster layout — in the cache AND
// in the database, with nothing to restore from.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
restoringRef.current = true;
return COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
});
}, [COL_CATALOG]);
// Re-apply the saved state after every rebuild, then re-enable saving.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(tm);
}, [columnDefs]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
// Dim "represents nothing" spots at the CELL level, via a class that
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
// every status update, which re-rendered the whole grid continuously and
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
}), []);
// Pass spotStatus through AG Grid's context so cell renderers can look up
// per-cell highlight without re-rendering the whole grid when the map
// updates. We refresh cells whose values depend on it after each prop
// change below.
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
// refresh so e.g. a worked call turns blue once its status loads.
//
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
// into one refresh every 200 ms (still imperceptible) instead of one per update.
const refreshPending = useRef<number | undefined>(undefined);
useEffect(() => {
if (refreshPending.current !== undefined) return; // a refresh is already queued
refreshPending.current = window.setTimeout(() => {
refreshPending.current = undefined;
gridRef.current?.api?.refreshCells({ force: true });
}, 200);
}, [spotStatus]);
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
// Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
// HOLDING THE LIST STILL WHILE IT IS BEING READ.
//
// A busy evening puts a spot on the list every second or two, each landing at
// the top and pushing everything below it down. At the top of the list that is
// exactly right — it is what makes the panel live. A few rows down it makes
// the panel unusable: the callsign under the pointer has moved by the time the
// click lands, and the operator ends up chasing the row they wanted.
//
// So the grid freezes as soon as it is scrolled away from the top, and says
// how many spots are waiting. Scrolling back to the top releases it, as does
// clicking the notice. Nothing is lost — the spots keep arriving in the shared
// list; this only decides when the GRID is allowed to redraw with them.
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
const shown = held ?? rows;
// How many arrived since the freeze — counted by TIME, not by finding the
// frozen top row again.
//
// Looking for that row was wrong in the ordinary case: a station spotted again
// REPLACES its row (that is the de-dupe), so the row we froze on disappears
// from the live list the moment somebody re-spots it — and the count fell
// through to "everything is new", jumping from 4 to the buffer cap. Reported
// as "it shows 4, 5 new spots and then 500 all at once".
//
// A timestamp survives both the replacement and the ring buffer, which was the
// reason the length was not used either.
const spotTime = (r: ClusterSpot) => Date.parse(String((r as any).received_at ?? '')) || 0;
const waiting = useMemo(() => {
if (!held || held.length === 0) return 0;
const since = spotTime(held[0]);
if (!since) return 0; // no usable timestamp — say nothing rather than a number
let n = 0;
for (const r of rows) {
if (spotTime(r) > since) n++;
}
return n;
}, [held, rows]);
const onBodyScroll = (e: { top: number }) => {
const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
if (!down && held) setHeld(null);
if (down && !held) setHeld(rows);
};
const release = () => {
setHeld(null);
gridRef.current?.api?.ensureIndexVisible(0, 'top');
};
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
if (e.data && onSpotSelect) onSpotSelect(e.data);
}
function handleRowDoubleClicked(e: RowDoubleClickedEvent<ClusterSpot>) {
if (e.data && onSpotClick) onSpotClick(e.data);
}
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
}
function setColVisible(colId: string, visible: boolean) {
const api = gridRef.current?.api;
if (!api) return;
api.setColumnsVisible([colId], visible);
saveColumnState();
}
function showAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, true);
saveColumnState();
}
function hideAll(group?: string) {
const api = gridRef.current?.api;
if (!api) return;
const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
api.setColumnsVisible(ids, false);
saveColumnState();
}
function resetDefaults() {
const api = gridRef.current?.api;
if (!api) return;
const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
api.setColumnsVisible(visible, true);
api.setColumnsVisible(hidden, false);
saveColumnState();
}
return (
<>
<div className="flex items-center gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
{headerLeft}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('clg2.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> {t('clg2.columns')}
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<ClusterSpot>
ref={gridRef}
theme={hamlogTheme}
rowData={shown}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
context={context}
onGridReady={onGridReady}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
onRowClicked={handleRowClicked}
onRowDoubleClicked={handleRowDoubleClicked}
onBodyScroll={onBodyScroll}
animateRows={false}
suppressCellFocus
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
/>
</div>
{/* Only when something is actually waiting: a frozen list with nothing
new to show needs no announcement. */}
{waiting > 0 && (
<button type="button" onClick={release}
className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
{t('clg2.newSpots', { n: waiting })}
</button>
)}
</div>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
<DialogDescription>
{t('clg2.pickerDesc')}
</DialogDescription>
</DialogHeader>
{/* Whole-dialog controls. showAll/hideAll with no group already mean
"every column"; only the buttons were missing, so hiding the lot
took one click per group. */}
<div className="flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
<span>{t('clg2.allGroups')}</span>
<button type="button" className="text-[11px] text-primary hover:underline px-1"
onClick={() => showAll()}>{t('clg2.all')}</button>
<span className="opacity-40">·</span>
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
onClick={() => hideAll()}>{t('clg2.none')}</button>
</div>
<div className="max-h-[60vh] overflow-y-auto py-2">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5 mb-2">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1">
{cols.map((c) => (
<label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox
checked={isColVisible(c.colId!)}
onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
/>
{c.label}
</label>
))}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}