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:
parent
08d14473d2
commit
732296d179
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue