feat(bandmap): draw the band plan of the operator's IARU region

The band maps carried one hard-coded table — Region 1's — so a US operator's
40 m stopped at 7200 with stations spotted at 7250 hanging past the top of the
map, and the SSB wash started 25 kHz early. The region is a station-level fact
stated once (Settings → General, portable pref); lib/bandplan owns the three
tables and publishes changes to the open maps the way the distance unit reaches
the grids.

The tables stay deliberately coarse: a band map's wash is context, not a
regulatory chart, so only the differences an operator notices are split out —
Region 2's 80 m to 4000 and 40 m to 7300 (phone from 7125), Region 3's 80 m to
3900, the wider VHF/UHF allocations. National fine print does not belong in a
background tint.
This commit is contained in:
2026-08-28 21:55:37 +02:00
parent 73cae855ed
commit f0f9898f7c
6 changed files with 166 additions and 55 deletions
+24 -53
View File
@@ -2,6 +2,7 @@ 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 { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
@@ -114,59 +115,26 @@ interface Props {
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],
// Band edges and mode segments come from lib/bandplan, which knows the
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
// cut the top 100 kHz of the band off the map.
//
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
// for), not states, so they take categorical hues — never the status tokens.
// 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. Checked with the palette validator in both
// themes (violet failed the dark protan step; magenta clears every check).
const SEG_COLOR: Record<SegMode, string> = {
cw: 'var(--chart-7)', // magenta
digi: 'var(--chart-1)', // blue
phone: 'var(--chart-2)', // aqua
};
// 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 (
@@ -309,8 +277,11 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
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] ?? [];
// Re-render when the operator changes their IARU region in Settings.
const [, setRegionTick] = useState(0);
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
const range = bandRange(band);
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
@@ -794,9 +765,9 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
))}
{/* 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")} />
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_COLOR.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')}