import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, } from 'ag-grid-community'; 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 } from '@/lib/gridPrefs'; ModuleRegistry.registerModules([AllCommunityModule]); const hamlogTheme = themeQuartz.withParams({ fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419', headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600, oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8', borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 }, cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12, }); 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; }; type Props = { rows: ClusterSpot[]; spotStatus: Record; 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 & { 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) ]; } // statusBadge maps a resolved spot status to a short labelled badge for the // Status column, using the same colours as the per-cell fills (NEW DXCC = // call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when // there's nothing notable to show. function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null { switch (s?.status) { case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' }; case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' }; case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' }; case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' }; default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null; } } const COL_CATALOG: ColEntry[] = [ { group: 'Spot', label: 'Time', colId: 'time', headerName: 'Time', field: 'time_utc' as any, width: 80, cellClass: 'font-mono', defaultVisible: true, sort: 'desc', cellStyle: { color: '#7a6b50' }, }, { group: 'Spot', label: 'Call', colId: 'call', headerName: 'Call', field: 'dx_call' as any, width: 120, defaultVisible: true, cellClass: 'font-mono', // New DXCC entity → fill the whole cell (no padded pill, so calls stay // aligned with non-new rows). Text colour also flags worked-call vs new-call. cellStyle: (p: any): any => (statusFor(p)?.status === 'new' ? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 } : { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }), tooltipValueGetter: (p: any) => { const s = statusFor(p); return s?.status === 'new' ? `NEW DXCC: ${s?.country ?? ''}` : s?.worked_call ? 'Already worked this call' : undefined; }, }, { group: 'Spot', label: 'Status', colId: 'status', headerName: 'Status', width: 96, sortable: true, defaultVisible: true, // Spells out the slot status as a text badge so NEW SLOT (and the others) // is obvious at the row level, not just a single coloured cell. valueGetter: (p: any) => { const s = statusFor(p); if (s?.status === 'new') return 'NEW DXCC'; if (s?.status === 'new-band') return 'NEW BAND'; if (s?.status === 'new-mode') return 'NEW MODE'; if (s?.status === 'new-slot') return 'NEW SLOT'; return s?.worked_call ? 'WKD CALL' : ''; }, cellRenderer: (p: any) => { const b = statusBadge(statusFor(p)); if (!b) return ; return ( {b.text} ); }, tooltipValueGetter: (p: any) => { const s = statusFor(p); if (s?.status === 'new') return `NEW DXCC: ${s?.country ?? ''}`; if (s?.status === 'new-band') return 'NEW BAND for this entity'; if (s?.status === 'new-slot') return 'NEW SLOT (mode not yet worked on this band)'; if (s?.worked_call) return 'Already worked this call'; return undefined; }, }, { group: 'Spot', label: 'POTA', colId: 'pota', headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono', defaultVisible: true, cellStyle: { color: '#166534' }, tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined), }, { group: 'Spot', label: 'Freq', colId: 'freq', headerName: '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: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, defaultVisible: true, cellClass: 'font-mono', // NEW BAND for this entity → fill the cell (keeps the band text aligned). cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' ? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 } : undefined), tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? 'NEW BAND for this entity' : undefined), }, { group: 'Spot', label: 'Mode', colId: 'mode', headerName: 'Mode', colSpan: undefined, width: 80, defaultVisible: true, cellClass: 'font-mono', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', // Fill the mode cell: teal = NEW MODE (mode never worked on this entity), // yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere). cellStyle: (p: any) => { const st = statusFor(p)?.status; // Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the // Status badge text tells them apart. if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }; return undefined; }, cellRenderer: (p: any) => p.value ? p.value : , tooltipValueGetter: (p: any) => { const st = statusFor(p)?.status; if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)'; if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)'; return undefined; }, }, { group: 'Spot', label: 'Pfx', colId: 'pfx', headerName: 'Pfx', width: 60, cellClass: 'font-mono', valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''), cellStyle: { color: '#7a6b50' }, }, { group: 'Geo', label: 'CQ Zone', colId: 'cqz', headerName: 'CQZ', field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => p.value ? String(p.value) : '', }, { group: 'Geo', label: 'ITU Zone', colId: 'ituz', headerName: 'ITU', field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono', valueFormatter: (p) => p.value ? String(p.value) : '', }, { group: 'Geo', label: 'Distance (km)', colId: 'distance_km', headerName: 'Dist 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: 'Short path (°)', colId: 'sp_deg', headerName: 'SP°', 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: 'Long path (°)', colId: 'lp_deg', headerName: 'LP°', 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: 'Country', colId: 'country', headerName: '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: '#7a6b50' }, }, { group: 'Spot', label: 'Continent', colId: 'continent', headerName: 'Cont', 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: '#7a6b50', fontSize: 10 }, }, { group: 'Spot', label: 'Spotter', colId: 'spotter', headerName: 'Spotter', field: 'spotter' as any, width: 100, cellClass: 'font-mono', defaultVisible: true, valueFormatter: (p) => cleanSpotter(p.value ?? ''), cellStyle: { color: '#7a6b50' }, }, { group: 'Spot', label: 'Source', colId: 'source', headerName: 'Source', field: 'source_name' as any, width: 100, defaultVisible: true, cellStyle: { color: '#9a8870', fontSize: 10 }, }, { group: 'Spot', label: 'Locator', colId: 'locator', headerName: 'Loc', field: 'locator' as any, width: 80, cellClass: 'font-mono', cellStyle: { color: '#7a6b50' }, }, { group: 'Spot', label: 'Comment', colId: 'comment', headerName: 'Comment', field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true, cellStyle: { color: '#7a6b50' }, }, { group: 'Spot', label: 'Received at', colId: 'received_at', headerName: 'Received UTC', field: 'received_at' as any, width: 160, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTimeUTC(p.value), }, { group: 'Spot', label: 'Raw', colId: 'raw', headerName: 'Raw', field: 'raw' as any, width: 300, cellClass: 'font-mono', }, ]; const GROUP_ORDER = ['Spot', 'Geo']; export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const columnDefs = useMemo[]>(() => COL_CATALOG.map((c) => { const { group: _g, label: _l, defaultVisible, ...rest } = c; return { ...rest, hide: !defaultVisible }; }), []); const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, filter: true, suppressMovable: false, }), []); // 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 depend on them but their cell VALUE doesn't change, so ag-grid // won't re-render those cells on its own — force a refresh so e.g. a worked call // turns blue once its status loads. useEffect(() => { gridRef.current?.api?.refreshCells({ force: true }); }, [spotStatus]); function onGridReady(e: GridReadyEvent) { const local = loadLocal(COL_STATE_KEY); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); loadRemote(COL_STATE_KEY).then((remote) => { if (remote && !local) { e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); seedLocal(COL_STATE_KEY, remote); } }); } const saveColumnState = useCallback(() => { const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state); }, []); function handleRowClicked(e: RowClickedEvent) { 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 ( <>
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}`} />
Cluster columns Pick the columns you want visible in the Cluster table.
{GROUP_ORDER.map((group) => { const cols = COL_CATALOG.filter((c) => c.group === group); if (cols.length === 0) return null; return (
{group}
{cols.map((c) => ( ))}
); })}
); }