/* Kaart screen: MapLibre + Centric toolbar + right layers panel */ const LAGEN_CONFIG = [ { id:'woonplaatsen', label:'Woonplaatsen', swatch:'#004494', mapLayers:['woonplaatsen-outline'], sourceId:'woonplaatsen', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('woonplaatsen-outline','line-opacity',op);}catch(e){} } }, { id:'panden', label:'Panden', swatch:'#3b82f6', mapLayers:['panden-fill','panden-outline'], sourceId:'panden', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('panden-fill','fill-opacity',op*0.65);}catch(e){} } }, { id:'percelen', label:'Percelen', swatch:'#f97316', mapLayers:['percelen-wmts','percelen-wfs','percelen-fill'], sourceId:'percelen-wfs', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('percelen-wmts','raster-opacity',op);}catch(e){} } }, { id:'zonnepanelen', label:'Zonnepanelen', swatch:'#eab308', mapLayers:['resultaten-zonnepanelen-fill','resultaten-zonnepanelen-line'], sourceId:'resultaten-zonnepanelen', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('resultaten-zonnepanelen-fill','fill-opacity',op*0.4);map.setPaintProperty('resultaten-zonnepanelen-line','line-opacity',op);}catch(e){} } }, { id:'dakkapellen', label:'Dakkapellen', swatch:'#8b5cf6', mapLayers:['resultaten-dakkapellen-fill','resultaten-dakkapellen-line'], sourceId:'resultaten-dakkapellen', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('resultaten-dakkapellen-fill','fill-opacity',op*0.4);map.setPaintProperty('resultaten-dakkapellen-line','line-opacity',op);}catch(e){} } }, { id:'contourafwijking', label:'Contourafwijking', swatch:'#ef4444', mapLayers:['resultaten-contourafwijking-fill','resultaten-contourafwijking-line'], sourceId:'resultaten-contourafwijking', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('resultaten-contourafwijking-fill','fill-opacity',op*0.4);map.setPaintProperty('resultaten-contourafwijking-line','line-opacity',op);}catch(e){} } }, { id:'perceel-objecten', label:'Perceel-objecten', swatch:'#06b6d4', mapLayers:['perceel-objecten-fill','perceel-objecten-line'], sourceId:'perceel-objecten', hasOpacity:true, paintFn:(map,op)=>{ try{map.setPaintProperty('perceel-objecten-fill','fill-opacity',op*0.35);map.setPaintProperty('perceel-objecten-line','line-opacity',op);}catch(e){} } }, ]; function boundsVanSource(map, sourceId) { const src = map.getSource(sourceId); if (!src) return null; const data = src._data; if (!data?.features?.length) return null; const bounds = new maplibregl.LngLatBounds(); data.features.forEach(f => { if (!f.geometry) return; const flat = (coords) => { if (!coords?.length) return; if (typeof coords[0] === 'number') { bounds.extend(coords); return; } coords.forEach(flat); }; flat(f.geometry.coordinates); }); return bounds.isEmpty() ? null : bounds; } function BboxKopieerKnop({ waarde }) { const [gekopieerd, setGekopieerd] = useState(false); function kopieer() { navigator.clipboard.writeText(waarde).then(() => { setGekopieerd(true); setTimeout(() => setGekopieerd(false), 1500); }); } return ( ); } function LaagRij({ laag, laagInstellingen, laagInstellingenRef, setLaagInstellingen, mapInstanceRef, laadPercelen, onDragStart, onDragOver, onDrop, isDragOver }) { const inst = laagInstellingen[laag.id]; const aan = inst.aan; const [expanded, setExpanded] = useState(false); function toggleLaag() { const nieuw = !aan; const volgende = {...laagInstellingenRef.current, [laag.id]: {...laagInstellingenRef.current[laag.id], aan: nieuw}}; laagInstellingenRef.current = volgende; setLaagInstellingen(volgende); const map = mapInstanceRef.current; if (!map) return; laag.mapLayers.forEach(lid => { try { map.setLayoutProperty(lid, 'visibility', nieuw ? 'visible' : 'none'); } catch(e) {} }); if (laag.id === 'percelen' && nieuw) laadPercelen(); } function stapOpacity(delta) { const huidig = Math.round(laagInstellingenRef.current[laag.id].opacity * 10) * 10; const nieuw = Math.min(100, Math.max(10, huidig + delta)) / 100; const volgende = {...laagInstellingenRef.current, [laag.id]: {...laagInstellingenRef.current[laag.id], opacity: nieuw}}; laagInstellingenRef.current = volgende; setLaagInstellingen(volgende); const map = mapInstanceRef.current; if (map && laag.paintFn) laag.paintFn(map, nieuw); } const opPct = Math.round((inst.opacity || 1) * 10) * 10; return (
{ e.dataTransfer.effectAllowed = 'move'; onDragStart(laag.id); }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; onDragOver(laag.id); }} onDrop={e => { e.preventDefault(); onDrop(laag.id); }} onDragEnd={() => onDrop(null)}>
{ e.stopPropagation(); setExpanded(v => !v); }}> {aan && } {laag.label} {laag.sourceId && ( )}
{expanded && laag.hasOpacity && (
Helderheid
{opPct}%
)}
); } function KopieerKnop({ tekst, label }) { const [gekopieerd, setGekopieerd] = useState(false); function kopieer() { navigator.clipboard.writeText(tekst) .then(() => { setGekopieerd(true); setTimeout(() => setGekopieerd(false), 1500); }) .catch(() => {}); } return ( {gekopieerd ? 'Gekopieerd!' : label} ); } /* Ray-casting point-in-polygon op schermcoördinaten. poly = [{x,y},...]. */ function puntInPolygoon(x, y, poly) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y; if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; } return inside; } /* Simpele centroid (gemiddelde van de buitenring) in lng/lat van een (Multi)Polygon. */ function geomCentroidLngLat(geom) { if (!geom) return null; let ring = null; if (geom.type === 'Polygon') ring = geom.coordinates[0]; else if (geom.type === 'MultiPolygon') ring = geom.coordinates[0] && geom.coordinates[0][0]; if (!ring || !ring.length) return null; let sx = 0, sy = 0; for (const c of ring) { sx += c[0]; sy += c[1]; } return [sx / ring.length, sy / ring.length]; } function SelectieOverlay({ mode, tekenPunten, startPunt, muisPunt, snapActief }) { if (mode === 'navigeren') return null; const blauw = '#004494'; const dashArray = '5,4'; if (mode === 'rechthoek' && startPunt && muisPunt) { const x = Math.min(startPunt.x, muisPunt.x); const y = Math.min(startPunt.y, muisPunt.y); const w = Math.abs(muisPunt.x - startPunt.x); const h = Math.abs(muisPunt.y - startPunt.y); return ( ); } if (mode === 'polygoon' && tekenPunten.length > 0) { const punten = [...tekenPunten, muisPunt].filter(Boolean); const polyline = punten.map((p,i) => `${i===0?'M':'L'}${p.x},${p.y}`).join(' '); return ( {tekenPunten.map((p,i) => ( ))} {snapActief && tekenPunten.length > 0 && ( )} ); } return null; } function SchermKaart({ gemeenten, kaartmateriaal, defaultGemeente, onNaarPand }) { const [geselecteerdeGemeente, setGeselecteerdeGemeente] = useState(() => defaultGemeente || ''); const [zoekterm, setZoekterm] = useState(''); const [suggesties, setSuggesties] = useState([]); const [detailPand, setDetailPand] = useState(null); const [lagenPanelOpen, setLagenPanelOpen] = useState(true); const [laagInstellingen, setLaagInstellingen] = useState({ woonplaatsen: { aan: true, opacity: 0.8 }, panden: { aan: true, opacity: 0.65 }, zonnepanelen: { aan: false, opacity: 1.0 }, dakkapellen: { aan: false, opacity: 1.0 }, contourafwijking: { aan: false, opacity: 1.0 }, 'perceel-objecten': { aan: false, opacity: 1.0 }, percelen: { aan: false, opacity: 0.8 }, }); const [achtergrondId, setAchtergrondId] = useState(null); const [lagenVolgorde, setLagenVolgorde] = useState(() => LAGEN_CONFIG.map(l => l.id)); const [dragBron, setDragBron] = useState(null); const [dragDoel, setDragDoel] = useState(null); const [geselecteerdPandId, setGeselecteerdPandId] = React.useState(null); const [nietAanwezigZichtbaar, setNietAanwezigZichtbaar] = React.useState(false); const [inactiefZichtbaar, setInactiefZichtbaar] = React.useState(false); const [bbox, setBbox] = useState(null); const [activeTool, setActiveTool] = useState('navigeren'); const [geselecteerdePanden, setGeselecteerdePanden] = useState([]); const [geselecteerdePercelen, setGeselecteerdePercelen] = useState([]); const [vanuitSelectie, setVanuitSelectie] = useState(false); const activeToolRef = useRef('navigeren'); useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]); const [tekenPunten, setTekenPunten] = useState([]); const [startPunt, setStartPunt] = useState(null); const [muisPunt, setMuisPunt] = useState(null); const [snapActief, setSnapActief] = useState(false); const tekenPuntenRef = useRef([]); const startPuntRef = useRef(null); useEffect(() => { tekenPuntenRef.current = tekenPunten; }, [tekenPunten]); useEffect(() => { startPuntRef.current = startPunt; }, [startPunt]); const [panelBreedte, setPanelBreedte] = useState(250); const mapRef = useRef(null); const mapInstanceRef = useRef(null); const gemRef = useRef(geselecteerdeGemeente); const laagInstellingenRef = useRef(laagInstellingen); function openPandDetailInData(pandId) { if (!pandId || !onNaarPand) return; onNaarPand(pandId); } useEffect(() => { gemRef.current = geselecteerdeGemeente; }, [geselecteerdeGemeente]); useEffect(() => { laagInstellingenRef.current = laagInstellingen; }, [laagInstellingen]); useEffect(() => { const map = mapInstanceRef.current; if (!map) return; if (activeTool === 'navigeren') { map.dragPan.enable(); map.getCanvas().style.cursor = ''; setSnapActief(false); setTekenPunten([]); setStartPunt(null); setMuisPunt(null); } else { map.dragPan.disable(); map.getCanvas().style.cursor = 'crosshair'; setSnapActief(false); setTekenPunten([]); setStartPunt(null); setMuisPunt(null); } }, [activeTool]); useEffect(() => { if (mapInstanceRef.current) return; const map = new maplibregl.Map({ container: mapRef.current, style: { version: 8, sources: {}, layers: [], glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf' }, center: [5.2913, 52.1326], zoom: 10, }); map.on('load', () => { // Achtergrondtile map.addSource('brt', { type: 'raster', tiles: [BRT_WMTS_TEMPLATE], tileSize: 256 }); map.addLayer({ id: 'brt', type: 'raster', source: 'brt' }); // Woonplaatsgrenzen map.addSource('woonplaatsen', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addLayer({ id: 'woonplaatsen-outline', type: 'line', source: 'woonplaatsen', maxzoom: 14, paint: { 'line-color': '#004494', 'line-width': 1.5, 'line-opacity': 0.8, 'line-dasharray': [4, 2] }, }); // Panden map.addSource('panden', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addLayer({ id: 'panden-fill', type: 'fill', source: 'panden', filter: ['!=', ['get', 'inactief'], true], paint: { 'fill-color': ['interpolate', ['linear'], ['get', 'bouwjaar'], 1900, '#3498db', 1960, '#2ecc71', 2000, '#f1c40f', 2015, '#e74c3c'], 'fill-opacity': 0.65 } }); map.addLayer({ id: 'panden-inactief-fill', type: 'fill', source: 'panden', filter: ['==', ['get', 'inactief'], true], layout: { visibility: 'none' }, paint: { 'fill-color': '#888', 'fill-opacity': 0.45 } }); map.addLayer({ id: 'panden-inactief-pattern', type: 'line', source: 'panden', filter: ['==', ['get', 'inactief'], true], layout: { visibility: 'none' }, paint: { 'line-color': '#e74c3c', 'line-width': 1, 'line-dasharray': [3, 2] } }); map.addLayer({ id: 'panden-outline', type: 'line', source: 'panden', filter: ['!=', ['get', 'inactief'], true], paint: { 'line-color': '#fff', 'line-width': 0.5 } }); // Geselecteerd pand highlight: fill + outline in #004494 map.addLayer({ id: 'pand-highlight-fill', type: 'fill', source: 'panden', paint: { 'fill-color': '#004494', 'fill-opacity': 0.25 }, filter: ['==', ['get', 'identificatie'], ''], }); map.addLayer({ id: 'pand-highlight', type: 'line', source: 'panden', paint: { 'line-color': '#004494', 'line-width': 2 }, filter: ['==', ['get', 'identificatie'], ''], }); map.on('click', 'panden-fill', async (e) => { if (activeToolRef.current !== 'navigeren') return; const id = e.features[0].properties.identificatie; const code = gemRef.current; if (!code) return; const f = ['==', ['get', 'identificatie'], id]; map.setFilter('pand-highlight-fill', f); map.setFilter('pand-highlight', f); setGeselecteerdPandId(id); setGeselecteerdePanden([]); setGeselecteerdePercelen([]); setVanuitSelectie(false); const r = await fetch(`${API}/gemeenten/${code}/panden/${id}`); setDetailPand(await r.json()); }); function updateBbox() { const b = map.getBounds(); setBbox([b.getWest().toFixed(6), b.getSouth().toFixed(6), b.getEast().toFixed(6), b.getNorth().toFixed(6)]); } map.on('moveend', () => { laadPanden(); updateBbox(); }); map.on('load', updateBbox); // Perceellagen map.addSource('percelen-wmts', { type: 'raster', tiles: ['https://service.pdok.nl/kadaster/kadastralekaart/wmts/v5_0?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=Kadastralekaart&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png'], tileSize: 256, attribution: '© Kadaster', }); map.addLayer({ id: 'percelen-wmts', type: 'raster', source: 'percelen-wmts', layout: { visibility: 'none' }, paint: { 'raster-opacity': 0.8 } }); map.addSource('percelen-wfs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); // Transparante fill op dezelfde polygon-source: maakt klikken/box-selectie // binnen het perceel mogelijk (de line-laag is alleen de rand → niet queryable // in het interieur). fill-opacity 0 = onzichtbaar maar wél queryRenderedFeatures. map.addLayer({ id: 'percelen-fill', type: 'fill', source: 'percelen-wfs', layout: { visibility: 'none' }, paint: { 'fill-color': '#ff8c00', 'fill-opacity': 0 }, minzoom: 15 }); map.addLayer({ id: 'percelen-wfs', type: 'line', source: 'percelen-wfs', layout: { visibility: 'none' }, paint: { 'line-color': '#ff8c00', 'line-width': 1.5 }, minzoom: 15 }); map.on('moveend', laadPercelen); map.on('click', 'percelen-fill', (e) => { const id = perceelIdUitFeature(e.features[0]) || 'Onbekend'; new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`Perceel
${id}`).addTo(map); }); map.on('mouseenter', 'percelen-fill', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'percelen-fill', () => { map.getCanvas().style.cursor = ''; }); // Apply initial tool state in case tool changed before map loaded if (activeToolRef.current !== 'navigeren') { map.dragPan.disable(); map.getCanvas().style.cursor = 'crosshair'; } }); mapInstanceRef.current = map; return () => { if (mapInstanceRef.current) { mapInstanceRef.current.remove(); mapInstanceRef.current = null; } }; }, []); // Perceellaag URL bijwerken bij nieuw kaartmateriaal useEffect(() => { const km = kaartmateriaal.find(k => k.voor_perceellaag); if (!km) return; const map = mapInstanceRef.current; if (!map || !map.isStyleLoaded()) return; try { const vis = map.getLayoutProperty('percelen-wmts', 'visibility'); if (map.getLayer('percelen-wmts')) map.removeLayer('percelen-wmts'); if (map.getSource('percelen-wmts')) map.removeSource('percelen-wmts'); map.addSource('percelen-wmts', { type: 'raster', tiles: [km.url], tileSize: 256, attribution: '© Kadaster' }); map.addLayer({ id: 'percelen-wmts', type: 'raster', source: 'percelen-wmts', layout: { visibility: vis || 'none' }, paint: { 'raster-opacity': 0.8 } }); } catch(e) {} }, [kaartmateriaal]); async function laadPanden() { const map = mapInstanceRef.current; const code = gemRef.current; if (!map || !code) return; const b = map.getBounds(); const url = `${API}/gemeenten/${code}/panden?minlng=${b.getWest()}&minlat=${b.getSouth()}&maxlng=${b.getEast()}&maxlat=${b.getNorth()}`; const data = await fetch(url).then(r => r.json()).catch(() => null); if (data && map.getSource('panden')) map.getSource('panden').setData(data); } async function laadWoonplaatsen(code) { const map = mapInstanceRef.current; if (!map || !code) return; const data = await fetch(`${API}/gemeenten/${code}/woonplaatsen-geojson`).then(r => r.json()).catch(() => null); if (data && map.getSource('woonplaatsen')) map.getSource('woonplaatsen').setData(data); } async function laadPercelen() { const map = mapInstanceRef.current; if (!map || !laagInstellingenRef.current.percelen?.aan || map.getZoom() < 15) return; const b = map.getBounds(); const url = `https://service.pdok.nl/kadaster/kadastralekaart/wfs/v5_0?service=WFS&version=2.0.0&request=GetFeature&typeName=kadastralekaart:perceel&outputFormat=application/json&bbox=${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()},EPSG:4326&count=200`; const data = await fetch(url).then(r => r.json()).catch(() => null); if (data && map.getSource('percelen-wfs')) map.getSource('percelen-wfs').setData(data); } // PDOK kadastralekaart v5 levert per perceel o.a. perceelnummer (getal, niet // landelijk uniek) en identificatieLokaalID (unieke BRK-ID). De backend-scan keyt // momenteel op perceelnummer (bronnen/perceel.py _wfs_ophalen_op_id), dus dat is // het primaire kopieer-ID; identificatieLokaalID als fallback. function perceelIdUitFeature(f) { const p = f.properties || {}; if (p.perceelnummer !== undefined && p.perceelnummer !== null) return String(p.perceelnummer); return p.kadastraleAanduiding || p.perceelidentificatie || p.identificatieLokaalID || null; } function voerSelectieUit(map, schermPuntenArray) { // schermPuntenArray: [{x, y}, ...] — voor rechthoek 2 punten, voor polygoon 3+ const xs = schermPuntenArray.map(p => p.x); const ys = schermPuntenArray.map(p => p.y); const box = [[Math.min(...xs), Math.min(...ys)], [Math.max(...xs), Math.max(...ys)]]; const isPolygoon = schermPuntenArray.length > 2; // queryRenderedFeatures accepteert alleen een bbox (de envelope). Bij een polygoon // levert dat over-selectie: alles in de omvattende rechthoek. Verfijn daarom op de // werkelijke vorm — een feature telt mee als zijn centroid binnen de polygoon valt. // Bij een rechthoek (2 punten) is box == vorm, dus geen extra filter nodig. function inSelectie(geom) { if (!isPolygoon) return true; const c = geomCentroidLngLat(geom); if (!c) return false; const s = map.project(c); return puntInPolygoon(s.x, s.y, schermPuntenArray); } const pandFeatures = map.queryRenderedFeatures(box, { layers: ['panden-fill'] }) .filter(f => inSelectie(f.geometry)); const panden = [...new Set(pandFeatures.map(f => f.properties.identificatie).filter(Boolean))]; // Perceel-IDs primair uit de pand-koppeling (primair_perceel_id, gecachet in de // panden-data → geen zoom/perceellaag nodig). Aangevuld met de WFS-fill als die // zichtbaar is (zoom ≥ 15), voor percelen zonder gekoppeld pand. const perceelSet = new Set(); pandFeatures.forEach(f => { const pid = f.properties.primair_perceel_id; if (pid !== undefined && pid !== null && pid !== '') perceelSet.add(String(pid)); }); if (map.getLayer('percelen-fill')) { map.queryRenderedFeatures(box, { layers: ['percelen-fill'] }) .filter(f => inSelectie(f.geometry)) .map(perceelIdUitFeature).filter(Boolean).forEach(id => perceelSet.add(String(id))); } return { panden, percelen: [...perceelSet] }; } function handleCanvasMouseDown(e) { const tool = activeToolRef.current; if (tool === 'navigeren') return; const rect = e.currentTarget.getBoundingClientRect(); const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }; if (tool === 'rechthoek') { setStartPunt(pt); setMuisPunt(pt); } else if (tool === 'polygoon') { // snapping: als binnen 12px van eerste punt, sluit polygoon const punten = tekenPuntenRef.current; if (punten.length >= 3) { const eerste = punten[0]; const dx = pt.x - eerste.x, dy = pt.y - eerste.y; if (Math.sqrt(dx*dx + dy*dy) < 12) { // sluit polygoon const map = mapInstanceRef.current; if (map) { verwerkSelectie(voerSelectieUit(map, punten), map); } setTekenPunten([]); setMuisPunt(null); setSnapActief(false); setActiveTool('navigeren'); return; } } setTekenPunten(prev => [...prev, pt]); } } function handleCanvasMouseMove(e) { const tool = activeToolRef.current; if (tool === 'navigeren') return; const rect = e.currentTarget.getBoundingClientRect(); const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }; setMuisPunt(pt); if (tool === 'polygoon') { const punten = tekenPuntenRef.current; if (punten.length >= 3) { const eerste = punten[0]; const dx = pt.x - eerste.x, dy = pt.y - eerste.y; setSnapActief(Math.sqrt(dx*dx + dy*dy) < 12); } } } function handleCanvasMouseUp(e) { const tool = activeToolRef.current; if (tool !== 'rechthoek') return; const sp = startPuntRef.current; if (!sp) return; const rect = e.currentTarget.getBoundingClientRect(); const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Minimum grootte 5px zodat kleine klikken geen lege selectie triggeren if (Math.abs(pt.x - sp.x) < 5 && Math.abs(pt.y - sp.y) < 5) { setStartPunt(null); setMuisPunt(null); return; } const map = mapInstanceRef.current; if (map) { verwerkSelectie(voerSelectieUit(map, [sp, pt]), map); } setStartPunt(null); setMuisPunt(null); setActiveTool('navigeren'); } function verwerkSelectie(selectie, map) { const panden = selectie.panden || []; const percelen = selectie.percelen || []; setGeselecteerdePanden(panden); setGeselecteerdePercelen(percelen); setDetailPand(null); setVanuitSelectie(false); if (panden.length > 0) { map.setFilter('pand-highlight-fill', ['in', ['get', 'identificatie'], ['literal', panden]]); map.setFilter('pand-highlight', ['in', ['get', 'identificatie'], ['literal', panden]]); } else { const leeg = ['==', ['get', 'identificatie'], '']; map.setFilter('pand-highlight-fill', leeg); map.setFilter('pand-highlight', leeg); } } async function openPandVanuitSelectie(id) { const code = gemRef.current; if (!code) return; const map = mapInstanceRef.current; try { const r = await fetch(`${API}/gemeenten/${code}/panden/${id}`); if (!r.ok) return; const data = await r.json(); setDetailPand(data); setVanuitSelectie(true); if (map) { map.setFilter('pand-highlight-fill', ['==', ['get', 'identificatie'], id]); map.setFilter('pand-highlight', ['==', ['get', 'identificatie'], id]); } } catch (_) {} } useEffect(() => { function onKeyDown(e) { if (e.key === 'Escape') { setActiveTool('navigeren'); setTekenPunten([]); setStartPunt(null); setMuisPunt(null); setSnapActief(false); } if (e.key === 'Backspace' && activeToolRef.current === 'polygoon' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) { e.preventDefault(); setTekenPunten(prev => prev.slice(0, -1)); } } window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, []); useEffect(() => { if (geselecteerdeGemeente) { laadPanden(); laadWoonplaatsen(geselecteerdeGemeente); if (mapInstanceRef.current) laadResultaten(geselecteerdeGemeente, mapInstanceRef.current); const gem = gemeenten.find(g => g.code === geselecteerdeGemeente); if (gem?.bbox && mapInstanceRef.current) { const [minlng, minlat, maxlng, maxlat] = gem.bbox; mapInstanceRef.current.fitBounds([[minlng, minlat], [maxlng, maxlat]], { padding: 40, animate: true }); } } }, [geselecteerdeGemeente]); useEffect(() => { if (kaartmateriaal.length > 0 && achtergrondId === null) { const standaard = kaartmateriaal.find(k => k.voor_kaart && k.type === 'wmts') || kaartmateriaal.find(k => k.voor_kaart); if (standaard) setAchtergrondId(standaard.id); } }, [kaartmateriaal]); useEffect(() => { const map = mapInstanceRef.current; if (!map || achtergrondId === null) return; const km = kaartmateriaal.find(k => k.id === achtergrondId); if (!km) return; const tileUrl = tileUrlVoorKaartmateriaal(km); if (!tileUrl || !map.getSource('brt')) return; map.getLayer('brt') && map.removeLayer('brt'); map.removeSource('brt'); map.addSource('brt', { type: 'raster', tiles: [tileUrl], tileSize: 256 }); const onderlaag = map.getLayer('woonplaatsen-outline') ? 'woonplaatsen-outline' : map.getLayer('panden-fill') ? 'panden-fill' : undefined; map.addLayer({ id: 'brt', type: 'raster', source: 'brt' }, onderlaag); }, [achtergrondId, kaartmateriaal]); useEffect(() => { const map = mapInstanceRef.current; if (!map) return; const detectieLayers = [ 'resultaten-zonnepanelen-fill', 'resultaten-zonnepanelen-line', 'resultaten-dakkapellen-fill', 'resultaten-dakkapellen-line', 'resultaten-contourafwijking-fill', 'resultaten-contourafwijking-line', ]; detectieLayers.forEach(lid => { if (!map.getLayer(lid)) return; if (nietAanwezigZichtbaar) { map.setFilter(lid, null); } else { map.setFilter(lid, ['==', ['get', 'aanwezig'], true]); } }); }, [nietAanwezigZichtbaar]); useEffect(() => { const map = mapInstanceRef.current; if (!map) return; const vis = inactiefZichtbaar ? 'visible' : 'none'; try { map.setLayoutProperty('panden-inactief-fill', 'visibility', vis); } catch(e) {} try { map.setLayoutProperty('panden-inactief-pattern', 'visibility', vis); } catch(e) {} }, [inactiefZichtbaar]); function handleDragDrop(doelId) { if (!dragBron || !doelId || dragBron === doelId) { setDragDoel(null); return; } const volgorde = [...lagenVolgorde]; const vanIdx = volgorde.indexOf(dragBron); const naarIdx = volgorde.indexOf(doelId); if (vanIdx === -1 || naarIdx === -1) return; volgorde.splice(vanIdx, 1); volgorde.splice(naarIdx, 0, dragBron); setLagenVolgorde(volgorde); setDragBron(null); setDragDoel(null); // Pas MapLibre volgorde aan: lijst boven = voorgrond, dus omgekeerd = van achter naar voren const map = mapInstanceRef.current; if (!map) return; const omgekeerd = [...volgorde].reverse(); omgekeerd.forEach((id, i) => { const laagConf = LAGEN_CONFIG.find(l => l.id === id); if (!laagConf) return; const volgendConf = LAGEN_CONFIG.find(l => l.id === omgekeerd[i + 1]); const beforeId = volgendConf ? volgendConf.mapLayers[0] : undefined; laagConf.mapLayers.forEach(lid => { try { map.moveLayer(lid, beforeId); } catch(e) {} }); }); } async function zoek(q) { setZoekterm(q); if (q.length < 2 || !geselecteerdeGemeente) { setSuggesties([]); return; } const qt = q.trim(); if (/^\d{10,}$/.test(qt) || /^[\dA-Za-z]{4,}([-_][\dA-Za-z]+)+$/.test(qt)) { const data = await fetch(`${API}/gemeenten/${geselecteerdeGemeente}/zoek-id?id=${encodeURIComponent(q.trim())}`).then(r => r.json()).catch(() => []); setSuggesties(data); } else { const data = await fetch(`${API}/gemeenten/${geselecteerdeGemeente}/zoek?q=${encodeURIComponent(q)}`).then(r => r.json()).catch(() => []); setSuggesties(data); } } function navigeerNaar(sug) { setSuggesties([]); const isId = ['Pand', 'VBO', 'Adres-ID', 'Perceel'].includes(sug.straatnaam); setZoekterm(isId ? (sug.identificatie || '') : `${sug.straatnaam} ${sug.huisnummer}${sug.huisletter||''}`); if (sug.geometry?.coordinates && mapInstanceRef.current) mapInstanceRef.current.flyTo({ center: sug.geometry.coordinates, zoom: 17 }); if (sug.pand_identificatie && mapInstanceRef.current) { const f = ['==', ['get', 'identificatie'], sug.pand_identificatie]; mapInstanceRef.current.setFilter('pand-highlight-fill', f); mapInstanceRef.current.setFilter('pand-highlight', f); setGeselecteerdPandId(sug.pand_identificatie); } } async function laadResultaten(gemeenteCode, map) { const zekerheidKleur = ['match', ['get', 'zekerheid'], 'hoog', '#22c55e', 'midden', '#fb923c', 'laag', '#ef4444', '#888' ]; const perceelObjectKleur = ['match', ['get', 'object_type'], 'contourafwijking', '#ef4444', 'zonnepanelen', '#eab308', 'natuurlijk', '#22c55e', 'onzeker', '#64748b', '#06b6d4' ]; const types = [ { type: 'zonnepanelen', laagId: 'zonnepanelen', sourceId: 'resultaten-zonnepanelen', fillId: 'resultaten-zonnepanelen-fill', lineId: 'resultaten-zonnepanelen-line' }, { type: 'dakkapel', laagId: 'dakkapellen', sourceId: 'resultaten-dakkapellen', fillId: 'resultaten-dakkapellen-fill', lineId: 'resultaten-dakkapellen-line' }, { type: 'contour', laagId: 'contourafwijking', sourceId: 'resultaten-contourafwijking', fillId: 'resultaten-contourafwijking-fill', lineId: 'resultaten-contourafwijking-line' }, ]; for (const { type, laagId, sourceId, fillId, lineId } of types) { try { const r = await fetch(`${API}/gemeenten/${gemeenteCode}/resultaten-geojson?detectie_type=${type}`); const geojson = await r.json(); const zichtbaarheid = laagInstellingenRef.current[laagId]?.aan ? 'visible' : 'none'; // Remove old layers/source if (map.getLayer(lineId)) map.removeLayer(lineId); if (map.getLayer(fillId)) map.removeLayer(fillId); // Also remove legacy circle layer if present (migration) if (map.getLayer(sourceId)) map.removeLayer(sourceId); if (map.getSource(sourceId)) map.removeSource(sourceId); map.addSource(sourceId, { type: 'geojson', data: geojson }); // Fill layer map.addLayer({ id: fillId, type: 'fill', source: sourceId, layout: { visibility: zichtbaarheid }, paint: { 'fill-color': zekerheidKleur, 'fill-opacity': 0.4, }, }); // Outline layer — dashed if bron='handmatig' map.addLayer({ id: lineId, type: 'line', source: sourceId, layout: { visibility: zichtbaarheid }, paint: { 'line-color': zekerheidKleur, 'line-width': 2, 'line-dasharray': ['case', ['==', ['get', 'bron'], 'handmatig'], ['literal', [4, 2]], ['literal', [1]]], }, }); } catch(e) {} } try { const r = await fetch(`${API}/gemeenten/${gemeenteCode}/perceel-objecten-geojson`); const geojson = await r.json(); const zichtbaarheid = laagInstellingenRef.current['perceel-objecten']?.aan ? 'visible' : 'none'; if (map.getLayer('perceel-objecten-line')) map.removeLayer('perceel-objecten-line'); if (map.getLayer('perceel-objecten-fill')) map.removeLayer('perceel-objecten-fill'); if (map.getSource('perceel-objecten')) map.removeSource('perceel-objecten'); map.addSource('perceel-objecten', { type: 'geojson', data: geojson }); map.addLayer({ id: 'perceel-objecten-fill', type: 'fill', source: 'perceel-objecten', layout: { visibility: zichtbaarheid }, paint: { 'fill-color': perceelObjectKleur, 'fill-opacity': 0.35, }, }); map.addLayer({ id: 'perceel-objecten-line', type: 'line', source: 'perceel-objecten', layout: { visibility: zichtbaarheid }, paint: { 'line-color': perceelObjectKleur, 'line-width': 2.5, }, }); } catch(e) {} // Click popup voor contourafwijking-laag map.on('click', 'resultaten-contourafwijking-fill', (e) => { const props = e.features[0].properties; map.getCanvas().style.cursor = ''; new maplibregl.Popup() .setLngLat(e.lngLat) .setHTML(` ${(props.subtype || 'afwijking').toUpperCase()}
Zekerheid: ${props.zekerheid}
Oppervlak: ${props.component_area_m2 ?? '?'} m² `) .addTo(map); }); map.on('mouseenter', 'resultaten-contourafwijking-fill', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'resultaten-contourafwijking-fill', () => { map.getCanvas().style.cursor = ''; }); map.on('click', 'perceel-objecten-fill', (e) => { const props = e.features[0].properties; const oppervlakte = props.oppervlakte_m2 ?? props.component_area_m2 ?? '?'; new maplibregl.Popup() .setLngLat(e.lngLat) .setHTML(` ${(props.subtype || props.object_type || 'object').toUpperCase()}
Type: ${props.object_type || '?'}
Perceel: ${props.perceel_id || '?'}
Pand: ${props.gekoppeld_pand_id || 'geen koppeling'}
Zekerheid: ${props.zekerheid || '?'}
Oppervlak: ${oppervlakte} m² `) .addTo(map); }); map.on('mouseenter', 'perceel-objecten-fill', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'perceel-objecten-fill', () => { map.getCanvas().style.cursor = ''; }); } const heeftDetectieAan = ['zonnepanelen','dakkapellen','contourafwijking','perceel-objecten'].some(id => laagInstellingen[id].aan); function startPanelResize(e) { e.preventDefault(); const startX = e.clientX; const startBreedte = panelBreedte; function onMove(e) { const delta = startX - e.clientX; setPanelBreedte(Math.max(200, Math.min(600, startBreedte + delta))); } function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } return (
{/* ── Centric toolbar ── */}
Gemeente:
zoek(e.target.value)} /> {suggesties.length > 0 && (
{suggesties.map((s, i) => { const isId = !s.straatnaam || ['Pand','VBO','Adres-ID'].includes(s.straatnaam); return (
navigeerNaar(s)}> {isId ? <>{s.straatnaam}{s.identificatie} : <>{s.straatnaam} {s.huisnummer}{s.huisletter||''}{s.postcode} {s.woonplaats}}
); })}
)}
{/* ── Kaartgebied ── */}
{/* ── Detail paneel: selectiemodus of panddetail ── */} {(geselecteerdePanden.length > 0 || geselecteerdePercelen.length > 0 || detailPand) && (
{/* Selectielijst */} {!detailPand && (geselecteerdePanden.length > 0 || geselecteerdePercelen.length > 0) && ( <>

{[ geselecteerdePanden.length > 0 ? `${geselecteerdePanden.length} panden` : null, geselecteerdePercelen.length > 0 ? `${geselecteerdePercelen.length} percelen` : null, ].filter(Boolean).join(' + ')} geselecteerd

{ setGeselecteerdePanden([]); setGeselecteerdePercelen([]); setDetailPand(null); setActiveTool('navigeren'); if (mapInstanceRef.current) { const leeg = ['==', ['get', 'identificatie'], '']; mapInstanceRef.current.setFilter('pand-highlight-fill', leeg); mapInstanceRef.current.setFilter('pand-highlight', leeg); } }} />
{/* Beide kopieerknoppen samen bovenaan (buiten de scroll), zodat de perceel-knop niet onder de volledige pand-lijst verdwijnt. */}
{geselecteerdePanden.length > 0 && ( )} {geselecteerdePercelen.length > 0 && ( )}
{geselecteerdePanden.length > 0 && ( <>
Panden — selectie gebaseerd op zichtbare panden
{geselecteerdePanden.map(id => (
openPandVanuitSelectie(id)}> {id}
))} )} {geselecteerdePercelen.length > 0 && ( <>
Percelen — uit pand-koppeling (+ perceellaag indien aan, zoom ≥ 15)
{geselecteerdePercelen.map(id => (
{id}
))} )}
)} {/* Panddetail (ook vanuit selectie) */} {detailPand && ( <>
{vanuitSelectie && ( { setDetailPand(null); setVanuitSelectie(false); const map = mapInstanceRef.current; if (map && geselecteerdePanden.length > 0) { map.setFilter('pand-highlight-fill', ['in', ['get', 'identificatie'], ['literal', geselecteerdePanden]]); map.setFilter('pand-highlight', ['in', ['get', 'identificatie'], ['literal', geselecteerdePanden]]); } }}>Selectie )}

Pand detail

{ setDetailPand(null); setGeselecteerdPandId(null); setVanuitSelectie(false); setGeselecteerdePanden([]); setGeselecteerdePercelen([]); if (mapInstanceRef.current) { const leeg = ['==', ['get', 'identificatie'], '']; mapInstanceRef.current.setFilter('pand-highlight-fill', leeg); mapInstanceRef.current.setFilter('pand-highlight', leeg); } }} />
BAG Pand
Bouwjaar: {detailPand.pand?.bouwjaar}
Status: {detailPand.pand?.status}
{detailPand.pand?.document_nummer &&
Doc: {detailPand.pand.document_nummer} ({detailPand.pand.document_datum})
}
{detailPand.detectie?.length > 0 && (

Detectieresultaten

{detailPand.detectie.map((d, i) => { const icoon = DETECTIE_ICON[d.detectie_type]==='sun' ? '☀️' : DETECTIE_ICON[d.detectie_type]==='home' ? '🏗️' : '⚠️'; const reviewIkoon = d.beoordeling==='correct' ? '✅' : d.beoordeling==='incorrect' ? '❌' : null; const analyseStatus = d.analyse_status || 'ok'; const aanwezigLabel = d.aanwezig === true ? 'Aanwezig' : d.aanwezig === false ? 'Niet aanwezig' : 'Onbekend'; const aanwezigKind = d.aanwezig === true ? 'success' : analyseStatus !== 'ok' ? 'warning' : 'neutral'; return (
{icoon} {DETECTIE_LABEL[d.detectie_type]||d.detectie_type} {reviewIkoon && {reviewIkoon}}
{d.ai_model||d.ai_provider||'—'} · {fmtDateTime(d.geanalyseerd_op)} {d.prompt_naam && <> · {d.prompt_naam}} {d.finish_reason && <> · finish: {d.finish_reason}}
{aanwezigLabel} {d.zekerheid||'—'}
{analyseStatus !== 'ok' && (
{d.fout_code || analyseStatus}
)} {d.toelichting &&
{d.toelichting}
}
); })}
)} {detailPand.verblijfsobjecten?.map((v,i) => (
{v.straatnaam} {v.huisnummer}{v.huisletter||''}
{v.postcode} {v.woonplaats_naam}
{v.gebruiksdoel} · {v.oppervlakte} m²
))} )}
)} {/* ── Kaartlagen paneel (rechts) ── */}
Kaartlagen
{/* Achtergrondlagen uit kaartmateriaal */} {kaartmateriaal.filter(k => k.voor_kaart).length > 0 && (
Achtergrond
{kaartmateriaal.filter(k => k.voor_kaart).map(k => (
setAchtergrondId(k.id)}> {achtergrondId === k.id && } {k.naam}{k.jaar ? ` (${k.jaar})` : ''}
))}
)} {/* Overige lagen — sleep om volgorde te wijzigen (boven = voorgrond) */} {lagenVolgorde.map(id => { const laag = LAGEN_CONFIG.find(l => l.id === id); if (!laag) return null; return ( setDragBron(id)} onDragOver={id => setDragDoel(id)} onDrop={handleDragDrop} isDragOver={dragDoel === laag.id} /> ); })} {/* Inactieve panden */}
setInactiefZichtbaar(v => !v)}> { e.stopPropagation(); setInactiefZichtbaar(v => !v); }}> {inactiefZichtbaar && } Inactieve panden
{/* Zekerheidslegenda */} {heeftDetectieAan && (
Zekerheid detectie
Aanwezig
{[{k:'#1a9e4a',l:'Hoog'},{k:'#f1c40f',l:'Midden'},{k:'#e67e22',l:'Laag'}].map(({k,l}) => (
{l}
))}
Niet aanwezig
{[{k:'#c0392b',l:'Hoog'},{k:'#e74c3c',l:'Midden'},{k:'#f1948a',l:'Laag'}].map(({k,l}) => (
{l}
))}
)}
{/* ── Legenda + live bbox ── */}
Bouwjaar voor 1960 1960–2000 2000–2015 na 2015 {bbox && ( Bbox: {bbox.join(',')} )}
); } window.SchermKaart = SchermKaart;