This commit is contained in:
2026-05-28 11:09:07 +02:00
parent a8b7622667
commit d3c9982c66
8 changed files with 380 additions and 200 deletions
+184 -127
View File
@@ -1,13 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { spotStatusKey } from '@/lib/spot';
import { spotStatusKey, inferSpotMode } from '@/lib/spot';
// BandMap — vertical spectrum panel. Layout follows Log4OM's well-loved
// design: a kHz scale on the left, callsign labels stacked vertically on
// the right (one per line, no overlap), connected to their actual
// frequency on the scale by diagonal "leader" lines. Wheel-scroll for
// long spot lists, Ctrl+wheel to zoom.
// 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;
@@ -31,8 +34,6 @@ interface Props {
onClose?: () => void;
}
// Visible kHz range per band — covers IARU R1 plus a small pad so spots
// right at the edge are still drawn.
const BAND_RANGES: Record<string, [number, number]> = {
'160m': [1800, 2000],
'80m': [3500, 3800],
@@ -64,33 +65,63 @@ const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
};
function statusColor(s: string): { fg: string; line: string } {
// fg is the label text colour; line is the SVG stroke. Both follow the
// same NEW / NEW BAND / NEW SLOT / WORKED palette as the spot table.
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 { fg: 'text-rose-700', line: 'stroke-rose-500' };
case 'new-band': return { fg: 'text-amber-700', line: 'stroke-amber-500' };
case 'new-slot': return { fg: 'text-yellow-700', line: 'stroke-yellow-600' };
case 'worked': return { fg: 'text-muted-foreground', line: 'stroke-border' };
default: return { fg: 'text-emerald-700', line: 'stroke-emerald-600' };
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',
};
}
}
const ZOOMS = [1, 2, 4, 8, 16];
const SCALE_W = 56; // px — left freq scale column
const LINE_H = 18; // px — per-callsign row height
const LABEL_PAD_LEFT = 24; // px — diagonal line lands here, before the text
// 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
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) {
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(0);
const [center, setCenter] = useState<number | null>(null);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const innerRef = useRef<HTMLDivElement | null>(null);
const [containerH, setContainerH] = useState(400);
// Track the visible container height so we can stretch the scale.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
@@ -100,31 +131,55 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return () => ro.disconnect();
}, []);
// Window geometry.
const zoom = ZOOMS[zoomIdx];
const fallback: [number, number] = range ?? [0, 1];
const [bandLo, bandHi] = fallback;
const visSpan = (bandHi - bandLo) / zoom;
const c0 = center ?? (currentFreqHz > 0 ? currentFreqHz / 1000 : (bandLo + (bandHi - bandLo) / 2));
const c = clampCenter(c0, fallback, zoom);
const lo = c - visSpan / 2;
const hi = c + visSpan / 2;
const [lo, hi] = fallback;
const span = hi - lo;
const pxPerKHz = PX_PER_KHZ[zoomIdx];
// Filtered + sorted spots (highest freq first → top of the column).
const visible = useMemo(() => {
if (!range) return [];
return spots
.filter((s) => s.freq_khz >= lo && s.freq_khz <= hi)
.sort((a, b) => b.freq_khz - a.freq_khz);
}, [spots, lo, hi, range]);
// 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 } = useMemo<{ placed: Placed[]; totalH: 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 };
const seen = new Set<string>();
const filtered: 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);
filtered.push(s);
}
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
const out: Placed[] = [];
let prevY = -Infinity;
for (const s of filtered) {
const fy = TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH;
const ly = Math.max(fy, prevY + PILL_H);
out.push({ spot: s, freqY: fy, labelY: ly });
prevY = ly;
}
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),
};
}, [spots, range, lo, hi, span, pxPerKHz, containerH]);
// Total content height: stretch so every label has its own row, but
// never shrink below the visible container so the scale fills the box
// when there are few spots.
const totalH = Math.max(containerH, visible.length * LINE_H + 16);
// 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;
// Ctrl+wheel = zoom, regular wheel = native scroll (default browser).
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
@@ -132,7 +187,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setZoomIdx((z) => Math.max(0, Math.min(ZOOMS.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
}
};
el.addEventListener('wheel', onWheel, { passive: false });
@@ -148,29 +203,25 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
);
}
// Tick step adapts to visible kHz span so labels stay legible.
let step = 100;
if (span <= 1500) step = 50;
if (span <= 800) step = 25;
if (span <= 300) step = 10;
if (span <= 100) step = 5;
if (span <= 40) step = 2;
if (span <= 20) step = 1;
// 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 / step) * step; t <= hi; t += step) ticks.push(t);
// Y-axis convention: HIGH frequency at top, LOW at bottom (matches a
// physical receiver dial). freqToY maps a kHz to pixel-Y in totalH.
const freqToY = (kHz: number) => (1 - (kHz - lo) / span) * totalH;
for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t);
function recenterOnRig() {
if (currentFreqHz > 0) setCenter(clampCenter(currentFreqHz / 1000, range, zoom));
else setCenter(null);
// Also scroll to keep the rig pointer in view.
if (scrollerRef.current && currentFreqHz > 0) {
const y = freqToY(currentFreqHz / 1000);
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
}
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;
@@ -186,15 +237,15 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
title="Zoom out">
<Minus className="size-3" />
</button>
<span className="font-mono text-[10px] w-7 text-center">{zoom}×</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(ZOOMS.length - 1, z + 1))} disabled={zoomIdx === ZOOMS.length - 1}
<span className="font-mono text-[10px] w-12 text-center">{pxPerKHz}px/kHz</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title="Zoom in">
<Plus className="size-3" />
</button>
<button type="button" onClick={recenterOnRig}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
title="Center on current rig frequency">
title="Scroll to current rig frequency">
<Crosshair className="size-3" />
</button>
{onClose && (
@@ -207,8 +258,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
</div>
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
<div ref={innerRef} className="relative" style={{ height: totalH }}>
{/* Scale column background — full height, segments stretched */}
<div className="relative" style={{ height: totalH, minWidth: SCALE_W + PILL_GAP + LABEL_W }}>
{/* Scale background segments stretched to total height */}
<svg
className="absolute top-0 left-0 pointer-events-none"
width={SCALE_W}
@@ -216,19 +267,17 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
preserveAspectRatio="none"
>
{segments.map(([s, e, cls], i) => {
if (e < lo || s > hi) return null;
const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo));
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
})}
{/* Scale border */}
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg>
{/* Tick marks + labels on scale */}
{/* Tick marks + freq labels */}
{ticks.map((t) => {
const y = freqToY(t);
const major = t % (step * 5) === 0;
const major = t % labelStep === 0;
return (
<div key={t} className="absolute left-0 flex items-center pointer-events-none" style={{ top: y, transform: 'translateY(-50%)', width: SCALE_W }}>
<div className={cn('border-t', major ? 'w-full border-foreground/40' : 'w-3 border-border/60')} />
@@ -241,83 +290,91 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
);
})}
{/* SVG layer for leader lines + rig pointer */}
<svg
className="absolute inset-0 pointer-events-none"
width="100%"
height={totalH}
preserveAspectRatio="none"
>
{visible.map((s, i) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
{/* Dots on the scale + leader lines (always-on, subtle) + rig pointer */}
<svg className="absolute inset-0 pointer-events-none" width="100%" height={totalH} preserveAspectRatio="none">
{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 color = statusColor(st);
const fy = freqToY(s.freq_khz);
const ly = i * LINE_H + LINE_H / 2 + 8;
const style = statusStyle(st);
const labelMidY = p.labelY + PILL_H / 2;
const bumped = Math.abs(p.freqY - labelMidY) > 0.5;
return (
<line
key={`l-${i}-${s.dx_call}`}
x1={SCALE_W}
y1={fy}
x2={SCALE_W + LABEL_PAD_LEFT}
y2={ly}
className={color.line}
strokeWidth={1}
/>
<g key={`l-${i}-${p.spot.dx_call}`}>
{/* Small dot on the scale where the spot actually is */}
<circle cx={SCALE_W - 2} cy={p.freqY} r={3} className={style.dot} />
{/* 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". */}
<line
x1={SCALE_W + 1}
y1={p.freqY}
x2={SCALE_W + PILL_GAP - 2}
y2={labelMidY}
className={cn(style.line, bumped && 'opacity-60')}
strokeWidth={bumped ? 1 : 1.5}
strokeDasharray={bumped ? '2 2' : ''}
/>
</g>
);
})}
{showRigPointer && (
<>
{/* Triangle pointer + soft horizontal target line */}
<polygon
points={`${SCALE_W - 1},${rigY - 4} ${SCALE_W + 5},${rigY} ${SCALE_W - 1},${rigY + 4}`}
className="fill-primary"
points={`${SCALE_W - 6},${rigY - 5} ${SCALE_W + 1},${rigY} ${SCALE_W - 6},${rigY + 5}`}
className="fill-primary drop-shadow-sm"
/>
<line
x1={SCALE_W + 5}
x1={SCALE_W + 1}
y1={rigY}
x2="100%"
y2={rigY}
className="stroke-primary/40"
className="stroke-primary/30"
strokeWidth={1}
strokeDasharray="3 3"
strokeDasharray="4 4"
/>
</>
)}
</svg>
{/* Callsign label stack — one per line, sorted by freq desc */}
<div className="absolute" style={{ left: SCALE_W + LABEL_PAD_LEFT, top: 8, right: 0 }}>
{visible.map((s, i) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status ?? '';
const color = statusColor(st);
return (
<button
key={`${s.freq_khz}-${s.dx_call}-${i}`}
type="button"
onClick={() => onSpotClick(s)}
style={{ height: LINE_H, lineHeight: `${LINE_H}px` }}
className={cn(
'block w-full text-left px-1 font-mono text-[11px] font-bold hover:bg-accent/30 transition-colors whitespace-nowrap',
color.fg,
{/* 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 st = spotStatus[k]?.status ?? '';
const style = statusStyle(st);
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return (
<button
key={`${p.spot.freq_khz}-${p.spot.dx_call}-${i}`}
type="button"
onClick={() => onSpotClick(p.spot)}
style={{ top: p.labelY, left: SCALE_W + PILL_GAP, height: PILL_H }}
className={cn(
'absolute inline-flex items-stretch overflow-hidden rounded-md border shadow-sm cursor-pointer transition-all',
'hover:translate-x-0.5 hover:shadow',
style.pill,
)}
title={`${p.spot.dx_call} · ${p.spot.freq_khz.toFixed(1)} kHz${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
>
{/* Status accent strip on the left */}
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
<span>{p.spot.dx_call}</span>
{mode && (
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
{mode}
</span>
)}
title={`${s.dx_call} · ${s.freq_khz.toFixed(1)} kHz${s.comment ? ' · ' + s.comment : ''}${s.spotter ? ' · de ' + s.spotter : ''}`}
>
{s.dx_call}
</button>
);
})}
</div>
</span>
</button>
);
})}
</div>
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
scroll · ctrl+wheel = zoom · = recenter
scroll · ctrl+wheel = zoom · = jump to rig
</div>
</div>
);
}
function clampCenter(c: number, [lo, hi]: [number, number], zoom: number): number {
const halfSpan = (hi - lo) / zoom / 2;
return Math.max(lo + halfSpan, Math.min(hi - halfSpan, c));
}