feat(maps): remembered views for the FT and grid maps, and no wasted first paint

The world map was the only one that remembered anything. Panning and
zooming a map is the operator saying which part of the world they work,
and on the FT decodes map and the grid-square map that was thrown away on
every tab switch. Both now keep centre and zoom, through one shared
helper (lib/mapView) that the world map uses too, and the keys are
portable so a copied data folder brings the views with it.

The world map also waits for the station's square before it paints. It
drew the world at 0 degrees and then moved to the operator's longitude —
a screenful of Esri tiles fetched and discarded on every first run. It is
now built once, knowing where it is looking; a profile with no locator
gets the default view after two seconds rather than a blank panel.
This commit is contained in:
2026-09-06 14:23:43 +02:00
parent 50e97a8e0d
commit f56630d7d6
6 changed files with 91 additions and 27 deletions
+6 -2
View File
@@ -12,7 +12,9 @@
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.", "[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.",
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.", "PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.",
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.", "Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.",
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins." "[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
"The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world maps own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
"The world map now waits for the stations square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel."
], ],
"fr": [ "fr": [
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.", "Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
@@ -24,7 +26,9 @@
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.", "[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.",
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.", "Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.",
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.", "Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
"[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire." "[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
"La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, cest dire quelle partie du monde on travaille, et c’était jeté à chaque changement donglet.",
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide."
] ]
}, },
{ {
+14 -1
View File
@@ -5,6 +5,7 @@ import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maid
import { BASEMAPS, type BasemapKey } from '@/components/MainMap'; import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
// FT Map — the live decode feed as geography: every station decoded in the // FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by // last half hour, an arc from the operator's own square to theirs, coloured by
@@ -39,6 +40,10 @@ const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000; const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) { export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
// Where this map was left. Panning and zooming it is the operator saying which
// part of the world they are working; throwing that away on every tab switch
// made it something to set up again rather than something to glance at.
const saved = useRef(loadMapView(MAP_VIEW_FT));
const { t } = useI18n(); const { t } = useI18n();
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
@@ -58,7 +63,15 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
// No maxBounds: with the world smaller than the window the clamp // No maxBounds: with the world smaller than the window the clamp
// dragged every zoom into a corner. noWrap tiles alone keep one world. // dragged every zoom into a corner. noWrap tiles alone keep one world.
worldCopyJump: false, preferCanvas: true, worldCopyJump: false, preferCanvas: true,
center: [25, 0], zoom: 2, minZoom: 2, center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
zoom: saved.current ? saved.current.zoom : 2,
minZoom: 2,
});
// Every move, not just the deliberate ones: a zoom is as much a choice as a
// pan, and there is no moment afterwards at which to ask.
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
}); });
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
+12 -3
View File
@@ -8,6 +8,7 @@ import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap'; import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
// GridSquareMap — every Maidenhead square in the log, drawn on a world map. // GridSquareMap — every Maidenhead square in the log, drawn on a world map.
// //
@@ -131,8 +132,16 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
}).setView([20, 0], 2); }).setView([20, 0], 2);
// The whole world, once, whatever the window is shaped like — computed by // The whole world, once, whatever the window is shaped like — computed by
// Leaflet from the container rather than guessed with a zoom number. // Leaflet from the container rather than guessed with a zoom number. Unless
m.fitWorld({ animate: false }); // the operator has already chosen a view: theirs is the answer, and fitting
// the world over it would undo the choice on every tab switch.
const saved = loadMapView(MAP_VIEW_GRIDS);
if (saved) m.setView([saved.lat, saved.lon], saved.zoom);
else m.fitWorld({ animate: false });
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_GRIDS, c.lat, c.lng, m.getZoom());
});
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never // Leaflet measures its container ONCE, when the map is created, and never
@@ -144,7 +153,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// fitWorld above ran against a container that may still have had no size — // fitWorld above ran against a container that may still have had no size —
// the tab is mounted hidden. Fit ONCE more the first time it really has one, // the tab is mounted hidden. Fit ONCE more the first time it really has one,
// and never again: after that the view belongs to the operator. // and never again: after that the view belongs to the operator.
let fitted = false; let fitted = !!saved; // a remembered view is already the right size
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
m.invalidateSize({ animate: false }); m.invalidateSize({ animate: false });
const el = hostRef.current; const el = hostRef.current;
+25 -21
View File
@@ -5,15 +5,13 @@ import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead'; import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
// Persisted free-pan view of the world map (when auto-zoom is off). // Persisted free-pan view of the world map (when auto-zoom is off).
function loadMapView(): { lat: number; lon: number; zoom: number } | null { const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; } function saveWorldView(m: L.Map) {
catch { return null; }
}
function saveMapView(m: L.Map) {
const c = m.getCenter(); const c = m.getCenter();
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() })); saveMapView(MAP_VIEW_WORLD, c.lat, c.lng, m.getZoom());
} }
// The Main tab is built from two independent map panes that the operator can // The Main tab is built from two independent map panes that the operator can
@@ -198,21 +196,27 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const autoZoomRef = useRef(autoZoom); const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]); useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
// The station's own square arrives from the profile a moment AFTER this map is // WAIT FOR THE SQUARE BEFORE PAINTING.
// built, so the first view is drawn without it. Re-centred once when it lands — //
// and never again, nor over a view the operator has panned to themselves. // The station's own locator arrives from the profile a moment after this
const homeCentred = useRef(false); // component mounts. Building the map immediately meant painting the world at
// 0°, then moving it to the operator's longitude — a screenful of tiles
// fetched from Esri and thrown away on every first run. The map is built once,
// when it knows where it is looking.
//
// Not for ever, though: an operator with no locator set (or a profile still
// loading after two seconds) gets the default view rather than a blank panel.
const [mapReady, setMapReady] = useState(() => !!gridToLatLon(fromGrid) || !!loadWorldView());
useEffect(() => { useEffect(() => {
const m = worldMap.current; if (mapReady) return;
if (!m || homeCentred.current || !gridToLatLon(fromGrid)) return; if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
homeCentred.current = true; const t = window.setTimeout(() => setMapReady(true), 2000);
if (loadMapView()) return; // their own view wins return () => window.clearTimeout(t);
m.setView(homeView(fromGrid), m.getZoom()); }, [fromGrid, mapReady]);
}, [fromGrid]);
// One-time map creation. // One-time map creation.
useEffect(() => { useEffect(() => {
if (worldRef.current && !worldMap.current) { if (worldRef.current && !worldMap.current && mapReady) {
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to // preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120 // ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is // composited paths re-rasterised on every pan, zoom and redraw, which is
@@ -223,9 +227,9 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
addBasemap(m, basemap, baseLayer, labelsLayer); addBasemap(m, basemap, baseLayer, labelsLayer);
worldOverlay.current = L.layerGroup().addTo(m); worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m; worldMap.current = m;
const sv = loadMapView(); const sv = loadWorldView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom); if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); m.on('moveend', () => { if (!autoZoomRef.current) saveWorldView(m); });
} }
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80); const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
@@ -235,7 +239,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize()); const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current); if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); }; return () => { window.clearTimeout(t); ro.disconnect(); };
}, []); }, [mapReady]);
// Swap the basemap (and its optional place-name overlay) when the operator // Swap the basemap (and its optional place-name overlay) when the operator
// picks a different one. Vector overlays (path/beam) live in Leaflet's // picks a different one. Vector overlays (path/beam) live in Leaflet's
@@ -460,7 +464,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
setAutoZoom(v); setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0'); writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current; const m = worldMap.current;
if (!v && m) saveMapView(m); if (!v && m) saveWorldView(m);
}} }}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'} title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${ className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
+31
View File
@@ -0,0 +1,31 @@
// Remembered map views — where each map was left, per map.
//
// Panning and zooming a map is an operator saying "this is the part of the world
// I work". Throwing that away on every tab switch made the maps something to
// set up again each time rather than something to glance at, and the world map
// was the only one that remembered anything.
//
// One key per map, and portable (see lib/uiPref) like the world map's own: a
// copied data folder brings the views with it.
import { writeUiPref } from '@/lib/uiPref';
export type MapView = { lat: number; lon: number; zoom: number };
// The keys in use. Named here rather than typed at each call site so a rename
// cannot silently orphan somebody's saved view.
export const MAP_VIEW_WORLD = 'opslog.mapView';
export const MAP_VIEW_FT = 'opslog.ftMapView';
export const MAP_VIEW_GRIDS = 'opslog.gridMapView';
export function loadMapView(key: string): MapView | null {
try {
const v = JSON.parse(localStorage.getItem(key) || 'null');
return v && typeof v.zoom === 'number' && typeof v.lat === 'number' && typeof v.lon === 'number' ? v : null;
} catch {
return null; // corrupt or unreadable → open where the map would by default
}
}
export function saveMapView(key: string, lat: number, lon: number, zoom: number): void {
writeUiPref(key, JSON.stringify({ lat, lon, zoom }));
}
+3
View File
@@ -29,6 +29,9 @@ const PORTABLE_KEYS = [
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab 'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom) 'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest.
'opslog.ftMapView', 'opslog.gridMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots 'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)