import { useEffect, useRef, useState } from 'react' import maplibregl from 'maplibre-gl' import type { ExpressionSpecification } from '@maplibre/maplibre-gl-style-spec' import 'maplibre-gl/dist/maplibre-gl.css' import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus' import { featureCollectionBounds } from '../lib/geojsonBounds' import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types' import { basemapGround, basemapPaint, huidigeWerkstand, mapSymbology } from './map/mapSymbology' 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: [ { // Grondtoon onder de tegels. Zonder deze laag flitst er wit tussen // tegels die nog niet geladen zijn. id: 'basemap-ground', type: 'background', paint: { 'background-color': basemapGround(huidigeWerkstand()) }, }, { id: 'osm-standard', type: 'raster', source: 'osm-standard', // De tegel wordt ontkleurd en gedempt tot een operationele ondergrond, // zodat alleen de eigen data nog kleur draagt. Geen andere tegelbron en // geen sleutel nodig; wie een echte vectorstijl heeft zet die via // VITE_MAP_STYLE_URL en omzeilt dit blok volledig. Zie basemapPaint voor // het verschil tussen de twee werkstanden. paint: basemapPaint(huidigeWerkstand()), }, ], } function datasetFillColor(fallbackColor: string): ExpressionSpecification { return [ 'case', ['==', ['get', 'layer_type'], 'municipality_boundary'], mapSymbology.boundary, [ 'match', ['get', 'change_type'], 'added', mapSymbology.added, 'removed', mapSymbology.removed, 'modified', mapSymbology.modified, 'unchanged', mapSymbology.unchanged, fallbackColor, ], ] } function datasetLineColor(fallbackColor: string): ExpressionSpecification { return [ 'case', ['==', ['get', 'layer_type'], 'municipality_boundary'], mapSymbology.boundaryStrong, [ 'match', ['get', 'change_type'], 'added', mapSymbology.added, 'removed', mapSymbology.removed, 'modified', mapSymbology.modified, 'unchanged', mapSymbology.unchanged, 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 = mapSymbology.dataFill, dataLineColor = mapSymbology.dataLine, 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(null) const mapRef = useRef(null) const onFeatureSelectRef = useRef(onFeatureSelect) const onMapCoordinateSelectRef = useRef(onMapCoordinateSelect) const onMapBboxPreviewRef = useRef(onMapBboxPreview) const onMapBboxSelectRef = useRef(onMapBboxSelect) const onViewportChangeRef = useRef(onViewportChange) const bboxSelectionModeRef = useRef(bboxSelectionMode) const areaDataRef = useRef(areaData) const dataRef = useRef(data) const fitDataOnChangeRef = useRef(fitDataOnChange) const imageOverlayIdsRef = useRef([]) // Onthoudt op welk kader al is ingezoomd. De fit-effecten draaien ook wanneer // alleen `data` verandert; zonder deze bewaking sprong de kaart na elke // analyse terug naar het volledige werkgebied en verloor de gebruiker zijn // ingezoomde beeld direct na het tekenen van een selectie. const lastFittedBoundsRef = useRef(null) // Zoomt alleen wanneer het kader echt anders is dan waarop we al pasten. // Herhaalde aanroepen met dezelfde grenzen laten het beeld met rust. const fitBoundsIfChanged = ( map: maplibregl.Map, bounds: maplibregl.LngLatBoundsLike | null, ) => { if (!bounds) { return } const key = JSON.stringify(bounds) if (lastFittedBoundsRef.current === key) { return } lastFittedBoundsRef.current = key map.fitBounds(bounds, { padding: 40, duration: 0 }) } 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]) // De kaart wordt eenmalig opgebouwd, dus bij het wisselen van werkstand moet // alleen de verf van de ondergrond mee. setPaintProperty laat alle datalagen // ongemoeid; een volledige setStyle zou ze opnieuw moeten opbouwen. useEffect(() => { const pasAan = () => { const map = mapRef.current if (!map || !map.isStyleLoaded()) return const werkstand = huidigeWerkstand() if (!map.getLayer('osm-standard')) return for (const [naam, waarde] of Object.entries(basemapPaint(werkstand))) { map.setPaintProperty('osm-standard', naam as never, waarde as never) } if (map.getLayer('basemap-ground')) { map.setPaintProperty('basemap-ground', 'background-color', basemapGround(werkstand)) } } const waarnemer = new MutationObserver(pasAan) waarnemer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] }) return () => waarnemer.disconnect() }, []) useEffect(() => { if (!containerRef.current || mapRef.current) { return } const map = new maplibregl.Map({ container: containerRef.current, style: defaultMapStyle(), center: NATIONAL_MAP_CENTER, zoom: NATIONAL_MAP_ZOOM, attributionControl: false, }) const resizeObserver = new ResizeObserver(() => { map.resize() const fitCollection = areaDataRef.current ?? (fitDataOnChangeRef.current ? dataRef.current : null) // Bij het aanpassen van de venstergrootte het beeld behouden in plaats // van terugspringen naar het volledige werkgebied. fitBoundsIfChanged(map, fitCollection ? collectCoordinates(fitCollection) : null) }) 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) { 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) { 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) { fitBoundsIfChanged(map, collectCoordinates(collection)) } } }, [areaData, data, dataFillColor, dataLineColor, fitDataOnChange, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady) { 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) { 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': mapSymbology.boundary, 'fill-opacity': 0.18, }, }, map.getLayer('dataset-fill') ? 'dataset-fill' : undefined, ) map.addLayer( { id: 'area-line', type: 'line', source: 'area', paint: { 'line-color': mapSymbology.boundary, 'line-width': 3, 'line-dasharray': [2, 1], }, }, map.getLayer('dataset-line') ? 'dataset-line' : undefined, ) } // Dit effect draait ook wanneer alleen `data` verandert, bijvoorbeeld zodra // een analyse resultaten oplevert. Ongewaakt zoomde de kaart dan terug naar // het volledige werkgebied, meteen nadat de gebruiker een rechthoek had // getekend. De bewaking laat het beeld staan zolang het gebied gelijk blijft. const activeCollection = areaData ?? (fitDataOnChange ? data : null) if (activeCollection) { fitBoundsIfChanged(map, collectCoordinates(activeCollection)) } }, [areaData, data, fitDataOnChange, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady) { 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) { 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) { 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': mapSymbology.selectionFill, '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': mapSymbology.selectionLine, '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': mapSymbology.selectionFill, 'circle-radius': 7, 'circle-stroke-color': mapSymbology.selectionLine, 'circle-stroke-width': 2, }, }) }, [selectedFeature, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady) { 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': mapSymbology.waterFill, 'fill-opacity': 0.12, }, }) map.addLayer({ id: 'selection-bbox-line', type: 'line', source: 'selection-bbox', paint: { 'line-color': mapSymbology.waterLine, 'line-width': 2, 'line-dasharray': [2, 1], }, }) } }, [selectionBbox, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady) { 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': mapSymbology.detectionFill, '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': mapSymbology.detectionLine, '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': mapSymbology.detectionFill, 'circle-radius': 6, 'circle-stroke-color': mapSymbology.pointStroke, 'circle-stroke-width': 2, }, }) }, [selectionData, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady) { 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', mapSymbology.unchanged, 'match_reference', mapSymbology.boundary, 'false_positive', mapSymbology.removed, 'false_negative', mapSymbology.modified, mapSymbology.fallback, ] as 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': mapSymbology.pointStroke, 'circle-stroke-width': 2, }, }) }, [qaEvidenceData, mapStyleReady]) return (
) } // Geen React.memo hier. Het is geprobeerd en het scheelde niets: van de // negentien props worden er te veel per render opnieuw gemaakt, dus de // vergelijking slaat nooit over. Zinvol wordt dat pas wanneer die props // gestabiliseerd zijn; tot die tijd is het schijnzekerheid. export default GeoMap