project-nomad/admin/inertia/components/maps/MapComponent.tsx

197 lines
6.2 KiB
TypeScript

import Map, {
FullscreenControl,
NavigationControl,
ScaleControl,
Marker,
MapProvider,
} from 'react-map-gl/maplibre'
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 { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
import ViewMapMarkerPopup from './ViewMapMarkerPopup'
import MapMarkerFormPopup from './MapMarkerFormPopup'
import ScaleUnitToggle from './ScaleUnitToggle'
type ScaleUnit = 'imperial' | 'metric'
export default function MapComponent() {
const mapRef = useRef<MapRef>(null)
const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers()
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 [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
)
const confirmDiscardMarkerChanges = useCallback(() => {
if (!hasUnsavedMarkerChanges) return true
return window.confirm('Discard unsaved marker changes?')
}, [hasUnsavedMarkerChanges])
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
return () => {
maplibregl.removeProtocol('pmtiles')
}
}, [])
const handleScaleUnitChange = useCallback((unit: ScaleUnit) => {
setScaleUnit(unit)
localStorage.setItem('nomad:map-scale-unit', unit)
}, [])
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
if (!confirmDiscardMarkerChanges()) return
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setSelectedMarkerId(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
},
[confirmDiscardMarkerChanges]
)
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
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)
}
deleteMarker(id)
},
[selectedMarkerId, editingMarkerId, deleteMarker]
)
const selectedMarker = selectedMarkerId ? markers.find((marker) => marker.id === selectedMarkerId) : null
return (
<MapProvider>
<Map
ref={mapRef}
reuseMaps
style={{
width: '100%',
height: '100vh',
}}
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
mapLib={maplibregl}
initialViewState={{
longitude: -101,
latitude: 40,
zoom: 3.5,
}}
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} />
{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}
/>
</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
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
/>
)}
{selectedMarker && editingMarkerId !== selectedMarker.id && (
<ViewMapMarkerPopup
marker={selectedMarker}
onClose={() => setSelectedMarkerId(null)}
onEdit={() => setEditingMarkerId(selectedMarker.id)}
/>
)}
{selectedMarker && editingMarkerId === selectedMarker.id && (
<MapMarkerFormPopup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
initialMarker={selectedMarker}
onDirtyChange={setHasUnsavedMarkerChanges}
onSave={async ({ id, name, notes, color }) => {
if (!id) return
await updateMarker(id, {
name,
notes: notes || null,
color,
})
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
onCancel={() => setEditingMarkerId(null)}
/>
)}
</Map>
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</MapProvider>
)
}