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
+25 -21
View File
@@ -5,15 +5,13 @@ import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref';
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).
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
catch { return null; }
}
function saveMapView(m: L.Map) {
const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
function saveWorldView(m: L.Map) {
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
@@ -198,21 +196,27 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
// The station's own square arrives from the profile a moment AFTER this map is
// 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.
const homeCentred = useRef(false);
// WAIT FOR THE SQUARE BEFORE PAINTING.
//
// The station's own locator arrives from the profile a moment after this
// 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(() => {
const m = worldMap.current;
if (!m || homeCentred.current || !gridToLatLon(fromGrid)) return;
homeCentred.current = true;
if (loadMapView()) return; // their own view wins
m.setView(homeView(fromGrid), m.getZoom());
}, [fromGrid]);
if (mapReady) return;
if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
const t = window.setTimeout(() => setMapReady(true), 2000);
return () => window.clearTimeout(t);
}, [fromGrid, mapReady]);
// One-time map creation.
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
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// 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);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
const sv = loadMapView();
const sv = loadWorldView();
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);
@@ -235,7 +239,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); };
}, []);
}, [mapReady]);
// Swap the basemap (and its optional place-name overlay) when the operator
// 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);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
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'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${