De inlogpagina haalde 1,46 MB over de lijn, waaronder de volledige kaartmotor van 213 kB terwijl er geen kaart staat. De werkbank is nu een aparte brok die pas na aanmelden geladen wordt: de eerste JS-bundel gaat van 476 naar 30 kB, en met de beeldwerk uit een eerdere commit komt de inlogpagina op 344 kB. Bij het opstarten gingen 27 verzoeken de deur uit in drie golven. Alles werd vooraf opgehaald, ook voor werkbladen die de gebruiker misschien nooit opent, en twee daarvan werden dubbel opgevraagd door effecten die negentig milliseconde na elkaar liepen. Gelijktijdige en vlak opeenvolgende identieke GET-verzoeken delen nu één belofte, en elk werkblad haalt zijn eigen gegevens pas op wanneer het geopend wordt. Nu zestien verzoeken. Typen in het themazoekveld kostte 53 ms mediaan met uitschieters tot 127 ms, omdat die filterwaarde in het viewmodel van de hele werkruimte leefde. Het veld is een eigen component geworden dat zijn invoer zelf bijhoudt. React.memo om de kaart is geprobeerd en weer verwijderd: van de negentien props worden er te veel per render opnieuw gemaakt, dus de vergelijking sloeg nooit over. Verder: donker blijft de standaard maar er is nu een weg terug naar licht, de kaartondergrond schakelt mee zonder de datalagen te herbouwen, de onderste hero-afbeelding wordt pas opgehaald als hij in beeld komt, en de werkbank heeft een h1 zodat schermlezers een ankerpunt hebben. De tests op de bootstrap legden het oude gedrag vast en toetsen nu het nieuwe, van drie naar zeven gevallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
805 lines
25 KiB
TypeScript
805 lines
25 KiB
TypeScript
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<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[]>([])
|
|
// 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<string | null>(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 (
|
|
<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."
|
|
/>
|
|
)
|
|
}
|
|
|
|
// 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
|