diff --git a/admin/inertia/components/maps/CoordinateOverlay.tsx b/admin/inertia/components/maps/CoordinateOverlay.tsx new file mode 100644 index 0000000..43166bb --- /dev/null +++ b/admin/inertia/components/maps/CoordinateOverlay.tsx @@ -0,0 +1,25 @@ +type CoordinateOverlayProps = { + latitude: number + longitude: number + x: number + y: number +} + +export default function CoordinateOverlay({ + latitude, + longitude, + x, + y, +}: CoordinateOverlayProps) { + return ( +
+ {latitude.toFixed(6)}, {longitude.toFixed(6)} +
+ ) +} diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index b906932..922713d 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -8,37 +8,130 @@ import Map, { 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 { useEffect, useRef, useState, useCallback } from 'react' +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' -import ScaleUnitToggle from './ScaleUnitToggle' type ScaleUnit = 'imperial' | 'metric' -export default function MapComponent() { +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 getMapLocationParams = (): MapLocationParams | null => { + const params = new URLSearchParams(window.location.search) + + const lat = Number(params.get('lat')) + const lngParam = params.get('lng') + const longParam = params.get('long') + const lng = Number(lngParam ?? longParam) + 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, + } +} + +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) + const confirmDiscardMarkerChanges = useCallback(() => { if (!hasUnsavedMarkerChanges) return true - return window.confirm('Discard unsaved marker changes?') }, [hasUnsavedMarkerChanges]) + const hideCoordinates = useCallback(() => { + setShowCoordinates(false) + 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) @@ -48,11 +141,72 @@ export default function MapComponent() { } }, []) + 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 handleMapLoad = useCallback(() => { + flyToLocationParams() + }, [flyToLocationParams]) + const handleMapClick = useCallback( (e: MapLayerMouseEvent) => { if (!confirmDiscardMarkerChanges()) return @@ -61,136 +215,241 @@ export default function MapComponent() { 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') + ) { + 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) - } + 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 + const selectedMarker = selectedMarkerId + ? markers.find((marker) => marker.id === selectedMarkerId) + : null return ( - { + setIsDraggingMap(false) + hideCoordinates() }} - mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`} - mapLib={maplibregl} - initialViewState={{ - longitude: -101, - latitude: 40, - zoom: 3.5, + onMouseMoveCapture={(e) => { + const target = e.target as HTMLElement | null + + if ( + target?.closest( + '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale' + ) + ) { + hideCoordinates() + } }} - onClick={handleMapClick} > - - - - + { + setIsDraggingMap(true) + hideCoordinates() + }} + onMouseUp={() => { + setIsDraggingMap(false) + }} + onDragStart={() => { + setIsDraggingMap(true) + hideCoordinates() + }} + onDragEnd={() => { + setIsDraggingMap(false) + hideCoordinates() + }} + onClick={handleMapClick} + onMouseMove={handleMouseMove} + onMouseLeave={hideCoordinates} + > + + + - {markers.map((marker) => ( - { - e.originalEvent.stopPropagation() - - if (!confirmDiscardMarkerChanges()) return - - setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) - setPlacingMarker(null) - setEditingMarkerId(null) - setHasUnsavedMarkerChanges(false) - }} - > - color.id === marker.color)?.hex} - active={marker.id === selectedMarkerId} + {showCoordinates && cursorLngLat && ( + - - ))} + )} - {placingMarker && ( - { - await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined) - setPlacingMarker(null) - setHasUnsavedMarkerChanges(false) - }} - onCancel={() => { - if (!confirmDiscardMarkerChanges()) return + {targetIndicator && ( + +