rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+434
View File
@@ -0,0 +1,434 @@
import { useCallback, 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 } 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';
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;
};
export type SpotStatusEntry = {
status?: string;
country?: string;
continent?: string;
worked_call?: boolean;
};
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 };
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,
cellRenderer: (p: any) => {
if (!p.value) return '';
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
];
const isNew = status?.status === 'new';
const workedCall = !!status?.worked_call;
const style: any = {
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
};
if (isNew) {
style.backgroundColor = '#ffe4e6';
style.color = '#9f1239';
style.border = '1px solid #fda4af';
} else if (workedCall) {
style.color = '#0369a1';
} else {
style.color = '#b8410c';
}
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
},
},
{
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: 'flex items-center',
cellRenderer: (p: any) => {
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
];
const newBand = status?.status === 'new-band';
const bg = newBand ? '#fde68a' : '#f0d9a8';
const fg = newBand ? '#92400e' : '#7a4a14';
return p.value
? <span
style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: bg, color: fg, lineHeight: '16px',
border: newBand ? '1px solid #f59e0b' : undefined,
}}
title={newBand ? 'NEW BAND for this entity' : undefined}
>{p.value}</span>
: '';
},
},
{
group: 'Spot', label: 'Mode', colId: 'mode',
headerName: 'Mode', colSpan: undefined, width: 80,
defaultVisible: true,
cellClass: 'flex items-center',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
cellRenderer: (p: any) => {
const status: SpotStatusEntry | undefined = p.context?.spotStatus?.[
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
];
const newSlot = status?.status === 'new-slot';
const bg = newSlot ? '#fef08a' : '#d1fae5';
const fg = newSlot ? '#854d0e' : '#047857';
return p.value
? <span
style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: bg, color: fg, lineHeight: '16px',
border: newSlot ? '1px solid #eab308' : undefined,
}}
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
>{p.value}</span>
: <span style={{ color: '#a8a29e', fontSize: 10 }}></span>;
},
},
{
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<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), []);
const defaultColDef = useMemo<ColDef>(() => ({
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]);
function onGridReady(e: GridReadyEvent) {
try {
const raw = localStorage.getItem(COL_STATE_KEY);
if (raw) {
const state = JSON.parse(raw) as ColumnState[];
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true });
}
} catch {}
}
const saveColumnState = useCallback(() => {
try {
const state = gridRef.current?.api?.getColumnState();
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []);
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={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> 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>Cluster columns</DialogTitle>
<DialogDescription>
Pick the columns you want visible in the Cluster table.
</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">{group}</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>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}>Reset to defaults</Button>
<Button size="sm" onClick={() => setPickerOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}