spotStatus updates ~20x/second under an RBN firehose; firing a full refreshCells on each was pure churn on a slow PC. Coalesce into one refresh per 200ms. Keeps the NEW/WORKED badge colours and the 'represents nothing' dimming — the dimming itself is a trivial pure read now that the worked-index scan is cached, so it's no longer a cost worth removing.
579 lines
25 KiB
TypeScript
579 lines
25 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
AllCommunityModule, ModuleRegistry,
|
||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||
} 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 { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||
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;
|
||
pota_name?: string;
|
||
};
|
||
|
||
export type SpotStatusEntry = {
|
||
status?: string;
|
||
country?: string;
|
||
continent?: string;
|
||
worked_call?: boolean;
|
||
new_county?: boolean;
|
||
new_pota?: boolean;
|
||
new_pfx?: boolean;
|
||
pfx?: string;
|
||
};
|
||
|
||
type Props = {
|
||
rows: ClusterSpot[];
|
||
spotStatus: Record<string, SpotStatusEntry>;
|
||
onSpotClick?: (s: ClusterSpot) => void;
|
||
};
|
||
|
||
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 {
|
||
return p?.context?.spotStatus?.[
|
||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||
];
|
||
}
|
||
|
||
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
|
||
//
|
||
// The pills were 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 coloured IS the message, so one colour is enough for all three:
|
||
//
|
||
// yellow call → new DXCC yellow band → new band yellow mode → new mode
|
||
// blue call → already worked
|
||
const NEW = 'var(--warning)'; // yellow: something here is new
|
||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||
|
||
// 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':
|
||
case 'new-mode':
|
||
case 'new-slot':
|
||
return NEW;
|
||
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' || s.status === 'new-mode' || s.status === 'new-slot') return false;
|
||
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
|
||
}
|
||
|
||
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 → yellow call. Already worked → blue call. Anything else keeps
|
||
// the theme's normal ink so ordinary callsigns don't shout.
|
||
cellRenderer: (p: any) => {
|
||
const s = statusFor(p);
|
||
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
|
||
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</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') 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?.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'));
|
||
return parts.join(' ');
|
||
},
|
||
cellRenderer: (p: any) => {
|
||
const s = statusFor(p);
|
||
const parts: { text: string; color: string }[] = [];
|
||
const main = statusColor(s);
|
||
if (main) {
|
||
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||
: s?.status === 'new-band' ? t('clg2.newBand')
|
||
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||
: t('clg2.wkdCall');
|
||
parts.push({ text: label, color: main });
|
||
}
|
||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
|
||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
|
||
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 }}>
|
||
{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') return t('clg2.tipNewBand');
|
||
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
|
||
if (s?.worked_call) return t('clg2.tipWorkedCall');
|
||
return undefined;
|
||
},
|
||
},
|
||
{
|
||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||
defaultVisible: true,
|
||
cellStyle: { color: 'var(--success)' },
|
||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||
},
|
||
{
|
||
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 text turns yellow.
|
||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
|
||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === '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) : '',
|
||
// Only NEW MODE pills the mode cell — there 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.
|
||
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
|
||
tooltipValueGetter: (p: any) => {
|
||
const st = statusFor(p)?.status;
|
||
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 ?? ''),
|
||
cellStyle: { color: 'var(--muted-foreground)' },
|
||
},
|
||
{
|
||
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',
|
||
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||
valueFormatter: (p) => p.value ? String(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 }: Props) {
|
||
const { t } = useI18n();
|
||
const gridRef = useRef<any>(null);
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
|
||
// Localized column catalog — rebuilt when the language changes.
|
||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||
|
||
// 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);
|
||
}, []);
|
||
|
||
function handleRowClicked(e: RowClickedEvent<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 justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||
<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={rows}
|
||
columnDefs={columnDefs}
|
||
defaultColDef={defaultColDef}
|
||
context={context}
|
||
onGridReady={onGridReady}
|
||
onColumnResized={saveColumnState}
|
||
onColumnMoved={saveColumnState}
|
||
onColumnPinned={saveColumnState}
|
||
onColumnVisible={saveColumnState}
|
||
onSortChanged={saveColumnState}
|
||
onRowClicked={handleRowClicked}
|
||
animateRows={false}
|
||
suppressCellFocus
|
||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
|
||
<DialogDescription>
|
||
{t('clg2.pickerDesc')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<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>
|
||
</>
|
||
);
|
||
}
|