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,6 +5,7 @@ import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maid
|
||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||
import { cn } from '@/lib/utils';
|
||||
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
|
||||
// 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;
|
||||
|
||||
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 divRef = useRef<HTMLDivElement>(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
|
||||
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||
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;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
|
||||
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.
|
||||
//
|
||||
@@ -131,8 +132,16 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
|
||||
}).setView([20, 0], 2);
|
||||
// The whole world, once, whatever the window is shaped like — computed by
|
||||
// Leaflet from the container rather than guessed with a zoom number.
|
||||
m.fitWorld({ animate: false });
|
||||
// Leaflet from the container rather than guessed with a zoom number. Unless
|
||||
// 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;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
// 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 —
|
||||
// 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.
|
||||
let fitted = false;
|
||||
let fitted = !!saved; // a remembered view is already the right size
|
||||
const ro = new ResizeObserver(() => {
|
||||
m.invalidateSize({ animate: false });
|
||||
const el = hostRef.current;
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -29,6 +29,9 @@ const PORTABLE_KEYS = [
|
||||
'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.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.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)
|
||||
|
||||
Reference in New Issue
Block a user