mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-12 16:10:11 +02:00
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:
parent
c46a673812
commit
7dc5e6e8f3
|
|
@ -42,13 +42,22 @@ type MapLocationParams = {
|
||||||
zoom: number
|
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 params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
const lat = Number(params.get('lat'))
|
const latParam = params.get('lat')
|
||||||
const lngParam = params.get('lng')
|
const lngParam = params.get('lng')
|
||||||
const longParam = params.get('long')
|
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)
|
const zoom = Number(params.get('zoom') ?? 12)
|
||||||
|
|
||||||
if (
|
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({
|
export default function MapComponent({
|
||||||
mapCommand,
|
mapCommand,
|
||||||
isHoveringUI = false,
|
isHoveringUI = false,
|
||||||
|
|
@ -111,6 +149,24 @@ export default function MapComponent({
|
||||||
y: number
|
y: number
|
||||||
} | null>(null)
|
} | 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(() => {
|
const confirmDiscardMarkerChanges = useCallback(() => {
|
||||||
if (!hasUnsavedMarkerChanges) return true
|
if (!hasUnsavedMarkerChanges) return true
|
||||||
return window.confirm('Discard unsaved marker changes?')
|
return window.confirm('Discard unsaved marker changes?')
|
||||||
|
|
@ -121,17 +177,6 @@ export default function MapComponent({
|
||||||
setCursorLngLat(null)
|
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(() => {
|
useEffect(() => {
|
||||||
const protocol = new Protocol()
|
const protocol = new Protocol()
|
||||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
maplibregl.addProtocol('pmtiles', protocol.tile)
|
||||||
|
|
@ -203,10 +248,6 @@ export default function MapComponent({
|
||||||
localStorage.setItem('nomad:map-scale-unit', unit)
|
localStorage.setItem('nomad:map-scale-unit', unit)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleMapLoad = useCallback(() => {
|
|
||||||
flyToLocationParams()
|
|
||||||
}, [flyToLocationParams])
|
|
||||||
|
|
||||||
const handleMapClick = useCallback(
|
const handleMapClick = useCallback(
|
||||||
(e: MapLayerMouseEvent) => {
|
(e: MapLayerMouseEvent) => {
|
||||||
if (!confirmDiscardMarkerChanges()) return
|
if (!confirmDiscardMarkerChanges()) return
|
||||||
|
|
@ -297,12 +338,21 @@ export default function MapComponent({
|
||||||
cursor={isDraggingMap ? 'grabbing' : 'crosshair'}
|
cursor={isDraggingMap ? 'grabbing' : 'crosshair'}
|
||||||
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||||
mapLib={maplibregl}
|
mapLib={maplibregl}
|
||||||
initialViewState={{
|
initialViewState={initialViewState}
|
||||||
longitude: -101,
|
onMoveEnd={(e) => {
|
||||||
latitude: 40,
|
try {
|
||||||
zoom: 3.5,
|
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={() => {
|
onMouseDown={() => {
|
||||||
setIsDraggingMap(true)
|
setIsDraggingMap(true)
|
||||||
hideCoordinates()
|
hideCoordinates()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import MapsLayout from '~/layouts/MapsLayout'
|
import MapsLayout from '~/layouts/MapsLayout'
|
||||||
import { Head, Link, router } from '@inertiajs/react'
|
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 StyledButton from '~/components/StyledButton'
|
||||||
import { IconArrowLeft, IconMapPin, IconPlaneTilt } from '@tabler/icons-react'
|
import { IconArrowLeft, IconMapPin, IconPlaneTilt } from '@tabler/icons-react'
|
||||||
import { FileEntry } from '../../types/files'
|
import { FileEntry } from '../../types/files'
|
||||||
|
|
@ -18,7 +18,12 @@ type MapCommand = {
|
||||||
export default function Maps(props: {
|
export default function Maps(props: {
|
||||||
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
|
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 [mapCommand, setMapCommand] = useState<MapCommand | null>(null)
|
||||||
const [showCoordinatesEnabled, setShowCoordinatesEnabled] = useState(true)
|
const [showCoordinatesEnabled, setShowCoordinatesEnabled] = useState(true)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user