project-nomad/admin/inertia/components/maps/MapComponent.tsx

506 lines
15 KiB
TypeScript

import Map, {
FullscreenControl,
NavigationControl,
ScaleControl,
Marker,
MapProvider,
} from 'react-map-gl/maplibre'
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
import CoordinateOverlay from './CoordinateOverlay'
import ScaleUnitToggle from './ScaleUnitToggle'
import ViewMapMarkerPopup from './ViewMapMarkerPopup'
import MapMarkerFormPopup from './MapMarkerFormPopup'
type ScaleUnit = 'imperial' | 'metric'
type MapCommand = {
id: number
lat: number
lng: number
action: 'fly' | 'marker'
}
type MapComponentProps = {
mapCommand?: MapCommand | null
isHoveringUI?: boolean
showCoordinatesEnabled?: boolean
}
type MapLocationParams = {
lat: number
lng: number
zoom: number
}
const SAVED_MAP_VIEW_KEY = 'nomad:map-view'
export const getMapLocationParams = (): MapLocationParams | null => {
const params = new URLSearchParams(window.location.search)
const latParam = params.get('lat')
const lngParam = params.get('lng')
const longParam = params.get('long')
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 (
!Number.isFinite(lat) ||
!Number.isFinite(lng) ||
lat < -90 ||
lat > 90 ||
lng < -180 ||
lng > 180
) {
return null
}
if (!lngParam && longParam) {
params.set('lng', longParam)
params.delete('long')
const query = params.toString()
window.history.replaceState(
null,
'',
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`
)
}
return {
lat,
lng,
zoom: Number.isFinite(zoom) ? zoom : 12,
}
}
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,
showCoordinatesEnabled = true,
}: MapComponentProps) {
const mapRef = useRef<MapRef>(null)
const animationFrameRef = useRef<number | null>(null)
const handledMapCommandIdRef = useRef<number | null>(null)
const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers()
const [targetIndicator, setTargetIndicator] = useState<{ lng: number; lat: number } | null>(null)
const [isDraggingMap, setIsDraggingMap] = useState(false)
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
const [editingMarkerId, setEditingMarkerId] = useState<number | null>(null)
const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false)
const [showCoordinates, setShowCoordinates] = useState(false)
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
)
const [cursorLngLat, setCursorLngLat] = useState<{
lng: number
lat: number
x: number
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?')
}, [hasUnsavedMarkerChanges])
const hideCoordinates = useCallback(() => {
setShowCoordinates(false)
setCursorLngLat(null)
}, [])
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
return () => {
maplibregl.removeProtocol('pmtiles')
}
}, [])
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [])
useEffect(() => {
if (!mapCommand) return
if (handledMapCommandIdRef.current === mapCommand.id) return
handledMapCommandIdRef.current = mapCommand.id
if (mapCommand.action === 'fly') {
const currentZoom = mapRef.current?.getZoom() ?? 12
setTargetIndicator({
lng: mapCommand.lng,
lat: mapCommand.lat,
})
mapRef.current?.flyTo({
center: [mapCommand.lng, mapCommand.lat],
zoom: currentZoom,
duration: 1500,
})
return
}
if (mapCommand.action === 'marker') {
if (!confirmDiscardMarkerChanges()) return
setTargetIndicator(null)
const currentZoom = mapRef.current?.getZoom() ?? 12
mapRef.current?.flyTo({
center: [mapCommand.lng, mapCommand.lat],
zoom: currentZoom,
duration: 750,
})
window.setTimeout(() => {
setPlacingMarker({
lng: mapCommand.lng,
lat: mapCommand.lat,
})
setSelectedMarkerId(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}, 750)
}
}, [mapCommand, confirmDiscardMarkerChanges])
const handleScaleUnitChange = useCallback((unit: ScaleUnit) => {
setScaleUnit(unit)
localStorage.setItem('nomad:map-scale-unit', unit)
}, [])
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
if (!confirmDiscardMarkerChanges()) return
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setSelectedMarkerId(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
},
[confirmDiscardMarkerChanges]
)
const handleMouseMove = useCallback(
(e: MapLayerMouseEvent) => {
const target = e.originalEvent.target as HTMLElement | null
if (
!showCoordinatesEnabled ||
isHoveringUI ||
isDraggingMap ||
target?.closest('.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-popup')
) {
hideCoordinates()
return
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
animationFrameRef.current = requestAnimationFrame(() => {
setShowCoordinates(true)
setCursorLngLat({
lng: e.lngLat.lng,
lat: e.lngLat.lat,
x: e.point.x,
y: e.point.y,
})
})
},
[hideCoordinates, isHoveringUI, isDraggingMap, showCoordinatesEnabled]
)
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
setTargetIndicator(null)
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
}, [])
const handleDeleteMarker = useCallback(
(id: number) => {
if (selectedMarkerId === id) setSelectedMarkerId(null)
if (editingMarkerId === id) setEditingMarkerId(null)
deleteMarker(id)
},
[selectedMarkerId, editingMarkerId, deleteMarker]
)
const selectedMarker = selectedMarkerId
? markers.find((marker) => marker.id === selectedMarkerId)
: null
return (
<MapProvider>
<div
style={{ position: 'relative', width: '100%', height: '100vh' }}
onMouseLeave={() => {
setIsDraggingMap(false)
hideCoordinates()
}}
onMouseMoveCapture={(e) => {
const target = e.target as HTMLElement | null
if (
target?.closest(
'.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale, .maplibregl-popup'
)
) {
hideCoordinates()
}
}}
>
<Map
ref={mapRef}
reuseMaps
style={{ width: '100%', height: '100vh' }}
cursor={isDraggingMap ? 'grabbing' : 'crosshair'}
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
mapLib={maplibregl}
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
}
}}
onMouseDown={() => {
setIsDraggingMap(true)
hideCoordinates()
}}
onMouseUp={() => {
setIsDraggingMap(false)
}}
onDragStart={() => {
setIsDraggingMap(true)
hideCoordinates()
}}
onDragEnd={() => {
setIsDraggingMap(false)
hideCoordinates()
}}
onClick={handleMapClick}
onMouseMove={handleMouseMove}
onMouseLeave={hideCoordinates}
>
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
{showCoordinates && cursorLngLat && (
<CoordinateOverlay
latitude={cursorLngLat.lat}
longitude={cursorLngLat.lng}
x={cursorLngLat.x}
y={cursorLngLat.y}
/>
)}
{targetIndicator && (
<Marker longitude={targetIndicator.lng} latitude={targetIndicator.lat} anchor="center">
<div
className="pointer-events-none flex h-9 w-9 items-center justify-center rounded-full border-2 border-desert-orange bg-surface-primary/70 shadow-lg"
aria-hidden="true"
>
<div className="relative h-5 w-5">
<div className="absolute left-1/2 top-0 h-full w-[2px] -translate-x-1/2 bg-desert-orange" />
<div className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 bg-desert-orange" />
</div>
</div>
</Marker>
)}
<ScaleUnitToggle
scaleUnit={scaleUnit}
onChange={handleScaleUnitChange}
onMouseEnter={hideCoordinates}
/>
{markers.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
if (!confirmDiscardMarkerChanges()) return
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
>
<MarkerPin
color={PIN_COLORS.find((color) => color.id === marker.color)?.hex}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{placingMarker && (
<MapMarkerFormPopup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
onDirtyChange={setHasUnsavedMarkerChanges}
onSave={async ({ name, notes, color }) => {
await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined)
setPlacingMarker(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
/>
)}
{selectedMarker && editingMarkerId !== selectedMarker.id && (
<ViewMapMarkerPopup
marker={selectedMarker}
onClose={() => setSelectedMarkerId(null)}
onEdit={() => setEditingMarkerId(selectedMarker.id)}
onMouseEnter={hideCoordinates}
/>
)}
{selectedMarker && editingMarkerId === selectedMarker.id && (
<MapMarkerFormPopup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
initialMarker={selectedMarker}
onDirtyChange={setHasUnsavedMarkerChanges}
onMouseEnter={hideCoordinates}
onSave={async ({ id, name, notes, color }) => {
if (!id) return
await updateMarker(id, {
name,
notes: notes || null,
color,
})
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
/>
)}
</Map>
</div>
<div onMouseEnter={hideCoordinates}>
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</div>
</MapProvider>
)
}