Files
geointel/frontend/src/components/GeoMap.tsx
T
Codex 0bf6068817
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
Keep selected area as map viewport
2026-07-19 02:26:54 +02:00

746 lines
22 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
dataFillColor?: string
dataLineColor?: string
areaData?: GeoJSON.FeatureCollection | null
selectedFeature?: GeoJSON.Feature | null
selectionData?: GeoJSON.FeatureCollection | null
qaEvidenceData?: GeoJSON.FeatureCollection | null
imageOverlays?: MapImageOverlay[]
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
bboxSelectionMode?: boolean
visible?: boolean
opacity?: number
areaVisible?: boolean
areaOpacity?: number
fitDataOnChange?: boolean
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
onMapCoordinateSelect?: (coordinate: [number, number]) => void
onMapBboxPreview?: (bbox: VectorSelectionBBox) => void
onMapBboxSelect?: (bbox: VectorSelectionBBox) => void
onViewportChange?: (viewport: MapViewportState) => void
}
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
}
const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
version: 8,
sources: {
'osm-standard': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
maxzoom: 19,
},
},
layers: [
{
id: 'osm-standard',
type: 'raster',
source: 'osm-standard',
},
],
}
function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f766e',
[
'match',
['get', 'change_type'],
'added',
'#16a34a',
'removed',
'#dc2626',
'modified',
'#d97706',
'unchanged',
'#2563eb',
fallbackColor,
],
]
}
function datasetLineColor(fallbackColor: string): maplibregl.ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f5f59',
[
'match',
['get', 'change_type'],
'added',
'#15803d',
'removed',
'#b91c1c',
'modified',
'#b45309',
'unchanged',
'#1d4ed8',
fallbackColor,
],
]
}
function defaultMapStyle(): string | maplibregl.StyleSpecification {
return import.meta.env.VITE_MAP_STYLE_URL || DEFAULT_ROAD_BASEMAP_STYLE
}
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
const bounds = featureCollectionBounds(featureCollection)
if (!bounds) {
return null
}
return [
[bounds.minX, bounds.minY],
[bounds.maxX, bounds.maxY],
]
}
function bboxToFeatureCollection(
bbox: { min_x: number; min_y: number; max_x: number; max_y: number } | null | undefined,
): GeoJSON.FeatureCollection {
if (!bbox) {
return EMPTY_FEATURE_COLLECTION
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[bbox.min_x, bbox.min_y],
[bbox.max_x, bbox.min_y],
[bbox.max_x, bbox.max_y],
[bbox.min_x, bbox.max_y],
[bbox.min_x, bbox.min_y],
],
],
},
properties: {
layer_type: 'selection_bbox',
},
},
],
}
}
function GeoMap({
data,
dataFillColor = '#f97316',
dataLineColor = '#ea580c',
areaData = null,
selectedFeature = null,
selectionData = null,
qaEvidenceData = null,
imageOverlays = [],
selectionBbox = null,
bboxSelectionMode = false,
visible = true,
opacity = 0.4,
areaVisible = true,
areaOpacity = 0.18,
fitDataOnChange = true,
onFeatureSelect,
onMapCoordinateSelect,
onMapBboxPreview,
onMapBboxSelect,
onViewportChange,
}: GeoMapProps): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
const onMapBboxPreviewRef = useRef<GeoMapProps['onMapBboxPreview']>(onMapBboxPreview)
const onMapBboxSelectRef = useRef<GeoMapProps['onMapBboxSelect']>(onMapBboxSelect)
const onViewportChangeRef = useRef<GeoMapProps['onViewportChange']>(onViewportChange)
const bboxSelectionModeRef = useRef(bboxSelectionMode)
const areaDataRef = useRef<GeoJSON.FeatureCollection | null>(areaData)
const dataRef = useRef<GeoJSON.FeatureCollection | null>(data)
const fitDataOnChangeRef = useRef(fitDataOnChange)
const imageOverlayIdsRef = useRef<string[]>([])
const [mapStyleReady, setMapStyleReady] = useState(false)
areaDataRef.current = areaData
dataRef.current = data
fitDataOnChangeRef.current = fitDataOnChange
useEffect(() => {
onFeatureSelectRef.current = onFeatureSelect
}, [onFeatureSelect])
useEffect(() => {
onMapCoordinateSelectRef.current = onMapCoordinateSelect
}, [onMapCoordinateSelect])
useEffect(() => {
onMapBboxPreviewRef.current = onMapBboxPreview
}, [onMapBboxPreview])
useEffect(() => {
onMapBboxSelectRef.current = onMapBboxSelect
}, [onMapBboxSelect])
useEffect(() => {
onViewportChangeRef.current = onViewportChange
}, [onViewportChange])
useEffect(() => {
bboxSelectionModeRef.current = bboxSelectionMode
if (mapRef.current) {
mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : ''
if (bboxSelectionMode) {
mapRef.current.dragPan.disable()
} else {
mapRef.current.dragPan.enable()
}
}
}, [bboxSelectionMode])
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return
}
const map = new maplibregl.Map({
container: containerRef.current,
style: defaultMapStyle(),
center: PRIMARY_FOCUS_CENTER,
zoom: 11,
attributionControl: false,
})
const resizeObserver = new ResizeObserver(() => {
map.resize()
const fitCollection = areaDataRef.current
?? (fitDataOnChangeRef.current ? dataRef.current : null)
const bounds = fitCollection ? collectCoordinates(fitCollection) : null
if (bounds && map.isStyleLoaded()) {
map.fitBounds(bounds, { padding: 40, duration: 0 })
}
})
resizeObserver.observe(containerRef.current)
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(
new maplibregl.AttributionControl({
compact: true,
}),
'bottom-right',
)
const emitViewport = () => {
const bounds = map.getBounds()
onViewportChangeRef.current?.({
bbox: {
min_x: bounds.getWest(),
min_y: bounds.getSouth(),
max_x: bounds.getEast(),
max_y: bounds.getNorth(),
crs: 'EPSG:4326',
},
zoom: map.getZoom(),
})
}
map.on('load', () => {
map.resize()
setMapStyleReady(true)
emitViewport()
})
map.on('moveend', emitViewport)
let dragStart: [number, number] | null = null
let draggedSelection = false
const bboxFromCoordinates = (start: [number, number], end: [number, number]): VectorSelectionBBox => ({
min_x: Math.min(start[0], end[0]),
min_y: Math.min(start[1], end[1]),
max_x: Math.max(start[0], end[0]),
max_y: Math.max(start[1], end[1]),
crs: 'EPSG:4326',
})
map.on('mousedown', (event) => {
if (!bboxSelectionModeRef.current || event.originalEvent.button !== 0) {
return
}
event.preventDefault()
dragStart = [event.lngLat.lng, event.lngLat.lat]
draggedSelection = false
map.getCanvas().style.cursor = 'crosshair'
})
map.on('mousemove', (event) => {
if (!bboxSelectionModeRef.current || !dragStart) {
return
}
draggedSelection = true
onMapBboxPreviewRef.current?.(bboxFromCoordinates(dragStart, [event.lngLat.lng, event.lngLat.lat]))
})
map.on('mouseup', (event) => {
if (!bboxSelectionModeRef.current || !dragStart) {
return
}
const start = dragStart
dragStart = null
const bbox = bboxFromCoordinates(start, [event.lngLat.lng, event.lngLat.lat])
if (bbox.max_x > bbox.min_x && bbox.max_y > bbox.min_y) {
onMapBboxSelectRef.current?.(bbox)
}
})
map.on('click', (event) => {
if (bboxSelectionModeRef.current) {
if (draggedSelection) {
draggedSelection = false
return
}
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
return
}
const layers = [
'qa-evidence-fill',
'qa-evidence-line',
'qa-evidence-circle',
'selection-result-fill',
'selection-result-line',
'selection-result-circle',
'dataset-fill',
'dataset-line',
'area-fill',
'area-line',
].filter((layerId) => map.getLayer(layerId))
if (layers.length === 0) {
onFeatureSelectRef.current?.(null)
return
}
const features = map.queryRenderedFeatures(event.point, {
layers,
})
if (features.length === 0) {
onFeatureSelectRef.current?.(null)
return
}
const feature = features[0] as unknown as GeoJSON.Feature
onFeatureSelectRef.current?.(feature)
})
mapRef.current = map
return () => {
resizeObserver.disconnect()
map.remove()
mapRef.current = null
setMapStyleReady(false)
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
for (const overlayId of [...imageOverlayIdsRef.current].reverse()) {
if (map.getLayer(overlayId)) {
map.removeLayer(overlayId)
}
if (map.getSource(overlayId)) {
map.removeSource(overlayId)
}
}
imageOverlayIdsRef.current = []
const beforeLayer = ['area-fill', 'dataset-fill', 'selection-bbox-fill'].find((layerId) => map.getLayer(layerId))
imageOverlays.forEach((imageOverlay, index) => {
const overlayId = `bounded-raster-${index}`
const [minX, minY, maxX, maxY] = imageOverlay.bbox
map.addSource(overlayId, {
type: 'image',
url: imageOverlay.url,
coordinates: [
[minX, maxY],
[maxX, maxY],
[maxX, minY],
[minX, minY],
],
})
map.addLayer({
id: overlayId,
type: 'raster',
source: overlayId,
paint: { 'raster-opacity': imageOverlay.opacity ?? 0.88 },
}, beforeLayer)
imageOverlayIdsRef.current.push(overlayId)
})
}, [imageOverlays, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
if (map.getSource('dataset')) {
if (data) {
;(map.getSource('dataset') as maplibregl.GeoJSONSource).setData(data)
} else {
if (map.getLayer('dataset-fill')) {
map.removeLayer('dataset-fill')
}
if (map.getLayer('dataset-line')) {
map.removeLayer('dataset-line')
}
map.removeSource('dataset')
return
}
} else if (data) {
map.addSource('dataset', { type: 'geojson', data })
map.addLayer({
id: 'dataset-fill',
type: 'fill',
source: 'dataset',
paint: {
'fill-color': datasetFillColor(dataFillColor),
'fill-opacity': 0.4,
},
})
map.addLayer({
id: 'dataset-line',
type: 'line',
source: 'dataset',
paint: {
'line-color': datasetLineColor(dataLineColor),
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.25, 12, 0.8, 16, 2],
},
})
}
if (data && fitDataOnChange && !areaData) {
const collection = data
if (collection.type === 'FeatureCollection' && collection.features.length > 0) {
const bounds = collectCoordinates(collection)
if (bounds) {
map.fitBounds(bounds, { padding: 40 })
}
}
}
}, [areaData, data, dataFillColor, dataLineColor, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
if (map.getLayer('dataset-fill')) {
map.setPaintProperty('dataset-fill', 'fill-color', datasetFillColor(dataFillColor))
}
if (map.getLayer('dataset-line')) {
map.setPaintProperty('dataset-line', 'line-color', datasetLineColor(dataLineColor))
}
}, [dataFillColor, dataLineColor, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
if (map.getSource('area')) {
if (areaData) {
;(map.getSource('area') as maplibregl.GeoJSONSource).setData(areaData)
} else {
if (map.getLayer('area-fill')) {
map.removeLayer('area-fill')
}
if (map.getLayer('area-line')) {
map.removeLayer('area-line')
}
map.removeSource('area')
return
}
} else if (areaData) {
map.addSource('area', { type: 'geojson', data: areaData })
map.addLayer(
{
id: 'area-fill',
type: 'fill',
source: 'area',
paint: {
'fill-color': '#0f766e',
'fill-opacity': 0.18,
},
},
map.getLayer('dataset-fill') ? 'dataset-fill' : undefined,
)
map.addLayer(
{
id: 'area-line',
type: 'line',
source: 'area',
paint: {
'line-color': '#0f766e',
'line-width': 3,
'line-dasharray': [2, 1],
},
},
map.getLayer('dataset-line') ? 'dataset-line' : undefined,
)
}
const activeCollection = areaData ?? (fitDataOnChange ? data : null)
if (activeCollection) {
const bounds = collectCoordinates(activeCollection)
if (bounds) {
map.fitBounds(bounds, { padding: 40 })
}
}
}, [areaData, data, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
const visibility = visible ? 'visible' : 'none'
if (map.getLayer('dataset-fill')) {
map.setLayoutProperty('dataset-fill', 'visibility', visibility)
map.setPaintProperty('dataset-fill', 'fill-opacity', opacity)
}
if (map.getLayer('dataset-line')) {
map.setLayoutProperty('dataset-line', 'visibility', visibility)
map.setPaintProperty('dataset-line', 'line-opacity', visible ? 1 : 0)
}
}, [visible, opacity, data, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
const visibility = areaVisible ? 'visible' : 'none'
if (map.getLayer('area-fill')) {
map.setLayoutProperty('area-fill', 'visibility', visibility)
map.setPaintProperty('area-fill', 'fill-opacity', areaOpacity)
}
if (map.getLayer('area-line')) {
map.setLayoutProperty('area-line', 'visibility', visibility)
map.setPaintProperty('area-line', 'line-opacity', areaVisible ? 1 : 0)
}
}, [areaVisible, areaOpacity, areaData, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
const selectedCollection: GeoJSON.FeatureCollection = selectedFeature
? { type: 'FeatureCollection', features: [selectedFeature] }
: EMPTY_FEATURE_COLLECTION
if (map.getSource('selected-feature')) {
;(map.getSource('selected-feature') as maplibregl.GeoJSONSource).setData(selectedCollection)
return
}
map.addSource('selected-feature', { type: 'geojson', data: selectedCollection })
map.addLayer({
id: 'selected-feature-fill',
type: 'fill',
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': '#fde047',
'fill-opacity': 0.32,
},
})
map.addLayer({
id: 'selected-feature-line',
type: 'line',
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': '#854d0e',
'line-width': 4,
},
})
map.addLayer({
id: 'selected-feature-circle',
type: 'circle',
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': '#fde047',
'circle-radius': 7,
'circle-stroke-color': '#854d0e',
'circle-stroke-width': 2,
},
})
}, [selectedFeature, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
const bboxCollection = bboxToFeatureCollection(selectionBbox)
if (map.getSource('selection-bbox')) {
;(map.getSource('selection-bbox') as maplibregl.GeoJSONSource).setData(bboxCollection)
} else {
map.addSource('selection-bbox', { type: 'geojson', data: bboxCollection })
map.addLayer({
id: 'selection-bbox-fill',
type: 'fill',
source: 'selection-bbox',
paint: {
'fill-color': '#38bdf8',
'fill-opacity': 0.12,
},
})
map.addLayer({
id: 'selection-bbox-line',
type: 'line',
source: 'selection-bbox',
paint: {
'line-color': '#0369a1',
'line-width': 2,
'line-dasharray': [2, 1],
},
})
}
}, [selectionBbox, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
const resultCollection = selectionData ?? EMPTY_FEATURE_COLLECTION
if (map.getSource('selection-result')) {
;(map.getSource('selection-result') as maplibregl.GeoJSONSource).setData(resultCollection)
return
}
map.addSource('selection-result', { type: 'geojson', data: resultCollection })
map.addLayer({
id: 'selection-result-fill',
type: 'fill',
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': '#7c3aed',
'fill-opacity': 0.24,
},
})
map.addLayer({
id: 'selection-result-line',
type: 'line',
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': '#5b21b6',
'line-width': 3,
},
})
map.addLayer({
id: 'selection-result-circle',
type: 'circle',
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': '#7c3aed',
'circle-radius': 6,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
},
})
}, [selectionData, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
const evidenceCollection = qaEvidenceData ?? EMPTY_FEATURE_COLLECTION
if (map.getSource('qa-evidence')) {
;(map.getSource('qa-evidence') as maplibregl.GeoJSONSource).setData(evidenceCollection)
return
}
map.addSource('qa-evidence', { type: 'geojson', data: evidenceCollection })
const evidenceColor = [
'match',
['get', 'qa_evidence_role'],
'match_candidate',
'#2563eb',
'match_reference',
'#0f766e',
'false_positive',
'#dc2626',
'false_negative',
'#d97706',
'#475569',
] as maplibregl.ExpressionSpecification
map.addLayer({
id: 'qa-evidence-fill',
type: 'fill',
source: 'qa-evidence',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': evidenceColor,
'fill-opacity': 0.28,
},
})
map.addLayer({
id: 'qa-evidence-line',
type: 'line',
source: 'qa-evidence',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': evidenceColor,
'line-width': [
'match',
['get', 'qa_evidence_role'],
'false_positive',
4,
'false_negative',
4,
3,
],
},
})
map.addLayer({
id: 'qa-evidence-circle',
type: 'circle',
source: 'qa-evidence',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': evidenceColor,
'circle-radius': 7,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
},
})
}, [qaEvidenceData, mapStyleReady])
return (
<div
className="map-container"
ref={containerRef}
role="region"
aria-label="Interactieve kaart. Gebruik de kaartbediening om te zoomen en de tekenknop om een gebied te selecteren."
/>
)
}
export default GeoMap