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 (
-
+
+
+
+
+
)
}
diff --git a/admin/inertia/components/maps/MapMarkerFormPopup.tsx b/admin/inertia/components/maps/MapMarkerFormPopup.tsx
index 94ff34b..a84278e 100644
--- a/admin/inertia/components/maps/MapMarkerFormPopup.tsx
+++ b/admin/inertia/components/maps/MapMarkerFormPopup.tsx
@@ -21,6 +21,7 @@ type MapMarkerFormPopupProps = {
}) => Promise | void
onCancel: () => void
onDirtyChange?: (dirty: boolean) => void
+ onMouseEnter?: () => void
}
export default function MapMarkerFormPopup({
@@ -30,6 +31,7 @@ export default function MapMarkerFormPopup({
onSave,
onCancel,
onDirtyChange,
+ onMouseEnter,
}: MapMarkerFormPopupProps) {
const [name, setName] = useState(initialMarker?.name ?? '')
const [notes, setNotes] = useState(initialMarker?.notes ?? '')
@@ -50,6 +52,13 @@ export default function MapMarkerFormPopup({
resizeTextarea()
}, [resizeTextarea])
+ const nameInputRef = useRef(null)
+
+ useLayoutEffect(() => {
+ nameInputRef.current?.focus()
+ nameInputRef.current?.select()
+ }, [])
+
const isDirty =
name !== (initialMarker?.name ?? '') ||
notes !== (initialMarker?.notes ?? '') ||
@@ -84,9 +93,17 @@ export default function MapMarkerFormPopup({
offset={[0, -36] as [number, number]}
onClose={onCancel}
closeOnClick={false}
+ closeButton={false}
>
-
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onMouseEnter={onMouseEnter}
+ >
-
+
))}
@@ -140,7 +157,7 @@ export default function MapMarkerFormPopup({
type="button"
onClick={onCancel}
disabled={isSaving}
- className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors disabled:opacity-40"
+ className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Cancel
diff --git a/admin/inertia/components/maps/MarkerPin.tsx b/admin/inertia/components/maps/MarkerPin.tsx
index 94c86af..c7ff026 100644
--- a/admin/inertia/components/maps/MarkerPin.tsx
+++ b/admin/inertia/components/maps/MarkerPin.tsx
@@ -1,17 +1,71 @@
-import { IconMapPinFilled } from '@tabler/icons-react'
+import {IconCircleFilled} from '@tabler/icons-react'
+import type { ComponentType, CSSProperties } from 'react'
+
+type MarkerIconProps = {
+ size?: number
+ color?: string
+ style?: CSSProperties
+ className?: string
+}
interface MarkerPinProps {
color?: string
active?: boolean
+ Icon?: ComponentType
+ iconColor?: string
}
-export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) {
+export default function MarkerPin({
+ color = '#a84a12',
+ active = false,
+ Icon = IconCircleFilled,
+ iconColor = '#ffffff',
+ }: MarkerPinProps) {
+ const width = active ? 42 : 36
+ const height = active ? 52 : 46
+ const iconSize = active ? 18 : 16
+
return (
-
-
+
+
+
+
+
+
)
}
diff --git a/admin/inertia/components/maps/ScaleUnitToggle.tsx b/admin/inertia/components/maps/ScaleUnitToggle.tsx
index 32cb788..a59c44e 100644
--- a/admin/inertia/components/maps/ScaleUnitToggle.tsx
+++ b/admin/inertia/components/maps/ScaleUnitToggle.tsx
@@ -1,34 +1,28 @@
type ScaleUnit = 'imperial' | 'metric'
-type ScaleUnitControlProps = {
+type ScaleUnitToggleProps = {
scaleUnit: ScaleUnit
onChange: (unit: ScaleUnit) => void
+ onMouseEnter?: () => void
}
-export default function ScaleUnitToggle({ scaleUnit, onChange }: ScaleUnitControlProps) {
+export default function ScaleUnitToggle({
+ scaleUnit,
+ onChange,
+ onMouseEnter,
+ }: ScaleUnitToggleProps) {
return (
-
-
+
+