fix(maps): null-island bug + persist view across refresh

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
This commit is contained in:
Chris Sherwood 2026-04-30 15:44:14 -07:00
parent c46a673812
commit 7dc5e6e8f3
2 changed files with 80 additions and 25 deletions

View File

@ -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()

View File

@ -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<MapCommand | null>(null)
const [showCoordinatesEnabled, setShowCoordinatesEnabled] = useState(true)