import { useEffect, useMemo, useRef, useState } from 'react'; import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot'; // BandMap — vertical spectrum panel inspired by Log4OM. // - Full band is always visible; zoom changes pixels-per-kHz, scroll // navigates the band (so 16× lets you read each spot, not crops the // band to a 22 kHz slice). // - Labels sit at their actual frequency by default (straight horizontal // leader line). Only when two labels would visually overlap do we // bump the lower one down — its leader then becomes diagonal back to // the true frequency. interface Spot { source_id?: number; source_name?: string; dx_call: string; freq_khz: number; freq_hz: number; band?: string; comment?: string; spotter?: string; } type SpotStatusEntry = { status: string; country?: string }; interface Props { band: string; spots: Spot[]; spotStatus: Record; currentFreqHz: number; onSpotClick: (s: Spot) => void; onClose?: () => void; side?: 'left' | 'right'; onToggleSide?: () => void; } const BAND_RANGES: Record = { '160m': [1800, 2000], '80m': [3500, 3800], '60m': [5350, 5450], '40m': [7000, 7200], '30m': [10100, 10150], '20m': [14000, 14350], '17m': [18068, 18168], '15m': [21000, 21450], '12m': [24890, 24990], '10m': [28000, 29700], '6m': [50000, 50500], '4m': [70000, 70500], '2m': [144000, 146000], '70cm': [430000, 440000], }; const SEGMENT_COLORS: Record = { '160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']], '80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']], '60m': [[5350, 5450, 'fill-amber-50']], '40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']], '30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']], '20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']], '17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']], '15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']], '12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']], '10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']], '6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']], }; // Small coloured dot + label used in the band-map legend strip. function LegendDot({ cls, label }: { cls: string; label: string }) { return ( {label} ); } // Human-readable label for a spot status — used in the pill hover tooltip // so the operator can see WHY a spot is coloured the way it is. function statusLabel(s: string): string { switch (s) { case 'new': return 'NEW DXCC (entity never worked)'; case 'new-band': return 'NEW BAND (entity not worked on this band)'; case 'new-slot': return 'NEW SLOT (mode not worked on this band)'; case 'worked': return 'Worked (this band + mode already in log)'; default: return 'Entity not resolved'; } } function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } { // pill = full pill background+text+border // bar = thick left accent inside the pill // line = SVG leader stroke (visible on hover) // dot = small marker on the freq scale switch (s) { case 'new': return { pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100', bar: 'bg-rose-500', line: 'stroke-rose-400', dot: 'fill-rose-500', }; case 'new-band': return { pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100', bar: 'bg-amber-500', line: 'stroke-amber-400', dot: 'fill-amber-500', }; case 'new-slot': return { pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100', bar: 'bg-yellow-500', line: 'stroke-yellow-500', dot: 'fill-yellow-500', }; case 'worked': return { pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50', bar: 'bg-muted-foreground/30', line: 'stroke-border', dot: 'fill-border', }; default: return { pill: 'bg-card text-foreground border-border hover:bg-accent/40', bar: 'bg-primary/60', line: 'stroke-primary/50', dot: 'fill-primary/60', }; } } // Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot // every 2.75 kHz fits without anti-overlap kicking in — comfortable for // most bands. Higher levels are for fine inspection of crowded sub-bands. const PX_PER_KHZ = [8, 16, 32, 64, 128, 256]; const SCALE_W = 56; const PILL_H = 22; // px — height of each callsign pill const PILL_GAP = 32; // px between scale border and first pill (room for leader) const LABEL_W = 200; const TOP_PAD = 14; // px of breathing room above/below the band edges so const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0 // Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once. // These pile up on the watering-hole frequencies and otherwise spawn // hundreds of spots that fan out and cover the whole map. ONLY digital is // capped — CW and SSB are always shown in full. When more than this digital // spots are in band we keep the most useful (new entities first, worked // last; ties broken by closeness to the rig freq). const MAX_VISIBLE_SPOTS = 30; export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) { const range = BAND_RANGES[band]; const segments = SEGMENT_COLORS[band] ?? []; const [zoomIdx, setZoomIdx] = useState(0); const scrollerRef = useRef(null); const [containerH, setContainerH] = useState(400); useEffect(() => { const el = scrollerRef.current; if (!el) return; const ro = new ResizeObserver(() => setContainerH(el.clientHeight)); ro.observe(el); setContainerH(el.clientHeight); return () => ro.disconnect(); }, []); const fallback: [number, number] = range ?? [0, 1]; const [lo, hi] = fallback; const span = hi - lo; const pxPerKHz = PX_PER_KHZ[zoomIdx]; // Anti-overlap layout: each label wants to sit at its true freq, but // never closer than PILL_H from the previous one. Sorted top-to-bottom // (highest freq first). Dedup by callsign (latest wins) so multi-cluster // duplicates don't stack identical pills. // // Returns placed labels + the required content height (the natural // band height OR the bottom of the last bumped label, whichever is // larger). When more labels stack than fit in the band's natural pixel // span, totalH grows so scrolling reveals them. type Placed = { spot: Spot; freqY: number; labelY: number }; const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => { // innerH is the band's stretched pixel span; total adds top+bottom // padding so the edge freq labels aren't clipped at y=0 / y=H. const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 }; const seen = new Set(); const inBand: Spot[] = []; for (const s of spots) { if (s.freq_khz < lo || s.freq_khz > hi) continue; if (seen.has(s.dx_call)) continue; seen.add(s.dx_call); inBand.push(s); } // Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…) // are capped — they're what floods the watering-hole frequencies. We key // off the mode CATEGORY (not a literal "FT8" string) because many FT8 // spots carry no mode word and the band-plan fallback labels them the // generic "DATA" rather than "FT8". CW and SSB are always shown in full. const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA'; const ftSpots = inBand.filter(isFlood); const otherSpots = inBand.filter((s) => !isFlood(s)); // Rank a DATA spot by usefulness (new entity → unworked → worked); ties // break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS. const rank = (s: Spot) => { const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); switch (spotStatus[k]?.status ?? '') { case 'new': return 0; case 'new-band': return 1; case 'new-slot': return 2; case 'worked': return 4; default: return 3; } }; let keptFt = ftSpots; let hiddenCount = 0; if (ftSpots.length > MAX_VISIBLE_SPOTS) { const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2; keptFt = [...ftSpots] .sort((a, b) => { const r = rank(a) - rank(b); if (r !== 0) return r; return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK); }) .slice(0, MAX_VISIBLE_SPOTS); hiddenCount = ftSpots.length - keptFt.length; } const filtered = [...otherSpots, ...keptFt]; filtered.sort((a, b) => b.freq_khz - a.freq_khz); // Desired pill-CENTRE Y for each spot = its true frequency's Y. const desired = filtered.map( (s) => TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH, ); // Non-overlapping label placement via isotonic regression (pool- // adjacent-violators). We want centres c_0 ≤ c_1 ≤ … with // c_{i+1} − c_i ≥ PILL_H, minimising the squared displacement from each // label's desired centre. Substituting q_i = c_i − i·PILL_H turns the // gap constraint into "q non-decreasing", which PAVA solves exactly in // one pass. The win over the old greedy push-down: a tight cluster is // centred on its mean, so its labels fan out symmetrically ABOVE and // below the frequency (Log4OM style) instead of all spilling downward. const n = filtered.length; type Block = { sum: number; count: number; start: number }; const blocks: Block[] = []; for (let i = 0; i < n; i++) { // e_i = desired_i − i·PILL_H is the target for the substituted q. let cur: Block = { sum: desired[i] - i * PILL_H, count: 1, start: i }; while (blocks.length > 0) { const prev = blocks[blocks.length - 1]; if (prev.sum / prev.count <= cur.sum / cur.count) break; blocks.pop(); cur = { sum: prev.sum + cur.sum, count: prev.count + cur.count, start: prev.start }; } blocks.push(cur); } const centers = new Array(n); for (const b of blocks) { const mean = b.sum / b.count; // optimal q for the whole block for (let i = b.start; i < b.start + b.count; i++) centers[i] = mean + i * PILL_H; } // Centres are non-decreasing, so centers[0] is the topmost. Shift the // whole set down by any overflow above the band edge so the first label // isn't clipped (preserves the ≥ PILL_H spacing). const shift = n > 0 ? Math.max(0, TOP_PAD - (centers[0] - PILL_H / 2)) : 0; const out: Placed[] = []; for (let i = 0; i < n; i++) { out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 }); } const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0; return { placed: out, totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD), hidden: hiddenCount, }; }, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]); // freqToY for elements rendered outside the memo (ticks, rig pointer). // Must mirror the same offset so the rig triangle sits on the right kHz. const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz); const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH; // Auto-centre on the rig frequency when the map opens or the band changes // (once per band, so it doesn't fight the user's manual scrolling). Waits // for the scroller height to be measured and a valid in-band rig freq. const centeredForRef = useRef(''); useEffect(() => { if (!range || containerH <= 0 || currentFreqHz <= 0) return; const kHz = currentFreqHz / 1000; if (kHz < lo || kHz > hi) return; if (centeredForRef.current === band) return; const el = scrollerRef.current; if (!el) return; centeredForRef.current = band; el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2); // freqToY is recomputed each render; intentionally excluded from deps. // eslint-disable-next-line react-hooks/exhaustive-deps }, [band, containerH, currentFreqHz, range, lo, hi]); useEffect(() => { const el = scrollerRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (!range) return; if (e.ctrlKey || e.metaKey) { e.preventDefault(); setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1)))); } }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [range]); if (!range) { return (
Band map
Not configured for {band || '—'}.
); } // Tick step (where small marks land) and label step (where the kHz // number is printed) are decoupled so the scale shows ~10-20 numeric // labels per viewport regardless of zoom — at base 8 px/kHz we want // labels every 25 kHz, not every 250. let tickStep = 50; let labelStep = 100; if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; } if (pxPerKHz >= 8) { tickStep = 5; labelStep = 25; } if (pxPerKHz >= 16) { tickStep = 2; labelStep = 10; } if (pxPerKHz >= 32) { tickStep = 1; labelStep = 5; } if (pxPerKHz >= 64) { tickStep = 1; labelStep = 2; } if (pxPerKHz >= 128) { tickStep = 1; labelStep = 1; } const ticks: number[] = []; for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t); function recenterOnRig() { if (!scrollerRef.current || currentFreqHz <= 0) return; const y = freqToY(currentFreqHz / 1000); scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2); } const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0; const showRigPointer = currentKHz >= lo && currentKHz <= hi; const rigY = freqToY(currentKHz); return (
Map · {band} {pxPerKHz}px/kHz {onToggleSide && ( )} {onClose && ( )}
{/* Scale background segments — stretched to total height */} {segments.map(([s, e, cls], i) => { const y1 = freqToY(Math.min(e, hi)); const y2 = freqToY(Math.max(s, lo)); return ; })} {/* Tick marks + freq labels */} {ticks.map((t) => { const y = freqToY(t); const major = t % labelStep === 0; return (
{major && ( {t.toLocaleString()} )}
); })} {/* Dots on the scale + leader lines (always-on, subtle) + rig pointer */} {placed.map((p, i) => { const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz); const st = spotStatus[k]?.status ?? ''; const style = statusStyle(st); const labelMidY = p.labelY + PILL_H / 2; const bumped = Math.abs(p.freqY - labelMidY) > 0.5; return ( {/* Small dot on the scale where the spot actually is */} {/* Leader line — solid+full opacity when straight, dashed+lower opacity when diagonal (bumped) so the eye distinguishes "this is the real freq" from "this label was nudged to fit". */} ); })} {showRigPointer && ( <> {/* Triangle pointer + soft horizontal target line */} )} {/* Pills absolutely positioned at their (anti-overlapped) Y */} {placed.map((p, i) => { const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz); const entry = spotStatus[k]; const st = entry?.status ?? ''; const style = statusStyle(st); const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz); return ( ); })}
{/* Colour legend — what each pill colour means. */}
scroll · ctrl+wheel = zoom · ◎ = jump to rig {hidden > 0 && · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden — top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)}
); }