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
+113
View File
@@ -0,0 +1,113 @@
// IARU band plans, by region.
//
// The band maps used to carry one hard-coded table — Region 1's — so a US or
// Australian operator saw 40 m stop at 7200 while stations sat spotted at 7250,
// and the SSB wash started 25 kHz early. The region is a station-level fact the
// operator states once (Settings → General); everything drawing a band edge or
// a mode segment reads it from here.
//
// The tables are deliberately coarse: a band map's wash is CONTEXT, not a
// regulatory chart. Only the segments that differ between regions in ways an
// operator notices are split out; national fine print (US licence classes,
// beacon slots) does not belong in a background tint.
import { writeUiPref } from '@/lib/uiPref';
export type IaruRegion = 1 | 2 | 3;
export const KEY_IARU_REGION = 'opslog.iaruRegion';
export function iaruRegion(): IaruRegion {
try {
const v = localStorage.getItem(KEY_IARU_REGION);
return v === '2' ? 2 : v === '3' ? 3 : 1;
} catch { return 1; }
}
export function setIaruRegion(r: IaruRegion): void {
writeUiPref(KEY_IARU_REGION, String(r));
listeners.forEach((l) => l());
}
// The band maps capture the plan inside their render, so a change must reach
// them the way the distance unit reaches the grids.
const listeners = new Set<() => void>();
export function subscribeIaruRegion(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export type SegMode = 'cw' | 'digi' | 'phone';
type Seg = [number, number, SegMode];
interface Plan {
ranges: Record<string, [number, number]>;
segments: Record<string, Seg[]>;
}
// Region 1 — Europe / Africa / Middle East / northern Asia. The baseline the
// other two override.
const R1: Plan = {
ranges: {
'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],
},
segments: {
'160m': [[1800, 1838, 'cw'], [1838, 1840, 'digi'], [1840, 2000, 'phone']],
'80m': [[3500, 3580, 'cw'], [3580, 3600, 'digi'], [3600, 3800, 'phone']],
'60m': [[5350, 5450, 'phone']],
'40m': [[7000, 7040, 'cw'], [7040, 7100, 'digi'], [7100, 7200, 'phone']],
'30m': [[10100, 10130, 'cw'], [10130, 10150, 'digi']],
'20m': [[14000, 14070, 'cw'], [14070, 14100, 'digi'], [14100, 14350, 'phone']],
'17m': [[18068, 18095, 'cw'], [18095, 18110, 'digi'], [18110, 18168, 'phone']],
'15m': [[21000, 21070, 'cw'], [21070, 21150, 'digi'], [21150, 21450, 'phone']],
'12m': [[24890, 24915, 'cw'], [24915, 24940, 'digi'], [24940, 24990, 'phone']],
'10m': [[28000, 28070, 'cw'], [28070, 28300, 'digi'], [28300, 29700, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 50500, 'phone']],
},
};
// Region 2 — the Americas. 80 m runs to 4000 and 40 m to 7300, with phone from
// 7125 (the whole point of asking the region: a KP4 ragchew on 7250 must not
// hang past the top of the map).
const R2: Plan = {
ranges: {
...R1.ranges,
'80m': [3500, 4000], '40m': [7000, 7300],
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [420000, 450000],
},
segments: {
...R1.segments,
'80m': [[3500, 3570, 'cw'], [3570, 3600, 'digi'], [3600, 4000, 'phone']],
'40m': [[7000, 7040, 'cw'], [7040, 7125, 'digi'], [7125, 7300, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
},
};
// Region 3 — Asia-Pacific. 80 m to 3900; 40 m as Region 1's shape.
const R3: Plan = {
ranges: {
...R1.ranges,
'80m': [3500, 3900],
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [430000, 450000],
},
segments: {
...R1.segments,
'80m': [[3500, 3535, 'cw'], [3535, 3600, 'digi'], [3600, 3900, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
},
};
function plan(): Plan {
const r = iaruRegion();
return r === 2 ? R2 : r === 3 ? R3 : R1;
}
export function bandRange(band: string): [number, number] | undefined {
return plan().ranges[band];
}
export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? [];
}
+4
View File
@@ -130,6 +130,8 @@ const en: Dict = {
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
'gen.iaruRegion': 'IARU region', 'gen.iaruHint': '(band plan drawn on the band maps)',
'gen.iaruR1': 'Region 1 — Europe, Africa, Middle East', 'gen.iaruR2': 'Region 2 — Americas', 'gen.iaruR3': 'Region 3 — Asia-Pacific',
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
'qslm.thStation': 'Station',
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
@@ -631,6 +633,8 @@ const fr: Dict = {
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
'gen.iaruRegion': 'Région IARU', 'gen.iaruHint': '(plan de bande dessiné sur les cartes de bande)',
'gen.iaruR1': 'Région 1 — Europe, Afrique, Moyen-Orient', 'gen.iaruR2': 'Région 2 — Amériques', 'gen.iaruR3': 'Région 3 — Asie-Pacifique',
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
'qslm.thStation': 'Station',
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
+1
View File
@@ -41,6 +41,7 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.distanceMiles', // distances shown in statute miles rather than km
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}