feat(grids): the square map filters by mode, band and satellite

The FTx button is gone. It was the wrong grain in both directions: the
Digital class already sat a contest RTTY square next to an FT8 one, and
FTx then sat FT8 next to FT4, when what this map answers is where ONE
mode has been heard. The four classes stay as buttons and a dropdown
beside them offers a single mode.

Its contents come from the log, not from a list in the code. That is
what settles FT2: it is not a registered ADIF mode yet, so a hardcoded
list meant either leaving out the operators already using it or shipping
a mode that does not officially exist. A query does neither, and needs
no change here the day it is registered. The mode offered is the SUBMODE
where there is one, because ADIF files PSK63 under PSK and "PSK" is not
the name anybody is looking for.

The band list is the station's own, unioned with anything worked outside
it so nothing in the log is unreachable, ordered by frequency through
the band plan's index — sorting the names puts 10m between 1.25m and
12m. The satellite list is drawn from SAT_NAME on the squares
themselves and the control is absent altogether on a terrestrial log: a
VHF square worked through AO-91 and one worked line-of-sight are not the
same achievement, and until now nothing separated them.

The mode dropdown shares the scope with the class buttons rather than
narrowing on top of them — mode is one question, and two controls both
answering it is how a map ends up showing PHONE ∩ FT8 and nothing else.
A stored FTX preference is read as Digital, so it cannot leave the map
filtered by something no control shows as selected.
This commit is contained in:
2026-09-10 11:07:25 +02:00
parent 2615365684
commit 052fc4cb80
9 changed files with 289 additions and 39 deletions
+103 -15
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Loader2, RefreshCw } from 'lucide-react';
import { GridSquares } from '../../wailsjs/go/main/App';
import { GridSquares, GridSquareChoices, GetListsSettings } from '../../wailsjs/go/main/App';
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -47,20 +47,27 @@ function cssColour(token: string, fallback: string): string {
} catch { return fallback; }
}
// Mode scope. The names come straight from the backend's own classes ("ALL",
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
// not a square worked on FT8.
// Mode scope. The four broad classes the rest of the app uses, as buttons —
// and then any single mode the log actually holds, from the dropdown beside
// them.
//
// There used to be an FTx button here, lumping FT8, FT4 and FT2 together. It
// was the wrong grain in both directions: "digital" already put a contest RTTY
// square beside an FT8 one, and FTx then put FT8 beside FT4, when the question
// this map answers is where ONE mode has been heard. The specific modes are
// read from the log rather than listed here, so FT2 is offered to an operator
// already using it and needs no change here the day it becomes registered.
const SCOPES = [
{ key: 'ALL', label: 'gsm.all' },
{ key: 'PHONE', label: 'gsm.phone' },
{ key: 'CW', label: 'gsm.cw' },
{ key: 'DIGI', label: 'gsm.digital' },
{ key: 'FTX', label: 'gsm.ftx' },
] as const;
type ScopeKey = typeof SCOPES[number]['key'];
type ScopeKey = string;
const SCOPE_KEY = 'opslog.gridMapScope';
const BAND_KEY = 'opslog.gridMapBand';
const SAT_KEY = 'opslog.gridMapSat';
// Chosen fill colours. Empty means "follow the theme", which is the default and
// stays the default: the tokens already track the four themes, and freezing a
// hex at first run would leave a dark-theme map painted in the light palette.
@@ -83,9 +90,22 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
const [squares, setSquares] = useState<Square[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [scope, setScope] = useState<ScopeKey>(
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
// The stored scope is taken as given rather than checked against SCOPES: it
// may legitimately be a mode name now, and the backend answers a mode nothing
// was worked on with no squares rather than an error.
const [scope, setScope] = useState<ScopeKey>(() => {
const v = localStorage.getItem(SCOPE_KEY) || 'DIGI';
// FTX was a button until the named modes replaced it. Left as it was, no
// control would show it selected while the map stayed filtered by it.
return v === 'FTX' ? 'DIGI' : v;
});
const [band, setBand] = useState(() => localStorage.getItem(BAND_KEY) ?? '');
const [sat, setSat] = useState(() => localStorage.getItem(SAT_KEY) ?? '');
// What the three filters can offer. The modes and satellites are the ones the
// squares were actually worked on; the bands are the station's own list too,
// so a band configured but not yet worked is still there to ask about.
const [choices, setChoices] = useState<{ modes: string[]; bands: string[]; satellites: string[] }>(
{ modes: [], bands: [], satellites: [] });
// This map's own imagery. It shared the world map's key until they were
// separated, so a choice made back then is inherited rather than reset.
@@ -102,17 +122,53 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
return () => obs.disconnect();
}, []);
const load = async (sc: ScopeKey = scope) => {
const load = async (sc: ScopeKey = scope, bd: string = band, st: string = sat) => {
setBusy(true); setErr('');
try {
const r = (await GridSquares(sc)) as any;
const r = (await GridSquares(sc, bd, st)) as any;
setSquares((Array.isArray(r) ? r : []) as Square[]);
} catch (e: any) {
setErr(String(e?.message ?? e));
setSquares([]);
} finally { setBusy(false); }
};
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
useEffect(() => { void load(scope, band, sat); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope, band, sat]);
// Loaded once: what the filters can offer changes only when the log does, and
// the refresh button reloads it alongside the squares.
const loadChoices = async () => {
try {
const c: any = await GridSquareChoices();
let bands: string[] = (c?.bands ?? []) as string[];
try {
const ls: any = await GetListsSettings();
const have = new Set(bands.map((b) => b.toLowerCase()));
// Union, the station's own list first: a configured band with nothing
// worked on it is still a fair question, and a band worked but never
// configured must not become unreachable.
const extra = ((ls?.bands ?? []) as string[])
.map((b) => String(b).toLowerCase())
.filter((b) => b && !have.has(b));
bands = [...extra, ...bands];
} catch { /* the log's own bands are enough */ }
setChoices({
modes: (c?.modes ?? []) as string[],
bands,
satellites: (c?.satellites ?? []) as string[],
});
} catch { /* the class buttons still work without it */ }
};
useEffect(() => { void loadChoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
// Only the modes NOT already a button: listing SSB and CW again would be two
// controls giving one answer.
const namedModes = useMemo(
() => choices.modes.filter((m) => !SCOPES.some((c) => c.key === m.toUpperCase())),
[choices.modes]);
const pick = (key: string, v: string, set: (v: string) => void) => {
set(v);
try { localStorage.setItem(key, v); } catch { /* quota */ }
};
// One-time map creation. preferCanvas: a busy digital log is a few thousand
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
@@ -244,13 +300,45 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
<div className="inline-flex rounded-md border border-border overflow-hidden">
{SCOPES.map((s, i) => (
<button key={s.key} type="button"
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
onClick={() => pick(SCOPE_KEY, s.key, setScope)}
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
{t(s.label)}
</button>
))}
</div>
{/* One named mode, where the FTx button used to be. It shares the scope
with the buttons rather than filtering on top of them: mode is one
question, and two controls that both answer it is how a map ends up
showing PHONE ∩ FT8, which is empty. Picking a mode here therefore
un-picks the buttons, and vice versa. */}
{namedModes.length > 0 && (
<select
value={namedModes.includes(scope) ? scope : ''}
onChange={(e) => pick(SCOPE_KEY, e.target.value || 'ALL', setScope)}
title={t('gsm.oneMode')}
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
>
<option value="">{t('gsm.oneMode')}</option>
{namedModes.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
)}
{choices.bands.length > 0 && (
<select value={band} onChange={(e) => pick(BAND_KEY, e.target.value, setBand)}
title={t('gsm.band')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
<option value="">{t('gsm.allBands')}</option>
{choices.bands.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
)}
{/* Only for a station that has worked one. A satellite dropdown on a
purely terrestrial log is a control that can only ever be empty. */}
{choices.satellites.length > 0 && (
<select value={sat} onChange={(e) => pick(SAT_KEY, e.target.value, setSat)}
title={t('gsm.satellite')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
<option value="">{t('gsm.allSats')}</option>
{choices.satellites.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
)}
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
</span>
@@ -287,7 +375,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
className="text-[11px] text-muted-foreground hover:text-foreground px-1"></button>
)}
<span className="flex-1" />
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
<button type="button" onClick={() => { void load(); void loadChoices(); }} disabled={busy} title={t('gsm.refresh')}
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
</button>
+2 -2
View File
@@ -392,7 +392,7 @@ const en: Dict = {
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.',
'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', 'gsm.oneMode': 'One mode', 'gsm.band': 'Band', 'gsm.allBands': 'All bands', 'gsm.satellite': 'Satellite', 'gsm.allSats': 'All satellites',
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region and 100 km suits 2 m, where a duct is narrow; where stations are far apart — VK, ZL, much of North America — 1000 to 2000 km may be what it takes to find any receivers at all.',
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.pillConnect': 'Click to connect', 'clu.pillDisconnect': 'Click to disconnect', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseCounty': 'Chase US counties', 'clu.chaseCountyHint': 'Off: no NEW COUNTY badge or filter in the cluster.', 'clu.chaseState': 'Chase US states', 'clu.chaseStateHint': 'Off: no NEW STATE badge or filter.', 'clu.chasePfx': 'Chase new prefixes', 'clu.chasePfxHint': 'Off: no NEW PFX badge or filter in the cluster.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
@@ -1027,7 +1027,7 @@ const fr: Dict = {
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de lindicatif : 4S trouve 4S7AB. * remplace nimporte quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient nimporte où, et F?BPO correspond à F4BPO.',
'chg.mode': 'Chasse', 'chg.sources': 'Confirmé par', 'chg.card': 'Carte QSL', 'dec.unconfTip': 'Contacté mais non confirmé — une QSL à chasser', 'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus cest étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme dici là.',
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés', 'gsm.oneMode': 'Un mode', 'gsm.band': 'Bande', 'gsm.allBands': 'Toutes les bandes', 'gsm.satellite': 'Satellite', 'gsm.allSats': 'Tous les satellites',
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que sil a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille na plus rien à observer. 300 km emprunte les oreilles de toute une région et 100 km convient au 2 m, où un conduit est étroit ; là où les stations sont très dispersées — VK, ZL, une bonne partie de lAmérique du Nord — 1000 à 2000 km sont parfois nécessaires pour trouver le moindre récepteur.',
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton nest pas affiché.',
+3 -1
View File
@@ -685,7 +685,9 @@ export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
export function GridSquareChoices():Promise<main.GridSquareChoices>;
export function GridSquares(arg1:string,arg2:string,arg3:string):Promise<Array<qso.GridSquare>>;
export function HaltAutoCall():Promise<void>;
+6 -2
View File
@@ -1302,8 +1302,12 @@ export function GetYaesuState() {
return window['go']['main']['App']['GetYaesuState']();
}
export function GridSquares(arg1) {
return window['go']['main']['App']['GridSquares'](arg1);
export function GridSquareChoices() {
return window['go']['main']['App']['GridSquareChoices']();
}
export function GridSquares(arg1, arg2, arg3) {
return window['go']['main']['App']['GridSquares'](arg1, arg2, arg3);
}
export function HaltAutoCall() {
+16
View File
@@ -3075,6 +3075,22 @@ export namespace main {
return a;
}
}
export class GridSquareChoices {
modes: string[];
bands: string[];
satellites: string[];
static createFrom(source: any = {}) {
return new GridSquareChoices(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.modes = source["modes"];
this.bands = source["bands"];
this.satellites = source["satellites"];
}
}
export class HamlogCfmResult {
total: number;
confirmed: number;