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.

This commit is contained in:
Kenneth Brewer 2026-04-25 03:41:02 -04:00
parent 08d14473d2
commit 732296d179
3 changed files with 145 additions and 37 deletions

View File

@ -13,22 +13,34 @@ import { Protocol } from 'pmtiles'
import { useEffect, useRef, useState, useCallback } from 'react'
type ScaleUnit = 'imperial' | 'metric'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import type { PinColorId } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
export default function MapComponent() {
export default function MapComponent({ isHoveringUI, showCoordinatesEnabled, }: {isHoveringUI: boolean, showCoordinatesEnabled: boolean}) {
const mapRef = useRef<MapRef>(null)
const { markers, addMarker, deleteMarker } = useMapMarkers()
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
const [markerName, setMarkerName] = useState('')
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
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)
const [showCoordinates, setShowCoordinates] = useState(false)
const toggleScaleUnit = useCallback(() => {
setScaleUnit((prev) => {
const next = prev === 'metric' ? 'imperial' : 'metric'
@ -37,15 +49,45 @@ export default function MapComponent() {
})
}, [])
// Add the PMTiles protocol to maplibre-gl
useEffect(() => {
let protocol = new Protocol()
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
return () => {
maplibregl.removeProtocol('pmtiles')
}
}, [])
const hideCoordinates = useCallback(() => {
setShowCoordinates(false)
setCursorLngLat(null)
}, [])
const handleMouseMove = useCallback((e: MapLayerMouseEvent) => {
const target = e.originalEvent.target as HTMLElement | null
if (target?.closest('.maplibregl-control-container, .maplibregl-ctrl')) {
hideCoordinates()
return
}
if (!showCoordinatesEnabled ||
isHoveringUI ||
target?.closest('.maplibregl-control-container, .maplibregl-ctrl')
) {
hideCoordinates()
return
}
setShowCoordinates(true)
setCursorLngLat({
lng: e.lngLat.lng,
lat: e.lngLat.lat,
x: e.point.x,
y: e.point.y,
})
}, [hideCoordinates, isHoveringUI])
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setMarkerName('')
@ -78,6 +120,21 @@ export default function MapComponent() {
return (
<MapProvider>
<div
style={{ position: 'relative', width: '100%', height: '100vh' }}
onMouseLeave={hideCoordinates}
onMouseMoveCapture={(e) => {
const target = e.target as HTMLElement | null
if (
target?.closest(
'.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale'
)
) {
hideCoordinates()
}
}}
>
<Map
ref={mapRef}
reuseMaps
@ -85,6 +142,7 @@ export default function MapComponent() {
width: '100%',
height: '100vh',
}}
cursor="crosshair"
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
mapLib={maplibregl}
initialViewState={{
@ -93,12 +151,38 @@ export default function MapComponent() {
zoom: 3.5,
}}
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 && showCoordinates && cursorLngLat && (
<div
style={{
position: 'absolute',
left: cursorLngLat.x,
top: cursorLngLat.y - 36,
transform: 'translateX(-50%)',
zIndex: 9999,
pointerEvents: 'none',
background: 'rgba(0, 0, 0, 0.75)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'monospace',
whiteSpace: 'nowrap',
}}
>
{cursorLngLat.lat.toFixed(6)}, {cursorLngLat.lng.toFixed(6)}
</div>
)}
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}>
<div
onMouseEnter={hideCoordinates}
style={{
display: 'inline-flex',
borderRadius: '4px',
@ -110,7 +194,9 @@ export default function MapComponent() {
}}
>
<button
onClick={() => { if (scaleUnit !== 'metric') toggleScaleUnit() }}
onClick={() => {
if (scaleUnit !== 'metric') toggleScaleUnit()
}}
style={{
background: scaleUnit === 'metric' ? '#424420' : 'white',
color: scaleUnit === 'metric' ? 'white' : '#666',
@ -121,8 +207,11 @@ export default function MapComponent() {
>
Metric
</button>
<button
onClick={() => { if (scaleUnit !== 'imperial') toggleScaleUnit() }}
onClick={() => {
if (scaleUnit !== 'imperial') toggleScaleUnit()
}}
style={{
background: scaleUnit === 'imperial' ? '#424420' : 'white',
color: scaleUnit === 'imperial' ? 'white' : '#666',
@ -136,7 +225,6 @@ export default function MapComponent() {
</div>
</div>
{/* Existing markers */}
{markers.map((marker) => (
<Marker
key={marker.id}
@ -156,7 +244,6 @@ export default function MapComponent() {
</Marker>
))}
{/* Popup for selected marker */}
{selectedMarker && (
<Popup
longitude={selectedMarker.longitude}
@ -170,7 +257,6 @@ export default function MapComponent() {
</Popup>
)}
{/* Popup for placing a new marker */}
{placingMarker && (
<Popup
longitude={placingMarker.lng}
@ -179,7 +265,7 @@ export default function MapComponent() {
onClose={() => setPlacingMarker(null)}
closeOnClick={false}
>
<div className="p-1">
<div onMouseEnter={hideCoordinates} className="p-1">
<input
autoFocus
type="text"
@ -192,6 +278,7 @@ export default function MapComponent() {
}}
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
@ -200,17 +287,16 @@ export default function MapComponent() {
title={c.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline: markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
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 }}
/>
<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)}
@ -218,6 +304,7 @@ export default function MapComponent() {
>
Cancel
</button>
<button
onClick={handleSaveMarker}
disabled={!markerName.trim()}
@ -230,15 +317,17 @@ export default function MapComponent() {
</Popup>
)}
</Map>
</div>
{/* Marker panel overlay */}
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
<div onMouseEnter={hideCoordinates}>
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</div>
</MapProvider>
)
}

View File

@ -5,10 +5,13 @@ import StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react'
import { FileEntry } from '../../types/files'
import Alert from '~/components/Alert'
import { useState } from 'react'
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
@ -20,19 +23,35 @@ export default function Maps(props: {
<Head title="Maps" />
<div className="relative w-full h-screen overflow-hidden">
{/* Nav and alerts are overlayed */}
<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">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p>
</Link>
<Link href="/settings/maps" className='mr-4'>
<StyledButton variant="primary" icon="IconSettings">
Manage Map Regions
</StyledButton>
</Link>
<div className="flex items-center gap-3 mr-4">
<button
type="button"
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>
{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
title={alertMessage}
type="warning"
@ -48,7 +67,7 @@ export default function Maps(props: {
</div>
)}
<div className="absolute inset-0">
<MapComponent />
<MapComponent isHoveringUI={isHoveringUI} showCoordinatesEnabled={showMapCoordinates} />
</div>
</div>
</MapsLayout>

View File

@ -9,8 +9,8 @@
name: project-nomad
services:
admin:
image: ghcr.io/crosstalk-solutions/project-nomad:latest
pull_policy: always
image: project-nomad:local
pull_policy: never #ghcr.io/crosstalk-solutions/project-nomad:latest
container_name: nomad_admin
restart: unless-stopped
extra_hosts:
@ -27,18 +27,18 @@ services:
- PORT=8080
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=replaceme
- APP_KEY=ce9579a56b32e05ee432be91e6d1d9b95cb3a00eb92789559569decdb76c4db2
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)
- URL=replaceme
- URL=http://nomad-dev.brewerhomestead.com/
- DB_HOST=mysql
# If you change the MySQL port, make sure to update this accordingly
- DB_PORT=3306
- DB_DATABASE=nomad
- DB_USER=nomad_user
# Needs to match the MYSQL_PASSWORD in the mysql service!
- DB_PASSWORD=replaceme
- DB_PASSWORD=2bc4dad18d5a0ecf5914428d91ebde27d42e8afafd72c73089a0dd1eba9ae1fc
- DB_NAME=nomad
- DB_SSL=false
- REDIS_HOST=redis
@ -72,11 +72,11 @@ services:
container_name: nomad_mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=replaceme
- MYSQL_ROOT_PASSWORD=d797ecbd5cee07758909a628cb0a2a75285dc3bac42d1513a8d2aed95c632577
- MYSQL_DATABASE=nomad
- MYSQL_USER=nomad_user
# Needs to match DB_PASSWORD in the admin service!
- MYSQL_PASSWORD=replaceme
- MYSQL_PASSWORD=2bc4dad18d5a0ecf5914428d91ebde27d42e8afafd72c73089a0dd1eba9ae1fc
volumes:
- /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
healthcheck: