Finishes the half of this that was already computing on the backend and reaching nobody. A Grid column in the Geo group, NEW GRID as a badge in Status, a filled cell in the marker's own colour, and a filter chip. new_grid joins lib/spotMarkers rather than getting colours of its own, so the badge, the cell fill and the chip cannot drift apart - and the per-marker colour setting will drive it with the rest from one table. Magenta: the last hue in the categorical set not already spoken for, and one that does not read as a status, because a new square is never urgent the way a new entity is. The band map leaves it to the cluster, as it already leaves the prefix. The pill is 22 px tall and its accent strip stops being readable past three segments. A row carrying a new grid is no longer "dull", or the dimming would grey out the one thing worth looking at. The column is off by default, like the other Geo columns: it is only ever filled for stations this receiver decoded over the UDP link, so for an operator who does not run digital it would be a permanently empty column.
687 lines
32 KiB
TypeScript
687 lines
32 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';
|
||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||
|
||
// 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;
|
||
}
|
||
|
||
// The FULL status entry, not the two fields the map used to declare. The extra
|
||
// markers were already arriving in this object — the local type simply never
|
||
// mentioned them, so they could not be drawn.
|
||
type SpotStatusEntry = {
|
||
status?: string;
|
||
country?: string;
|
||
worked_call?: boolean;
|
||
// worked_slot: this exact callsign already worked on THIS band and mode.
|
||
worked_slot?: boolean;
|
||
new_county?: boolean;
|
||
new_pota?: boolean;
|
||
new_pfx?: boolean;
|
||
};
|
||
|
||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||
// entity AND a new park. The cluster list stacks them as separate badges instead
|
||
// of letting one replace another, so the map stacks them too — as segments of
|
||
// the pill's left accent bar, which until now only repeated the pill's own
|
||
// colour and carried no information of its own.
|
||
//
|
||
// Their colours come from lib/spotMarkers, shared with the cluster list: the
|
||
// same fact must not be violet in one panel and green in the next.
|
||
// The map shows three of the five. A new PREFIX and a new GRID are left to the
|
||
// cluster list, which has the width to name them: the
|
||
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
|
||
// nobody can read at a glance. Add it here the day the strip earns more room.
|
||
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
|
||
const markersFor = (e: SpotStatusEntry | undefined) =>
|
||
activeMarkers(e).filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
|
||
|
||
// The legend spells the markers out; the cluster list's badges are abbreviated
|
||
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
|
||
const BMP_MARKER_LABEL: Record<string, string> = {
|
||
new_pota: 'bmp.legendNewPota',
|
||
new_county: 'bmp.legendNewCounty',
|
||
worked_call: 'bmp.legendWorkedCall',
|
||
};
|
||
|
||
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-mode': return t('bmp.statusNewMode');
|
||
case 'new-slot': return t('bmp.statusNewSlot');
|
||
case 'new-call': return t('bmp.statusNewCall');
|
||
case 'worked': return t('bmp.statusWorked');
|
||
// An empty status means the entity could not be resolved. Nothing else
|
||
// empties it: the mute option leaves the status alone and takes only the
|
||
// already-worked-callsign mark.
|
||
default: return t('bmp.statusUnresolved');
|
||
}
|
||
}
|
||
|
||
// QUIET: no status colour at all. The pill keeps the card background and the
|
||
// accent drops to a plain border grey, so the spot is present but says nothing.
|
||
const QUIET_STYLE = {
|
||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||
bar: 'bg-muted-foreground/30',
|
||
line: 'stroke-border',
|
||
dot: 'fill-border',
|
||
};
|
||
|
||
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-call':
|
||
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 QUIET_STYLE;
|
||
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: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||
const { t } = useI18n();
|
||
|
||
// The two display options are applied ONCE here, on the whole map, so the
|
||
// leader lines, the dots, the pills and the badges can never disagree — and so
|
||
// the map and the cluster list share the single rule set in lib/spotDisplay.
|
||
// Re-derived whenever the poll delivers a new status map, which is also when a
|
||
// just-changed option takes effect.
|
||
// Read at render time, not inside the memo: the options must be part of the
|
||
// dependencies. Keyed only on spotStatusRaw, toggling an option changed
|
||
// nothing until the next poll happened to hand over a fresh object.
|
||
const dispOpts = readSpotDisplayOptions();
|
||
const spotStatus = useMemo(() => {
|
||
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
||
const out: Record<string, SpotStatusEntry> = {};
|
||
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||
return out;
|
||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||
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 'new-call': 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]);
|
||
|
||
// 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.
|
||
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
|
||
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
|
||
// hook, so the hook count changed between renders and React crashed with #300
|
||
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
|
||
// back to [0,1] when there's no range), so it's safe to run here.
|
||
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]);
|
||
|
||
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);
|
||
}
|
||
|
||
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)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
|
||
>
|
||
{/* Left accent strip. With no extra marker it repeats the status
|
||
colour, exactly as before; otherwise it splits into one
|
||
segment per marker, so "worked entity + new park" shows both
|
||
instead of one hiding the other. */}
|
||
{(() => {
|
||
const marks = markersFor(entry);
|
||
if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
|
||
return (
|
||
<span className="w-1 shrink-0 flex flex-col" aria-hidden>
|
||
{marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
|
||
</span>
|
||
);
|
||
})()}
|
||
<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")} />
|
||
{/* The stacked markers, straight from the shared table so the legend
|
||
cannot drift from what the pills actually draw. */}
|
||
{BMP_MARKERS.map((m) => (
|
||
<LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
|
||
))}
|
||
{/* 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>
|
||
);
|
||
}
|