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 59bcb7b..e1093b0 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -9,13 +9,17 @@ 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 { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' import type { PinColorId } from '~/hooks/useMapMarkers' + import MarkerPin from './MarkerPin' import MarkerPanel from './MarkerPanel' +import CoordinateOverlay from './CoordinateOverlay' +import ScaleUnitToggle from './ScaleUnitToggle' type ScaleUnit = 'imperial' | 'metric' @@ -65,9 +69,22 @@ const getMapLocationParams = (): MapLocationParams | null => { } export default function MapComponent() { +type MapComponentProps = { + isHoveringUI: boolean + showCoordinatesEnabled: boolean +} + +export default function MapComponent({ + isHoveringUI, + showCoordinatesEnabled, +}: MapComponentProps) { const mapRef = useRef(null) + const animationFrameRef = useRef(null) + const { markers, addMarker, deleteMarker } = useMapMarkers() + const [isDraggingMap, setIsDraggingMap] = useState(false) + const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) const [markerName, setMarkerName] = useState('') const [markerColor, setMarkerColor] = useState('orange') @@ -77,6 +94,13 @@ export default function MapComponent() { () => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric' ) + const [cursorLngLat, setCursorLngLat] = useState<{ + lng: number + lat: number + x: number + y: number + } | null>(null) + const flyToLocationParams = useCallback(() => { const location = getMapLocationParams() if (!location) return @@ -96,6 +120,8 @@ export default function MapComponent() { }) }, []) + const [showCoordinates, setShowCoordinates] = useState(false) + useEffect(() => { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) @@ -105,6 +131,55 @@ export default function MapComponent() { } }, []) + useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, []) + + const hideCoordinates = useCallback(() => { + setShowCoordinates(false) + setCursorLngLat(null) + }, []) + + const handleScaleUnitChange = useCallback((unit: ScaleUnit) => { + setScaleUnit(unit) + localStorage.setItem('nomad:map-scale-unit', unit) + }, []) + + 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 handleMapLoad = useCallback(() => { flyToLocationParams() }, [flyToLocationParams]) @@ -141,176 +216,181 @@ export default function MapComponent() { 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() + } }} - onLoad={handleMapLoad} - 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() - setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) - setPlacingMarker(null) - }} - > - color.id === marker.color)?.hex} - active={marker.id === selectedMarkerId} + {showCoordinates && cursorLngLat && ( + - - ))} + )} - {selectedMarker && ( - setSelectedMarkerId(null)} - closeOnClick={false} - > -
{selectedMarker.name}
-
- )} + - {placingMarker && ( - setPlacingMarker(null)} - closeOnClick={false} - > -
- setMarkerName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveMarker() - if (e.key === 'Escape') setPlacingMarker(null) - }} - className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" + {markers.map((marker) => ( + { + e.originalEvent.stopPropagation() + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) + setPlacingMarker(null) + }} + > + c.id === marker.color)?.hex} + active={marker.id === selectedMarkerId} /> + + ))} -
- {PIN_COLORS.map((color) => ( + {selectedMarker && ( + setSelectedMarkerId(null)} + closeOnClick={false} + > +
{selectedMarker.name}
+
+ )} + + {placingMarker && ( + setPlacingMarker(null)} + closeOnClick={false} + > +
+ setMarkerName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveMarker() + if (e.key === 'Escape') setPlacingMarker(null) + }} + className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" + /> + +
+ {PIN_COLORS.map((c) => ( + + ))} +
+ +
- ))} + + +
+
+ )} + +
-
- - - -
-
-
- )} -
- - +
+ +
) } diff --git a/admin/inertia/components/maps/ScaleUnitToggle.tsx b/admin/inertia/components/maps/ScaleUnitToggle.tsx new file mode 100644 index 0000000..ba0bceb --- /dev/null +++ b/admin/inertia/components/maps/ScaleUnitToggle.tsx @@ -0,0 +1,46 @@ +type ScaleUnit = 'imperial' | 'metric' + +type ScaleUnitToggleProps = { + scaleUnit: ScaleUnit + onChange: (unit: ScaleUnit) => void + onMouseEnter?: () => void +} + +export default function ScaleUnitToggle({ + scaleUnit, + onChange, + onMouseEnter, +}: ScaleUnitToggleProps) { + return ( +
+
+ + + +
+
+ ) +} diff --git a/admin/inertia/pages/maps.tsx b/admin/inertia/pages/maps.tsx index 9fe20e0..a1d8df1 100644 --- a/admin/inertia/pages/maps.tsx +++ b/admin/inertia/pages/maps.tsx @@ -1,38 +1,66 @@ -import MapsLayout from '~/layouts/MapsLayout' +import { useState } from 'react' import { Head, Link, router } from '@inertiajs/react' +import { IconArrowLeft } from '@tabler/icons-react' + +import MapsLayout from '~/layouts/MapsLayout' import MapComponent from '~/components/maps/MapComponent' import StyledButton from '~/components/StyledButton' -import { IconArrowLeft } from '@tabler/icons-react' -import { FileEntry } from '../../types/files' import Alert from '~/components/Alert' +import { FileEntry } from '../../types/files' + export default function Maps(props: { maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } }) { + const [isHoveringUI, setIsHoveringUI] = useState(false) + const [showMapCoordinates, setShowMapCoordinates] = useState(true) + const alertMessage = !props.maps.baseAssetsExist ? 'The base map assets have not been installed. Please download them first to enable map functionality.' : props.maps.regionFiles.length === 0 - ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.' - : null + ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.' + : null return ( +
- {/* Nav and alerts are overlayed */} -
+ {/* Navbar */} +
setIsHoveringUI(true)} + onMouseLeave={() => setIsHoveringUI(false)} + >

Back to Home

- - - Manage Map Regions - - + +
+ + + + + Manage Map Regions + + +
+ + {/* Alert */} {alertMessage && ( -
+
setIsHoveringUI(true)} + onMouseLeave={() => setIsHoveringUI(false)} + >
)} + + {/* Map */}
- +