feat(maps): show map coordinates on mouse move (#786)

* feat: Updated the map to show the coordinates as the user moves the cursor over the map. Changed the cursor to a crosshairs to make it easier to place map markers.

* Moved the scale unit control to its own component file for easier maintenance. Enhanced the behavior of the coordinate display on the map to not display when over the on screen controls, and the navigation bar. Added a toggle to turn off the coordinate display if the user doesn't wish to see it. Intentionally left the coordinate display when over a map marker so that the coordinates of the map marker can be estimated. In the future I intend to add the coordinates of a map marker when the map marker is clicked so that behavior may change in the future.
---------

Co-authored-by: Kenneth Brewer <kennethbrewer3@protonmail.com>
This commit is contained in:
Kenneth Brewer 2026-05-03 17:11:03 -04:00 committed by Jake Turner
parent 8c06b5ba67
commit 08838b1944
4 changed files with 362 additions and 177 deletions

View File

@ -0,0 +1,25 @@
type CoordinateOverlayProps = {
latitude: number
longitude: number
x: number
y: number
}
export default function CoordinateOverlay({
latitude,
longitude,
x,
y,
}: CoordinateOverlayProps) {
return (
<div
className="pointer-events-none absolute z-[9999] -translate-x-1/2 whitespace-nowrap rounded bg-black/75 px-2 py-1 font-mono text-[11px] text-white"
style={{
left: x,
top: y - 36,
}}
>
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</div>
)
}

View File

@ -9,43 +9,111 @@ import Map, {
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre' import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
import maplibregl from 'maplibre-gl' import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles' import { Protocol } from 'pmtiles'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
type ScaleUnit = 'imperial' | 'metric'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import type { PinColorId } from '~/hooks/useMapMarkers' import type { PinColorId } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin' import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel' import MarkerPanel from './MarkerPanel'
import CoordinateOverlay from './CoordinateOverlay'
import ScaleUnitToggle from './ScaleUnitToggle'
export default function MapComponent() { type ScaleUnit = 'imperial' | 'metric'
type MapComponentProps = {
isHoveringUI: boolean
showCoordinatesEnabled: boolean
}
export default function MapComponent({
isHoveringUI,
showCoordinatesEnabled,
}: MapComponentProps) {
const mapRef = useRef<MapRef>(null) const mapRef = useRef<MapRef>(null)
const animationFrameRef = useRef<number | null>(null)
const { markers, addMarker, deleteMarker } = useMapMarkers() const { markers, addMarker, deleteMarker } = useMapMarkers()
const [isDraggingMap, setIsDraggingMap] = useState(false)
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
const [markerName, setMarkerName] = useState('') const [markerName, setMarkerName] = useState('')
const [markerColor, setMarkerColor] = useState<PinColorId>('orange') const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null) const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>( const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric' () => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
) )
const toggleScaleUnit = useCallback(() => { const [cursorLngLat, setCursorLngLat] = useState<{
setScaleUnit((prev) => { lng: number
const next = prev === 'metric' ? 'imperial' : 'metric' lat: number
localStorage.setItem('nomad:map-scale-unit', next) x: number
return next y: number
}) } | null>(null)
}, [])
const [showCoordinates, setShowCoordinates] = useState(false)
// Add the PMTiles protocol to maplibre-gl
useEffect(() => { useEffect(() => {
let protocol = new Protocol() const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile) maplibregl.addProtocol('pmtiles', protocol.tile)
return () => { return () => {
maplibregl.removeProtocol('pmtiles') maplibregl.removeProtocol('pmtiles')
} }
}, []) }, [])
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 handleMapClick = useCallback((e: MapLayerMouseEvent) => { const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setMarkerName('') setMarkerName('')
@ -78,167 +146,180 @@ export default function MapComponent() {
return ( return (
<MapProvider> <MapProvider>
<Map <div
ref={mapRef} style={{ position: 'relative', width: '100%', height: '100vh' }}
reuseMaps onMouseLeave={() => {
style={{ setIsDraggingMap(false)
width: '100%', hideCoordinates()
height: '100vh',
}} }}
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`} onMouseMoveCapture={(e) => {
mapLib={maplibregl} const target = e.target as HTMLElement | null
initialViewState={{
longitude: -101, if (
latitude: 40, target?.closest(
zoom: 3.5, '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale'
)
) {
hideCoordinates()
}
}} }}
onClick={handleMapClick}
> >
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} /> <Map
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} /> ref={mapRef}
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} /> reuseMaps
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}> style={{ width: '100%', height: '100vh' }}
<div cursor={isDraggingMap ? 'grabbing' : 'crosshair'}
style={{ mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
display: 'inline-flex', mapLib={maplibregl}
borderRadius: '4px', initialViewState={{
boxShadow: '0 0 0 2px rgba(0,0,0,0.1)', longitude: -101,
overflow: 'hidden', latitude: 40,
fontSize: '11px', zoom: 3.5,
fontWeight: 600, }}
lineHeight: 1, onMouseDown={() => {
}} setIsDraggingMap(true)
> hideCoordinates()
<button }}
onClick={() => { if (scaleUnit !== 'metric') toggleScaleUnit() }} onMouseUp={() => {
style={{ setIsDraggingMap(false)
background: scaleUnit === 'metric' ? '#424420' : 'white', }}
color: scaleUnit === 'metric' ? 'white' : '#666', onDragStart={() => {
border: 'none', setIsDraggingMap(true)
padding: '4px 8px', hideCoordinates()
cursor: 'pointer', }}
}} onDragEnd={() => {
> setIsDraggingMap(false)
Metric hideCoordinates()
</button> }}
<button onClick={handleMapClick}
onClick={() => { if (scaleUnit !== 'imperial') toggleScaleUnit() }} onMouseMove={handleMouseMove}
style={{ onMouseLeave={hideCoordinates}
background: scaleUnit === 'imperial' ? '#424420' : 'white', >
color: scaleUnit === 'imperial' ? 'white' : '#666', <NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
border: 'none', <FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
padding: '4px 8px', <ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
cursor: 'pointer',
}}
>
Imperial
</button>
</div>
</div>
{/* Existing markers */} {showCoordinates && cursorLngLat && (
{markers.map((marker) => ( <CoordinateOverlay
<Marker latitude={cursorLngLat.lat}
key={marker.id} longitude={cursorLngLat.lng}
longitude={marker.longitude} x={cursorLngLat.x}
latitude={marker.latitude} y={cursorLngLat.y}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
}}
>
<MarkerPin
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
active={marker.id === selectedMarkerId}
/> />
</Marker> )}
))}
{/* Popup for selected marker */} <ScaleUnitToggle
{selectedMarker && ( scaleUnit={scaleUnit}
<Popup onChange={handleScaleUnitChange}
longitude={selectedMarker.longitude} onMouseEnter={hideCoordinates}
latitude={selectedMarker.latitude} />
anchor="bottom"
offset={[0, -36] as [number, number]}
onClose={() => setSelectedMarkerId(null)}
closeOnClick={false}
>
<div className="text-sm font-medium">{selectedMarker.name}</div>
</Popup>
)}
{/* Popup for placing a new marker */} {markers.map((marker) => (
{placingMarker && ( <Marker
<Popup key={marker.id}
longitude={placingMarker.lng} longitude={marker.longitude}
latitude={placingMarker.lat} latitude={marker.latitude}
anchor="bottom" anchor="bottom"
onClose={() => setPlacingMarker(null)} onClick={(e) => {
closeOnClick={false} e.originalEvent.stopPropagation()
> setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
<div className="p-1"> setPlacingMarker(null)
<input }}
autoFocus >
type="text" <MarkerPin
placeholder="Name this location" color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
value={markerName} active={marker.id === selectedMarkerId}
onChange={(e) => 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"
/> />
<div className="mt-1.5 flex gap-1 items-center"> </Marker>
{PIN_COLORS.map((c) => ( ))}
<button
key={c.id}
onClick={() => setMarkerColor(c.id)}
title={c.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline: markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
outlineOffset: '1px',
}}
>
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: c.hex }}
/>
</button>
))}
</div>
<div className="mt-1.5 flex gap-1.5 justify-end">
<button
onClick={() => setPlacingMarker(null)}
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveMarker}
disabled={!markerName.trim()}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Save
</button>
</div>
</div>
</Popup>
)}
</Map>
{/* Marker panel overlay */} {selectedMarker && (
<MarkerPanel <Popup
markers={markers} longitude={selectedMarker.longitude}
onDelete={handleDeleteMarker} latitude={selectedMarker.latitude}
onFlyTo={handleFlyTo} anchor="bottom"
onSelect={setSelectedMarkerId} offset={[0, -36]}
selectedMarkerId={selectedMarkerId} onClose={() => setSelectedMarkerId(null)}
/> closeOnClick={false}
>
<div className="text-sm font-medium">{selectedMarker.name}</div>
</Popup>
)}
{placingMarker && (
<Popup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
anchor="bottom"
onClose={() => setPlacingMarker(null)}
closeOnClick={false}
>
<div onMouseEnter={hideCoordinates} className="p-1">
<input
autoFocus
type="text"
placeholder="Name this location"
value={markerName}
onChange={(e) => 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"
/>
<div className="mt-1.5 flex gap-1 items-center">
{PIN_COLORS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setMarkerColor(c.id)}
title={c.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline:
markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
outlineOffset: '1px',
}}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: c.hex }} />
</button>
))}
</div>
<div className="mt-1.5 flex gap-1.5 justify-end">
<button
type="button"
onClick={() => setPlacingMarker(null)}
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSaveMarker}
disabled={!markerName.trim()}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Save
</button>
</div>
</div>
</Popup>
)}
</Map>
</div>
<div onMouseEnter={hideCoordinates}>
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</div>
</MapProvider> </MapProvider>
) )
} }

View File

@ -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 (
<div
className="absolute bottom-[30px] left-[10px] z-[2]"
onMouseEnter={onMouseEnter}
>
<div className="inline-flex overflow-hidden rounded text-[11px] font-semibold leading-none shadow-[0_0_0_2px_rgba(0,0,0,0.1)]">
<button
type="button"
onClick={() => onChange('metric')}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'metric' ? '#424420' : 'white',
color: scaleUnit === 'metric' ? 'white' : '#666',
}}
>
Metric
</button>
<button
type="button"
onClick={() => onChange('imperial')}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'imperial' ? '#424420' : 'white',
color: scaleUnit === 'imperial' ? 'white' : '#666',
}}
>
Imperial
</button>
</div>
</div>
)
}

View File

@ -1,38 +1,66 @@
import MapsLayout from '~/layouts/MapsLayout' import { useState } from 'react'
import { Head, Link, router } from '@inertiajs/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 MapComponent from '~/components/maps/MapComponent'
import StyledButton from '~/components/StyledButton' import StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react'
import { FileEntry } from '../../types/files'
import Alert from '~/components/Alert' import Alert from '~/components/Alert'
import { FileEntry } from '../../types/files'
export default function Maps(props: { export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
}) { }) {
const [isHoveringUI, setIsHoveringUI] = useState(false)
const [showMapCoordinates, setShowMapCoordinates] = useState(true)
const alertMessage = !props.maps.baseAssetsExist const alertMessage = !props.maps.baseAssetsExist
? 'The base map assets have not been installed. Please download them first to enable map functionality.' ? 'The base map assets have not been installed. Please download them first to enable map functionality.'
: props.maps.regionFiles.length === 0 : props.maps.regionFiles.length === 0
? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.' ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.'
: null : null
return ( return (
<MapsLayout> <MapsLayout>
<Head title="Maps" /> <Head title="Maps" />
<div className="relative w-full h-screen overflow-hidden"> <div className="relative w-full h-screen overflow-hidden">
{/* Nav and alerts are overlayed */} {/* Navbar */}
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm"> <div
className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm"
onMouseEnter={() => setIsHoveringUI(true)}
onMouseLeave={() => setIsHoveringUI(false)}
>
<Link href="/home" className="flex items-center"> <Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} /> <IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p> <p className="text-lg text-text-secondary">Back to Home</p>
</Link> </Link>
<Link href="/settings/maps" className='mr-4'>
<StyledButton variant="primary" icon="IconSettings"> <div className="flex items-center gap-3 mr-4">
Manage Map Regions <button
</StyledButton> type="button"
</Link> onClick={() => setShowMapCoordinates((prev) => !prev)}
className="rounded px-3 py-2 text-sm bg-surface-primary text-text-secondary hover:opacity-80 transition"
>
{showMapCoordinates ? 'Hide Coordinates' : 'Show Coordinates'}
</button>
<Link href="/settings/maps">
<StyledButton variant="primary" icon="IconSettings">
Manage Map Regions
</StyledButton>
</Link>
</div>
</div> </div>
{/* Alert */}
{alertMessage && ( {alertMessage && (
<div className="absolute top-20 left-4 right-4 z-50"> <div
className="absolute top-20 left-4 right-4 z-50"
onMouseEnter={() => setIsHoveringUI(true)}
onMouseLeave={() => setIsHoveringUI(false)}
>
<Alert <Alert
title={alertMessage} title={alertMessage}
type="warning" type="warning"
@ -47,8 +75,13 @@ export default function Maps(props: {
/> />
</div> </div>
)} )}
{/* Map */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<MapComponent /> <MapComponent
isHoveringUI={isHoveringUI}
showCoordinatesEnabled={showMapCoordinates}
/>
</div> </div>
</div> </div>
</MapsLayout> </MapsLayout>