mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-31 16:46:49 +02:00
Merge branch 'feat/map-url-params' into feat/map-marker-customizations
This commit is contained in:
commit
667a8e32df
25
admin/inertia/components/maps/CoordinateOverlay.tsx
Normal file
25
admin/inertia/components/maps/CoordinateOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<MapRef>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const handledMapCommandIdRef = useRef<number | null>(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<number | null>(null)
|
||||
const [editingMarkerId, setEditingMarkerId] = useState<number | null>(null)
|
||||
const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false)
|
||||
const [showCoordinates, setShowCoordinates] = useState(false)
|
||||
|
||||
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 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 (
|
||||
<MapProvider>
|
||||
<Map
|
||||
ref={mapRef}
|
||||
reuseMaps
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
<div
|
||||
style={{ position: 'relative', width: '100%', height: '100vh' }}
|
||||
onMouseLeave={() => {
|
||||
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()
|
||||
}
|
||||
}}
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
|
||||
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
|
||||
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
|
||||
<ScaleUnitToggle scaleUnit={scaleUnit} onChange={handleScaleUnitChange} />
|
||||
<Map
|
||||
ref={mapRef}
|
||||
reuseMaps
|
||||
style={{ width: '100%', height: '100vh' }}
|
||||
cursor={isDraggingMap ? 'grabbing' : 'crosshair'}
|
||||
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||
mapLib={maplibregl}
|
||||
initialViewState={{
|
||||
longitude: -101,
|
||||
latitude: 40,
|
||||
zoom: 3.5,
|
||||
}}
|
||||
onLoad={handleMapLoad}
|
||||
onMouseDown={() => {
|
||||
setIsDraggingMap(true)
|
||||
hideCoordinates()
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
setIsDraggingMap(false)
|
||||
}}
|
||||
onDragStart={() => {
|
||||
setIsDraggingMap(true)
|
||||
hideCoordinates()
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setIsDraggingMap(false)
|
||||
hideCoordinates()
|
||||
}}
|
||||
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} />
|
||||
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation()
|
||||
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
>
|
||||
<MarkerPin
|
||||
color={PIN_COLORS.find((color) => color.id === marker.color)?.hex}
|
||||
active={marker.id === selectedMarkerId}
|
||||
{showCoordinates && cursorLngLat && (
|
||||
<CoordinateOverlay
|
||||
latitude={cursorLngLat.lat}
|
||||
longitude={cursorLngLat.lng}
|
||||
x={cursorLngLat.x}
|
||||
y={cursorLngLat.y}
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
)}
|
||||
|
||||
{placingMarker && (
|
||||
<MapMarkerFormPopup
|
||||
longitude={placingMarker.lng}
|
||||
latitude={placingMarker.lat}
|
||||
onDirtyChange={setHasUnsavedMarkerChanges}
|
||||
onSave={async ({ name, notes, color }) => {
|
||||
await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined)
|
||||
setPlacingMarker(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
{targetIndicator && (
|
||||
<Marker longitude={targetIndicator.lng} latitude={targetIndicator.lat} anchor="center">
|
||||
<div
|
||||
className="pointer-events-none flex h-9 w-9 items-center justify-center rounded-full border-2 border-desert-orange bg-surface-primary/70 shadow-lg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="relative h-5 w-5">
|
||||
<div className="absolute left-1/2 top-0 h-full w-[2px] -translate-x-1/2 bg-desert-orange" />
|
||||
<div className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 bg-desert-orange" />
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
<ScaleUnitToggle
|
||||
scaleUnit={scaleUnit}
|
||||
onChange={handleScaleUnitChange}
|
||||
onMouseEnter={hideCoordinates}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMarker && editingMarkerId !== selectedMarker.id && (
|
||||
<ViewMapMarkerPopup
|
||||
marker={selectedMarker}
|
||||
onClose={() => setSelectedMarkerId(null)}
|
||||
onEdit={() => setEditingMarkerId(selectedMarker.id)}
|
||||
/>
|
||||
)}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation()
|
||||
|
||||
{selectedMarker && editingMarkerId === selectedMarker.id && (
|
||||
<MapMarkerFormPopup
|
||||
longitude={selectedMarker.longitude}
|
||||
latitude={selectedMarker.latitude}
|
||||
initialMarker={selectedMarker}
|
||||
onDirtyChange={setHasUnsavedMarkerChanges}
|
||||
onSave={async ({ id, name, notes, color }) => {
|
||||
if (!id) return
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
await updateMarker(id, {
|
||||
name,
|
||||
notes: notes || null,
|
||||
color,
|
||||
})
|
||||
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
setTargetIndicator(null)
|
||||
}}
|
||||
>
|
||||
<MarkerPin
|
||||
color={PIN_COLORS.find((color) => color.id === marker.color)?.hex}
|
||||
active={marker.id === selectedMarkerId}
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
onCancel={() => setEditingMarkerId(null)}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
{placingMarker && (
|
||||
<MapMarkerFormPopup
|
||||
longitude={placingMarker.lng}
|
||||
latitude={placingMarker.lat}
|
||||
onDirtyChange={setHasUnsavedMarkerChanges}
|
||||
onSave={async ({ name, notes, color }) => {
|
||||
await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined)
|
||||
setPlacingMarker(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
setTargetIndicator(null)
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
<MarkerPanel
|
||||
markers={markers}
|
||||
onDelete={handleDeleteMarker}
|
||||
onFlyTo={handleFlyTo}
|
||||
onSelect={setSelectedMarkerId}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
/>
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
setTargetIndicator(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMarker && editingMarkerId !== selectedMarker.id && (
|
||||
<ViewMapMarkerPopup
|
||||
marker={selectedMarker}
|
||||
onClose={() => setSelectedMarkerId(null)}
|
||||
onEdit={() => setEditingMarkerId(selectedMarker.id)}
|
||||
onMouseEnter={hideCoordinates}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMarker && editingMarkerId === selectedMarker.id && (
|
||||
<MapMarkerFormPopup
|
||||
longitude={selectedMarker.longitude}
|
||||
latitude={selectedMarker.latitude}
|
||||
initialMarker={selectedMarker}
|
||||
onDirtyChange={setHasUnsavedMarkerChanges}
|
||||
onMouseEnter={hideCoordinates}
|
||||
onSave={async ({ id, name, notes, color }) => {
|
||||
if (!id) return
|
||||
|
||||
await updateMarker(id, {
|
||||
name,
|
||||
notes: notes || null,
|
||||
color,
|
||||
})
|
||||
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!confirmDiscardMarkerChanges()) return
|
||||
|
||||
setEditingMarkerId(null)
|
||||
setHasUnsavedMarkerChanges(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
</div>
|
||||
|
||||
<div onMouseEnter={hideCoordinates}>
|
||||
<MarkerPanel
|
||||
markers={markers}
|
||||
onDelete={handleDeleteMarker}
|
||||
onFlyTo={handleFlyTo}
|
||||
onSelect={setSelectedMarkerId}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
/>
|
||||
</div>
|
||||
</MapProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type MapMarkerFormPopupProps = {
|
|||
}) => Promise<void> | 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<HTMLInputElement | null>(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}
|
||||
>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Name this location"
|
||||
|
|
@ -130,7 +147,7 @@ export default function MapMarkerFormPopup({
|
|||
outlineOffset: '1px',
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: pinColor.hex }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{backgroundColor: pinColor.hex}}/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<MarkerIconProps>
|
||||
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 (
|
||||
<div className="cursor-pointer" style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))' }}>
|
||||
<IconMapPinFilled
|
||||
size={active ? 36 : 32}
|
||||
style={{ color }}
|
||||
/>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 36 46"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Pin body: circular head + precise pointed tip */}
|
||||
<path
|
||||
d="M18 45 C18 45 4 27.5 4 16.5 C4 7.4 10.3 1 18 1 C25.7 1 32 7.4 32 16.5 C32 27.5 18 45 18 45 Z"
|
||||
fill={color}
|
||||
stroke="rgba(0,0,0,0.25)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Inner icon circle */}
|
||||
<circle cx="18" cy="16.5" r="10.5" fill="rgba(255,255,255,0.18)" />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute flex items-center justify-center"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: active ? 17 : 15,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
}}
|
||||
>
|
||||
<Icon size={iconSize} color={iconColor} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 0 0 2px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<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={() => {
|
||||
if (scaleUnit !== 'metric') onChange('metric')
|
||||
}}
|
||||
className="border-0 px-2 py-1"
|
||||
style={{
|
||||
background: scaleUnit === 'metric' ? '#424420' : 'white',
|
||||
color: scaleUnit === 'metric' ? 'white' : '#666',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
|
|
@ -40,11 +34,10 @@ export default function ScaleUnitToggle({ scaleUnit, onChange }: ScaleUnitContro
|
|||
onClick={() => {
|
||||
if (scaleUnit !== 'imperial') onChange('imperial')
|
||||
}}
|
||||
className="border-0 px-2 py-1"
|
||||
style={{
|
||||
background: scaleUnit === 'imperial' ? '#424420' : 'white',
|
||||
color: scaleUnit === 'imperial' ? 'white' : '#666',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,59 +8,76 @@ type ViewMapMarkerPopupProps = {
|
|||
marker: MapMarker
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onMouseEnter?: () => void
|
||||
}
|
||||
|
||||
export default function ViewMapMarkerPopup({
|
||||
marker,
|
||||
onClose,
|
||||
onEdit,
|
||||
onMouseEnter,
|
||||
}: ViewMapMarkerPopupProps) {
|
||||
return (
|
||||
<div className="max-w-[260px]">
|
||||
<Popup
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
offset={[0, -36] as [number, number]}
|
||||
onClose={onClose}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="text-sm font-medium">{marker.name}</div>
|
||||
<Popup
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
offset={[0, -36] as [number, number]}
|
||||
onClose={onClose}
|
||||
closeOnClick={false}
|
||||
closeButton={false}
|
||||
>
|
||||
<div
|
||||
className="max-w-[260px]"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-sm font-medium break-words">{marker.name}</div>
|
||||
|
||||
{marker.notes && (
|
||||
<div className="mt-1 max-w-[240px] break-all whitespace-pre-wrap text-xs text-gray-500">
|
||||
{/* react-markdown is intentionally used without rehypeRaw.
|
||||
Do not enable raw HTML rendering unless notes are sanitized first. */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({href, children}) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{marker.notes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530]"
|
||||
{marker.notes && (
|
||||
<div className="mt-1 max-w-[240px] break-all whitespace-pre-wrap text-xs text-gray-500">
|
||||
{/* react-markdown is intentionally used without rehypeRaw.
|
||||
Do not enable raw HTML rendering unless notes are sanitized first. */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{marker.notes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-16 rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530] border-none outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="w-16 rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530] border-none outline-none"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,57 @@
|
|||
import { useState } from 'react'
|
||||
import MapsLayout from '~/layouts/MapsLayout'
|
||||
import { Head, Link, router } from '@inertiajs/react'
|
||||
import MapComponent from '~/components/maps/MapComponent'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import { IconArrowLeft } from '@tabler/icons-react'
|
||||
import { IconArrowLeft, IconMapPin, IconPlaneTilt } from '@tabler/icons-react'
|
||||
import { FileEntry } from '../../types/files'
|
||||
import Alert from '~/components/Alert'
|
||||
import { IconCrosshair } from '@tabler/icons-react'
|
||||
|
||||
type MapCommand = {
|
||||
id: number
|
||||
lat: number
|
||||
lng: number
|
||||
action: 'fly' | 'marker'
|
||||
}
|
||||
|
||||
export default function Maps(props: {
|
||||
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
|
||||
}) {
|
||||
const [coordinateSearch, setCoordinateSearch] = useState('')
|
||||
const [mapCommand, setMapCommand] = useState<MapCommand | null>(null)
|
||||
const [showCoordinatesEnabled, setShowCoordinatesEnabled] = useState(true)
|
||||
|
||||
const parseCoordinates = () => {
|
||||
const [latRaw, lngRaw] = coordinateSearch.split(',').map((value) => value.trim())
|
||||
const lat = Number(latRaw)
|
||||
const lng = Number(lngRaw)
|
||||
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lng) ||
|
||||
lat < -90 ||
|
||||
lat > 90 ||
|
||||
lng < -180 ||
|
||||
lng > 180
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
const handleCoordinateAction = (action: 'fly' | 'marker') => {
|
||||
const coordinates = parseCoordinates()
|
||||
if (!coordinates) return
|
||||
|
||||
setMapCommand({
|
||||
id: Date.now(),
|
||||
...coordinates,
|
||||
action,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -19,18 +62,63 @@ export default function Maps(props: {
|
|||
<MapsLayout>
|
||||
<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 items-center justify-between gap-4 p-4 bg-surface-secondary backdrop-blur-sm shadow-sm">
|
||||
<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-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="lat,lng"
|
||||
value={coordinateSearch}
|
||||
onChange={(event) => setCoordinateSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') handleCoordinateAction('fly')
|
||||
}}
|
||||
className="w-52 rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCoordinateAction('fly')}
|
||||
className="rounded border border-border-default bg-surface-primary p-2 text-text-secondary hover:bg-surface-secondary"
|
||||
title="Fly to coordinates"
|
||||
>
|
||||
<IconPlaneTilt size={18}/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCoordinateAction('marker')}
|
||||
className="rounded border border-border-default bg-surface-primary p-2 text-text-secondary hover:bg-surface-secondary"
|
||||
title="Add marker at coordinates"
|
||||
>
|
||||
<IconMapPin size={18}/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCoordinatesEnabled((prev) => !prev)}
|
||||
className={`rounded border border-border-default p-2 transition-colors ${
|
||||
showCoordinatesEnabled
|
||||
? 'bg-desert-green text-white'
|
||||
: 'bg-surface-primary text-text-secondary hover:bg-surface-secondary'
|
||||
}`}
|
||||
title={showCoordinatesEnabled ? 'Hide coordinates' : 'Show coordinates'}
|
||||
>
|
||||
<IconCrosshair size={18}/>
|
||||
</button>
|
||||
|
||||
<Link href="/settings/maps" className="mr-4">
|
||||
<StyledButton variant="primary" icon="IconSettings">
|
||||
Manage Map Regions
|
||||
</StyledButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alertMessage && (
|
||||
<div className="absolute top-20 left-4 right-4 z-50">
|
||||
<Alert
|
||||
|
|
@ -47,8 +135,12 @@ export default function Maps(props: {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<MapComponent />
|
||||
<MapComponent
|
||||
mapCommand={mapCommand}
|
||||
showCoordinatesEnabled={showCoordinatesEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MapsLayout>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user