197 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|