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
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user