merged in map-marker-point-inaccurate and map-url-params

This commit is contained in:
Kenneth Brewer 2026-05-06 01:01:35 -04:00
commit eca3184927
5 changed files with 626 additions and 214 deletions

View File

@ -1,49 +1,115 @@
import Map, {
FullscreenControl,
NavigationControl,
ScaleControl,
Marker,
MapProvider,
NavigationControl,
ScaleControl,
} from 'react-map-gl/maplibre'
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
import type { MapLayerMouseEvent, MapRef } 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 { useToast } from '~/hooks/useToast'
import { useMapMarkers } from '~/hooks/useMapMarkers'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
import CoordinateOverlay from './CoordinateOverlay'
import ViewMapMarkerPopup from './ViewMapMarkerPopup'
import MapMarkerFormPopup from './MapMarkerFormPopup'
import ScaleUnitToggle from './ScaleUnitToggle'
import ToastContainer from '~/components/ToastContainer'
type ScaleUnit = 'imperial' | 'metric'
type MapComponentProps = {
isHoveringUI: boolean
showCoordinatesEnabled: boolean
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,
}
}
const isValidMarkerCoordinate = (marker: { longitude: number; latitude: number }) =>
Number.isFinite(marker.longitude) &&
Number.isFinite(marker.latitude) &&
marker.longitude >= -180 &&
marker.longitude <= 180 &&
marker.latitude >= -90 &&
marker.latitude <= 90
export default function MapComponent({
isHoveringUI,
showCoordinatesEnabled,
}: MapComponentProps) {
mapCommand,
isHoveringUI = false,
showCoordinatesEnabled = true,
}: MapComponentProps) {
const mapRef = useRef<MapRef>(null)
const animationFrameRef = useRef<number | null>(null)
const handledMapCommandIdRef = useRef<number | null>(null)
const { toasts, showToast } = useToast()
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 hideCoordinates = useCallback(() => {
setShowCoordinates(false)
setCursorLngLat(null)
}, [])
const [showCoordinates, setShowCoordinates] = useState(false)
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
@ -56,175 +122,389 @@ export default function MapComponent({
y: number
} | null>(null)
const [showCoordinates, setShowCoordinates] = useState(false)
const hideCoordinates = useCallback(() => {
setShowCoordinates(false)
setCursorLngLat(null)
}, [])
const copyCoordinatesToClipboard = useCallback(
async (lat: number, lng: number) => {
const coordinates = `${lat.toFixed(6)},${lng.toFixed(6)}`
try {
await navigator.clipboard.writeText(coordinates)
showToast(`Copied: ${coordinates}`)
} catch {
window.prompt('Copy coordinates:', coordinates)
showToast('Clipboard blocked — copy manually')
}
},
[showToast]
)
const confirmDiscardMarkerChanges = useCallback(() => {
if (!hasUnsavedMarkerChanges) return true
return window.confirm('Discard unsaved marker changes?')
}, [hasUnsavedMarkerChanges])
// Add the PMTiles protocol to maplibre-gl
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)
return () => {
maplibregl.removeProtocol('pmtiles')
}
}, [])
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])
useEffect(() => {
if (!selectedMarkerId) return
const marker = markers.find((existingMarker) => existingMarker.id === selectedMarkerId)
if (!marker || marker.visible === false) {
setSelectedMarkerId(null)
setEditingMarkerId(null)
}
}, [markers, selectedMarkerId])
const handleScaleUnitChange = useCallback((unit: ScaleUnit) => {
setScaleUnit(unit)
localStorage.setItem('nomad:map-scale-unit', unit)
}, [])
const handleMapLoad = useCallback(() => {
flyToLocationParams()
}, [flyToLocationParams])
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
async (e: MapLayerMouseEvent) => {
if (e.originalEvent.shiftKey) {
await copyCoordinatesToClipboard(e.lngLat.lat, e.lngLat.lng)
return
}
if (!confirmDiscardMarkerChanges()) return
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setSelectedMarkerId(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
},
[confirmDiscardMarkerChanges]
[confirmDiscardMarkerChanges, copyCoordinatesToClipboard]
)
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 && isValidMarkerCoordinate(marker))
: 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
.filter((marker) => marker.visible)
.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
{showCoordinates && cursorLngLat && (
<CoordinateOverlay
latitude={cursorLngLat.lat}
longitude={cursorLngLat.lng}
x={cursorLngLat.x}
y={cursorLngLat.y}
/>
)}
{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>
)}
<ScaleUnitToggle
scaleUnit={scaleUnit}
onChange={handleScaleUnitChange}
onMouseEnter={hideCoordinates}
/>
{markers
.filter((marker) => marker.visible && isValidMarkerCoordinate(marker))
.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={async (e) => {
e.originalEvent.stopPropagation()
if (e.originalEvent.shiftKey) {
await copyCoordinatesToClipboard(marker.latitude, marker.longitude)
return
}
if (!confirmDiscardMarkerChanges()) return
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
>
<MarkerPin
color={marker.color}
customColor={marker.customColor}
icon={marker.icon}
iconColor={marker.iconColor}
visible={marker.visible}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{placingMarker && (
<MapMarkerFormPopup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
onDirtyChange={setHasUnsavedMarkerChanges}
onMouseEnter={hideCoordinates}
onSave={async ({ name, notes, color, customColor, icon }) => {
await addMarker({
name,
longitude: placingMarker.lng,
latitude: placingMarker.lat,
color,
customColor,
icon,
notes: notes || null,
})
setPlacingMarker(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
>
<MarkerPin
color={marker.color}
customColor={marker.customColor}
icon={marker.icon}
iconColor={marker.iconColor}
visible={marker.visible}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
/>
)}
{placingMarker && (
<MapMarkerFormPopup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
onDirtyChange={setHasUnsavedMarkerChanges}
onSave={async ({ name, notes, color, customColor, icon }) => {
await addMarker({
name,
longitude: placingMarker.lng,
latitude: placingMarker.lat,
color,
customColor,
icon,
notes: notes || null,
})
{selectedMarker && editingMarkerId !== selectedMarker.id && (
<ViewMapMarkerPopup
marker={selectedMarker}
onClose={() => setSelectedMarkerId(null)}
onEdit={() => setEditingMarkerId(selectedMarker.id)}
onMouseEnter={hideCoordinates}
/>
)}
setPlacingMarker(null)
setHasUnsavedMarkerChanges(false)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
{selectedMarker && editingMarkerId === selectedMarker.id && (
<MapMarkerFormPopup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
initialMarker={selectedMarker}
onDirtyChange={setHasUnsavedMarkerChanges}
onMouseEnter={hideCoordinates}
onSave={async ({ id, name, notes, color, customColor, icon }) => {
if (!id) return
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
/>
)}
await updateMarker(id, {
name,
notes: notes || null,
color,
customColor,
icon,
})
{selectedMarker && editingMarkerId !== selectedMarker.id && (
<ViewMapMarkerPopup
marker={selectedMarker}
onClose={() => setSelectedMarkerId(null)}
onEdit={() => setEditingMarkerId(selectedMarker.id)}
/>
)}
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
{selectedMarker && editingMarkerId === selectedMarker.id && (
<MapMarkerFormPopup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
initialMarker={selectedMarker}
onDirtyChange={setHasUnsavedMarkerChanges}
onSave={async ({ id, name, notes, color, customColor, icon }) => {
if (!id) return
await updateMarker(id, {
name,
notes: notes || null,
color,
customColor,
icon,
})
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
onCancel={() => setEditingMarkerId(null)}
/>
)}
</Map>
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
/>
)}
</Map>
</div>
<div onMouseEnter={hideCoordinates}>
<MarkerPanel
@ -233,8 +513,11 @@ export default function MapComponent({
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
onToggleVisibility={(id, visible) => updateMarker(id, { visible })}
/>
</div>
<ToastContainer toasts={toasts} />
</MapProvider>
)
}

View File

@ -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>

View File

@ -1,46 +1,49 @@
type ScaleUnit = 'imperial' | 'metric'
type ScaleUnitToggleProps = {
scaleUnit: ScaleUnit
onChange: (unit: ScaleUnit) => void
onMouseEnter?: () => void
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>
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={() => {
if (scaleUnit !== 'metric') onChange('metric')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'metric' ? '#424420' : 'white',
color: scaleUnit === 'metric' ? 'white' : '#666',
cursor: 'pointer',
}}
>
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>
)
<button
type="button"
onClick={() => {
if (scaleUnit !== 'imperial') onChange('imperial')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'imperial' ? '#424420' : 'white',
color: scaleUnit === 'imperial' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Imperial
</button>
</div>
</div>
)
}

View File

@ -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>
)
}

View File

@ -1,13 +1,19 @@
import { useState } from '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 StyledButton from '~/components/StyledButton'
import { IconArrowLeft, IconCrosshair, IconMapPin, IconPlaneTilt } from '@tabler/icons-react'
import { FileEntry } from '../../types/files'
import Alert from '~/components/Alert'
import { FileEntry } from '../../types/files'
type MapCommand = {
id: number
lat: number
lng: number
action: 'fly' | 'marker'
}
export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
@ -15,6 +21,40 @@ export default function Maps(props: {
const [isHoveringUI, setIsHoveringUI] = useState(false)
const [showMapCoordinates, setShowMapCoordinates] = useState(true)
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
@ -52,6 +92,56 @@ export default function Maps(props: {
</StyledButton>
</Link>
</div>
<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>
{/* Alert */}
@ -77,10 +167,12 @@ export default function Maps(props: {
)}
{/* Map */}
<div className="absolute inset-0">
<MapComponent
mapCommand={mapCommand}
isHoveringUI={isHoveringUI}
showCoordinatesEnabled={showMapCoordinates}
showCoordinatesEnabled={showCoordinatesEnabled}
/>
</div>
</div>