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:
@@ -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 ${
|
||||
|
||||
Reference in New Issue
Block a user