752 lines
36 KiB
TypeScript
752 lines
36 KiB
TypeScript
import { useCallback, 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';
|
||
import { writeUiPref } from '@/lib/uiPref';
|
||
|
||
// 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;
|
||
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||
lotw?: 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;
|
||
// Mark stations that upload to LoTW (Settings → Appearance).
|
||
showLotw?: 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.
|
||
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
|
||
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
|
||
// 2 px/kHz, which scrolls in one screen on most displays.
|
||
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
|
||
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
|
||
|
||
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
|
||
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
|
||
// and thirteen entries there to say the same thing would be thirteen chances to
|
||
// forget one.
|
||
const ZOOM_KEY = 'opslog.bandMapZoom';
|
||
|
||
function readZoomMap(): Record<string, number> {
|
||
try {
|
||
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
|
||
return m && typeof m === 'object' ? m : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function readZoom(band: string): number {
|
||
const n = readZoomMap()[band];
|
||
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
|
||
}
|
||
|
||
function writeZoom(band: string, idx: number): void {
|
||
const m = readZoomMap();
|
||
m[band] = idx;
|
||
writeUiPref(ZOOM_KEY, JSON.stringify(m));
|
||
}
|
||
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, showLotw = 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(() => readZoom(band));
|
||
// The docked map follows the rig, so a band change must bring up THAT band's
|
||
// remembered zoom.
|
||
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||
// Stored from the interaction, not from an effect on zoomIdx: an effect would
|
||
// also fire on the band change above, and in the same commit it would still be
|
||
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
|
||
// where the operator actually turns the wheel keeps the two apart. (Running
|
||
// twice under StrictMode is harmless: the same value is stored.)
|
||
const changeZoom = useCallback((delta: number) => {
|
||
setZoomIdx((z) => {
|
||
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
|
||
if (n !== z) writeZoom(band, n);
|
||
return n;
|
||
});
|
||
}, [band]);
|
||
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();
|
||
changeZoom(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 8 px/kHz we want labels
|
||
// every 25 kHz, not every 250. The bare values below are the floor, and
|
||
// they are what the 2 px/kHz step lands on: a whole band in one screen
|
||
// wants a number every 100 kHz, not every 25.
|
||
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">
|
||
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
|
||
what the panel obviously is, and with four band maps side by side it
|
||
was four times the same word. */}
|
||
<span className="flex-1 min-w-0 truncate">{band}</span>
|
||
<button type="button" onClick={() => changeZoom(-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={() => changeZoom(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>
|
||
{/* Same badge the cluster list draws, for the same reason: the
|
||
muted-blue of a confirmation, never a status colour —
|
||
whether a station uploads to LoTW says nothing about
|
||
whether the spot is worth chasing. A glyph rather than a
|
||
colour also keeps it off the channel the statuses use. */}
|
||
{showLotw && entry?.lotw && (
|
||
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</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>
|
||
);
|
||
}
|