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
+4 -2
View File
@@ -3,10 +3,12 @@
"version": "0.26.23", "version": "0.26.23",
"date": "", "date": "",
"en": [ "en": [
"LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station." "LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.",
"The band maps follow your IARU region (Settings → General): Region 2s 40 m runs to 7300 kHz with phone from 7125 and its 80 m to 4000, Region 3s 80 m to 3900 — the band edges and the CW/digital/phone shading all adjust."
], ],
"fr": [ "fr": [
"LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement." "LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.",
"Les cartes de bande suivent votre région IARU (Réglages → Général) : le 40 m de la Région 2 va jusqu'à 7300 kHz avec la phonie dès 7125 et son 80 m jusqu'à 4000, le 80 m de la Région 3 jusqu'à 3900 — les limites de bande et les zones CW/numérique/phonie s'ajustent."
] ]
}, },
{ {
+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 { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot'; import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers'; import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay'; import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
@@ -114,59 +115,26 @@ interface Props {
keyNav?: boolean; keyNav?: boolean;
} }
const BAND_RANGES: Record<string, [number, number]> = { // Band edges and mode segments come from lib/bandplan, which knows the
'160m': [1800, 2000], // operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
'80m': [3500, 3800], // to 7300 with phone from 7125, and drawing Region 1's plan under their spots
'60m': [5350, 5450], // cut the top 100 kHz of the band off the map.
'40m': [7000, 7200], //
'30m': [10100, 10150], // Sub-band shading colours: these are IDENTITIES (which mode the segment is
'20m': [14000, 14350], // for), not states, so they take categorical hues — never the status tokens.
'17m': [18068, 18168], // All three are drawn from the cool end of the categorical order and laid down
'15m': [21000, 21450], // at low opacity, so the band plan stays background context while the warm
'12m': [24890, 24990], // spot pills keep the foreground. Checked with the palette validator in both
'10m': [28000, 29700], // themes (violet failed the dark protan step; magenta clears every check).
'6m': [50000, 50500], const SEG_COLOR: Record<SegMode, string> = {
'4m': [70000, 70500], cw: 'var(--chart-7)', // magenta
'2m': [144000, 146000], digi: 'var(--chart-1)', // blue
'70cm': [430000, 440000], 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 // A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it. // are read on top of it.
const SEG_OPACITY = 0.13; 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. // Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) { function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return ( 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; for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
return out; return out;
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]); }, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band]; // Re-render when the operator changes their IARU region in Settings.
const segments = SEGMENT_COLORS[band] ?? []; 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)); const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's // The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom. // 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. */} {/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span> <span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} /> <LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} /> <LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} /> <LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
</div> </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"> <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')} {t('bmp.footerHint')}
+20
View File
@@ -81,6 +81,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { setUseMiles } from '@/lib/units'; import { setUseMiles } from '@/lib/units';
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'; import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme'; import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
@@ -1749,6 +1750,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1'); const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1'); const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1'); const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain // Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free. // function by the PANELS map, so it must stay hooks-free.
@@ -7239,6 +7241,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} /> <Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span> {t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
</label> </label>
<div className="flex items-center gap-2 text-sm">
{/* The IARU region drives the band edges and mode segments the band
maps draw Region 2's 40 m runs to 7300, Region 1's stops at
7200 and nothing else: spots and logging are region-blind. */}
<span>{t('gen.iaruRegion')}</span>
<Select value={String(region)} onValueChange={(v) => {
const r = (v === '2' ? 2 : v === '3' ? 3 : 1) as IaruRegion;
setRegion(r); setIaruRegion(r);
}}>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">{t('gen.iaruR1')}</SelectItem>
<SelectItem value="2">{t('gen.iaruR2')}</SelectItem>
<SelectItem value="3">{t('gen.iaruR3')}</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('gen.iaruHint')}</span>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
{/* Distances are computed in km everywhere and converted at display {/* Distances are computed in km everywhere and converted at display
time see lib/units. Changing this repaints the columns that time see lib/units. Changing this repaints the columns that
+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.', '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.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.", '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)', 'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
'qslm.thStation': 'Station', 'qslm.thStation': 'Station',
'qslm.qrzTitle': 'Open this callsign on QRZ.com', '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.", '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.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.", '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)', 'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
'qslm.thStation': 'Station', 'qslm.thStation': 'Station',
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com', '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.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.distanceMiles', // distances shown in statute miles rather than km '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.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares '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:[..]} 'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}