Statistics — the activity chart ignored the period selector: picking a year up top left it showing the last 7 days, two controls contradicting each other. The buttons were four fixed rolling windows anchored on the newest QSO, not granularities. They now choose the BUCKET SIZE over the selected period, with an Auto default and a step-up when a bucket would be too fine (the backend drops a series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day histogram covered a single day, too little to read anything from; it moves to its own card with a weekday view, over the whole period. Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the same amber that means "new band" on the pills drawn on top of them. A sub-band is an identity, not a state: categorical hues now, validated in both themes (violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and added to the legend, since identity must never be colour-alone. Frameless window — the OS title bar was a dead 32px band above a window that already has its own. Minimise/maximise/close move into the app header, which carries the drag region and double-click-to-maximise. The drag opt-out is by ROLE in CSS, so a future button in that bar keeps working without anyone remembering the rule. Fixes: - Saving settings froze the window while a device was slow: restarting a link waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM Connect when another program holds the rig. The restart is now off the UI path; a slow one is logged. - Multi-screen: a MAXIMISED window was never repositioned, so it came back on the primary screen every launch. The corner is now recorded even when maximised (it names the monitor) and re-applied while still hidden. - The Callsign field is marked out at rest — operators were typing the call into Name, which sat beside it and looked identical. - QSL dates get a real picker (native, for the OS calendar and locale order); a malformed old value stays in a text box rather than vanishing behind an empty one. Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2 report (one element then a ten-second pause, sidetone that will not switch off) is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would break the operators it works for today. The trace logs every byte with the command name and spells out the firmware family, which is what will settle it.
601 lines
28 KiB
TypeScript
601 lines
28 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import { useI18n } from '@/lib/i18n';
|
||
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<string, SpotStatusEntry>;
|
||
currentFreqHz: number;
|
||
onSpotClick: (s: Spot) => void;
|
||
onClose?: () => void;
|
||
side?: 'left' | 'right';
|
||
onToggleSide?: () => void;
|
||
// hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
|
||
// watering holes don't hog the map. fitToBand overrides zoom so the whole
|
||
// band edge-to-edge fits the visible height (no scrolling). Both are driven
|
||
// globally from the band-map tab toolbar.
|
||
hideDigital?: boolean;
|
||
fitToBand?: boolean;
|
||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||
keyNav?: boolean;
|
||
}
|
||
|
||
const BAND_RANGES: Record<string, [number, number]> = {
|
||
'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],
|
||
};
|
||
|
||
// Sub-band shading: CW / digital / phone.
|
||
//
|
||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
||
// categorical hues — never the status tokens. They used to use success / info /
|
||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
||
// a giant "new band" wash. Status colours are reserved for status.
|
||
//
|
||
// All three are drawn from the COOL end of the categorical order and laid down at
|
||
// low opacity, so the band plan stays background context while the warm spot pills
|
||
// keep the foreground to themselves.
|
||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
||
const SEG_CW = 'var(--chart-7)'; // magenta
|
||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||
// are read on top of it.
|
||
const SEG_OPACITY = 0.13;
|
||
|
||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
||
'60m': [[5350, 5450, SEG_PHONE]],
|
||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
||
};
|
||
|
||
// Small coloured dot + label used in the band-map legend strip.
|
||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// 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, t: (k: string) => string): string {
|
||
switch (s) {
|
||
case 'new': return t('bmp.statusNew');
|
||
case 'new-band': return t('bmp.statusNewBand');
|
||
case 'new-slot': return t('bmp.statusNewSlot');
|
||
case 'worked': return t('bmp.statusWorked');
|
||
default: return t('bmp.statusUnresolved');
|
||
}
|
||
}
|
||
|
||
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-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||
bar: 'bg-danger',
|
||
line: 'stroke-danger',
|
||
dot: 'fill-danger',
|
||
};
|
||
case 'new-band': return {
|
||
pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
|
||
bar: 'bg-warning',
|
||
line: 'stroke-warning',
|
||
dot: 'fill-warning',
|
||
};
|
||
case 'new-slot': return {
|
||
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||
bar: 'bg-caution',
|
||
line: 'stroke-caution',
|
||
dot: 'fill-caution',
|
||
};
|
||
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, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||
const { t } = useI18n();
|
||
const range = BAND_RANGES[band];
|
||
const segments = SEGMENT_COLORS[band] ?? [];
|
||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
||
const scrollerRef = useRef<HTMLDivElement | null>(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;
|
||
// Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
|
||
// the visible height; otherwise use the discrete zoom step.
|
||
const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
|
||
? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
|
||
: 0;
|
||
const pxPerKHz = fitPxPerKHz || 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<string>();
|
||
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';
|
||
// hideDigital removes them entirely; otherwise they're capped below.
|
||
const ftSpots = hideDigital ? [] : 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<number>(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;
|
||
// In fit mode the band scale exactly fills the viewport (innerH = containerH
|
||
// − pads); when crowded sub-bands stack labels past the bottom the content
|
||
// grows and a scroll bar appears — the map defaults to the top so the whole
|
||
// band is visible, and you scroll down to reach the stacked spots.
|
||
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, hideDigital]);
|
||
|
||
// 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;
|
||
|
||
// Keep the map centred on the rig frequency: the effect re-runs only when the
|
||
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
||
// while a stable frequency leaves your manual scroll alone.
|
||
useEffect(() => {
|
||
if (!range || containerH <= 0) return;
|
||
const el = scrollerRef.current;
|
||
if (!el) return;
|
||
// Fit-to-band: keep the view at the top so the full band is always in frame;
|
||
// any overflow spots are reached by scrolling down (don't follow the rig).
|
||
if (fitToBand) { el.scrollTop = 0; return; }
|
||
if (currentFreqHz <= 0) return;
|
||
const kHz = currentFreqHz / 1000;
|
||
if (kHz < lo || kHz > hi) return;
|
||
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, pxPerKHz, fitToBand]);
|
||
|
||
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 (
|
||
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
|
||
<div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
|
||
{t('bmp.notConfigured', { band: band || '—' })}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||
useEffect(() => {
|
||
if (!keyNav) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||
const ae = document.activeElement as HTMLElement | null;
|
||
const tag = (ae?.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||
const list = spots
|
||
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||
.slice()
|
||
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||
if (!list.length) return;
|
||
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||
let target: Spot | undefined;
|
||
if (e.key === 'ArrowUp') {
|
||
target = list.find((s) => s.freq_hz > cur + EPS);
|
||
} else {
|
||
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||
}
|
||
if (!target) return;
|
||
e.preventDefault();
|
||
onSpotClick(target);
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||
|
||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||
const rigY = freqToY(currentKHz);
|
||
|
||
return (
|
||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||
title={t('bmp.zoomOut')}>
|
||
<Minus className="size-3" />
|
||
</button>
|
||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||
title={t('bmp.zoomIn')}>
|
||
<Plus className="size-3" />
|
||
</button>
|
||
<button type="button" onClick={recenterOnRig}
|
||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||
title={t('bmp.scrollToRig')}>
|
||
<Crosshair className="size-3" />
|
||
</button>
|
||
{onToggleSide && (
|
||
<button type="button" onClick={onToggleSide}
|
||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||
title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
|
||
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
|
||
</button>
|
||
)}
|
||
{onClose && (
|
||
<button type="button" onClick={onClose}
|
||
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
|
||
title={t('bmp.hide')}>
|
||
<X className="size-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
|
||
<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}
|
||
height={totalH}
|
||
preserveAspectRatio="none"
|
||
>
|
||
{segments.map(([s, e, colour], i) => {
|
||
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)}
|
||
fill={colour} fillOpacity={SEG_OPACITY} />
|
||
);
|
||
})}
|
||
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
|
||
</svg>
|
||
|
||
{/* Tick marks + freq labels */}
|
||
{ticks.map((t) => {
|
||
const y = freqToY(t);
|
||
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')} />
|
||
{major && (
|
||
<span className="absolute left-1 text-[10px] font-mono text-muted-foreground/90 bg-card/80 px-0.5">
|
||
{t.toLocaleString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* 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 style = statusStyle(st);
|
||
const labelMidY = p.labelY + PILL_H / 2;
|
||
const bumped = Math.abs(p.freqY - labelMidY) > 0.5;
|
||
return (
|
||
<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 - 6},${rigY - 5} ${SCALE_W + 1},${rigY} ${SCALE_W - 6},${rigY + 5}`}
|
||
className="fill-primary drop-shadow-sm"
|
||
/>
|
||
<line
|
||
x1={SCALE_W + 1}
|
||
y1={rigY}
|
||
x2="100%"
|
||
y2={rigY}
|
||
className="stroke-primary/30"
|
||
strokeWidth={1}
|
||
strokeDasharray="4 4"
|
||
/>
|
||
</>
|
||
)}
|
||
</svg>
|
||
|
||
{/* 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 (
|
||
<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}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${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>
|
||
)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
{/* Colour legend — what each pill colour means. */}
|
||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
|
||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||
<span className="mx-0.5 opacity-40">|</span>
|
||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||
</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">
|
||
{t('bmp.footerHint')}
|
||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|