/* 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 (
);
}
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}) => (
))}
Niet aanwezig
{[{k:'#c0392b',l:'Hoog'},{k:'#e74c3c',l:'Midden'},{k:'#f1948a',l:'Laag'}].map(({k,l}) => (
))}
)}
{/* ── Legenda + live bbox ── */}
Bouwjaar
voor 1960
1960–2000
2000–2015
na 2015
{bbox && (
Bbox:
{bbox.join(',')}
)}
);
}
window.SchermKaart = SchermKaart;