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(null) const animationFrameRef = useRef(null) const handledMapCommandIdRef = useRef(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(null) const [editingMarkerId, setEditingMarkerId] = useState(null) const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false) const [showCoordinates, setShowCoordinates] = useState(false) const [scaleUnit, setScaleUnit] = useState( () => (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 (
{ 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() } }} > { 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} > {showCoordinates && cursorLngLat && ( )} {targetIndicator && (