From 7dc5e6e8f364fe16c21ac0a3bb012afbf8dc7e79 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Thu, 30 Apr 2026 15:44:14 -0700 Subject: [PATCH] fix(maps): null-island bug + persist view across refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from manual testing of the v1.32 maps bundle on NOMAD3: 1. Refresh of /maps flew to (0, 0) at zoom 12 — Gulf of Guinea / null island. Root cause: getMapLocationParams() was reading lat/lng with Number(params.get('lat')), but Number(null) === 0 and Number('') === 0. When URL had no params (the typical refresh case), both lat and lng silently parsed as 0, validation passed (0 is finite and in bounds), and handleMapLoad triggered a flyTo (0, 0, 12). Fix: check that lat and lng params are present and non-empty before parsing. 2. User expectation: refresh should preserve current map position and zoom. Add localStorage persistence (key 'nomad:map-view'). Save on onMoveEnd; restore from initialViewState on mount. Priority order: URL params -> saved view -> default. Removed now-redundant flyToLocationParams + handleMapLoad — initialViewState handles URL params directly. 3. UX improvement: when /maps loads with ?lat=X&lng=Y, pre-fill the page's coordinate search input with "lat,lng" so the user can immediately click the marker button to drop a pin at that location without re-typing. Files: admin/inertia/components/maps/MapComponent.tsx - getMapLocationParams: hard-fail on null/empty params (export now) - getSavedMapView: new helper for localStorage view restore (with bounds check) - initialViewState: lazy useState picking URL > saved > default - onMoveEnd: persist current view to localStorage - removed flyToLocationParams + handleMapLoad admin/inertia/pages/maps.tsx - import getMapLocationParams - lazy useState pre-fills coordinateSearch from URL params --- .../inertia/components/maps/MapComponent.tsx | 96 ++++++++++++++----- admin/inertia/pages/maps.tsx | 9 +- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index 468470f..bc23db5 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -42,13 +42,22 @@ type MapLocationParams = { zoom: number } -const getMapLocationParams = (): MapLocationParams | null => { +const SAVED_MAP_VIEW_KEY = 'nomad:map-view' + +export const getMapLocationParams = (): MapLocationParams | null => { const params = new URLSearchParams(window.location.search) - const lat = Number(params.get('lat')) + const latParam = params.get('lat') const lngParam = params.get('lng') const longParam = params.get('long') - const lng = Number(lngParam ?? longParam) + const effectiveLng = lngParam ?? longParam + + // Both lat and lng must be present and non-empty. Number(null)/Number('') + // both coerce to 0, which would silently fly to (0,0) — null island. + if (!latParam || !effectiveLng) return null + + const lat = Number(latParam) + const lng = Number(effectiveLng) const zoom = Number(params.get('zoom') ?? 12) if ( @@ -81,6 +90,35 @@ const getMapLocationParams = (): MapLocationParams | null => { } } +const getSavedMapView = (): { longitude: number; latitude: number; zoom: number } | null => { + try { + const raw = localStorage.getItem(SAVED_MAP_VIEW_KEY) + if (!raw) return null + + const parsed = JSON.parse(raw) + if ( + typeof parsed === 'object' && + parsed !== null && + Number.isFinite(parsed.longitude) && + Number.isFinite(parsed.latitude) && + Number.isFinite(parsed.zoom) && + parsed.latitude >= -90 && + parsed.latitude <= 90 && + parsed.longitude >= -180 && + parsed.longitude <= 180 + ) { + return { + longitude: parsed.longitude, + latitude: parsed.latitude, + zoom: parsed.zoom, + } + } + } catch { + // ignore — fall through to default + } + return null +} + export default function MapComponent({ mapCommand, isHoveringUI = false, @@ -111,6 +149,24 @@ export default function MapComponent({ y: number } | null>(null) + // Resolve the initial map view once at mount: URL params → saved view → default. + // Lazy useState so we don't recompute on every render. + const [initialViewState] = useState(() => { + const urlParams = getMapLocationParams() + if (urlParams) { + return { + longitude: urlParams.lng, + latitude: urlParams.lat, + zoom: urlParams.zoom, + } + } + + const saved = getSavedMapView() + if (saved) return saved + + return { longitude: -101, latitude: 40, zoom: 3.5 } + }) + const confirmDiscardMarkerChanges = useCallback(() => { if (!hasUnsavedMarkerChanges) return true return window.confirm('Discard unsaved marker changes?') @@ -121,17 +177,6 @@ export default function MapComponent({ setCursorLngLat(null) }, []) - const flyToLocationParams = useCallback(() => { - const location = getMapLocationParams() - if (!location) return - - mapRef.current?.flyTo({ - center: [location.lng, location.lat], - zoom: location.zoom, - duration: 1500, - }) - }, []) - useEffect(() => { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) @@ -203,10 +248,6 @@ export default function MapComponent({ localStorage.setItem('nomad:map-scale-unit', unit) }, []) - const handleMapLoad = useCallback(() => { - flyToLocationParams() - }, [flyToLocationParams]) - const handleMapClick = useCallback( (e: MapLayerMouseEvent) => { if (!confirmDiscardMarkerChanges()) return @@ -297,12 +338,21 @@ export default function MapComponent({ cursor={isDraggingMap ? 'grabbing' : 'crosshair'} mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`} mapLib={maplibregl} - initialViewState={{ - longitude: -101, - latitude: 40, - zoom: 3.5, + initialViewState={initialViewState} + onMoveEnd={(e) => { + try { + localStorage.setItem( + SAVED_MAP_VIEW_KEY, + JSON.stringify({ + longitude: e.viewState.longitude, + latitude: e.viewState.latitude, + zoom: e.viewState.zoom, + }) + ) + } catch { + // ignore — quota / privacy mode + } }} - onLoad={handleMapLoad} onMouseDown={() => { setIsDraggingMap(true) hideCoordinates() diff --git a/admin/inertia/pages/maps.tsx b/admin/inertia/pages/maps.tsx index 1dcfffb..e043463 100644 --- a/admin/inertia/pages/maps.tsx +++ b/admin/inertia/pages/maps.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import MapsLayout from '~/layouts/MapsLayout' import { Head, Link, router } from '@inertiajs/react' -import MapComponent from '~/components/maps/MapComponent' +import MapComponent, { getMapLocationParams } from '~/components/maps/MapComponent' import StyledButton from '~/components/StyledButton' import { IconArrowLeft, IconMapPin, IconPlaneTilt } from '@tabler/icons-react' import { FileEntry } from '../../types/files' @@ -18,7 +18,12 @@ type MapCommand = { export default function Maps(props: { maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } }) { - const [coordinateSearch, setCoordinateSearch] = useState('') + // Pre-fill search box from URL params so users landing at /maps?lat=X&lng=Y + // can immediately click the marker button to drop a pin without re-typing. + const [coordinateSearch, setCoordinateSearch] = useState(() => { + const params = getMapLocationParams() + return params ? `${params.lat},${params.lng}` : '' + }) const [mapCommand, setMapCommand] = useState(null) const [showCoordinatesEnabled, setShowCoordinatesEnabled] = useState(true)