/* ReviewCanvas: viewer met optionele SVG-correctiemodus */ /* Rotatie is VIEW-ONLY: opgeslagen quads blijven in originele beeldoriëntatie. Punten zijn genormaliseerd [0..1]. roteerPunt = origineel→weergave, ontroteerPunt = weergave→origineel. */ function roteerPunt([x, y], rot) { if (rot === 90) return [1 - y, x]; if (rot === 180) return [1 - x, 1 - y]; if (rot === 270) return [y, 1 - x]; return [x, y]; } function ontroteerPunt(p, rot) { return roteerPunt(p, (360 - rot) % 360); } /* Douglas-Peucker op een genormaliseerde, open ring. tolNorm = tol_m / breedte_m, spiegelt _simplificeer_ring in de pipeline zodat slider ≈ pipeline-default geeft. */ function _perpDist(p, a, b) { const dx = b[0] - a[0], dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; if (len2 === 0) { const ex = p[0] - a[0], ey = p[1] - a[1]; return Math.sqrt(ex * ex + ey * ey); } let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; t = Math.max(0, Math.min(1, t)); const px = a[0] + t * dx, py = a[1] + t * dy; const ex = p[0] - px, ey = p[1] - py; return Math.sqrt(ex * ex + ey * ey); } function _dpRange(pts, first, last, tol, keep) { let maxD = 0, idx = -1; for (let i = first + 1; i < last; i++) { const d = _perpDist(pts[i], pts[first], pts[last]); if (d > maxD) { maxD = d; idx = i; } } if (idx !== -1 && maxD > tol) { _dpRange(pts, first, idx, tol, keep); keep[idx] = true; _dpRange(pts, idx, last, tol, keep); } } function simplifyRing(ring, tolNorm) { if (!Array.isArray(ring) || ring.length <= 3 || tolNorm <= 0) return ring; const keep = new Array(ring.length).fill(false); keep[0] = true; keep[ring.length - 1] = true; _dpRange(ring, 0, ring.length - 1, tolNorm, keep); const out = ring.filter((_, i) => keep[i]); return out.length >= 3 ? out : ring; } /* ── Teken-hulp: afstand, projectie, ortho-constraint, regularisatie ── */ const _round5 = v => Math.round(v * 1e5) / 1e5; function _afstand(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1]); } function _projecteerOpSegment(p, a, b) { const dx = b[0] - a[0], dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; if (len2 === 0) return [a[0], a[1]]; let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2; t = Math.max(0, Math.min(1, t)); return [a[0] + t * dx, a[1] + t * dy]; } // Dwing een punt op de dichtstbijzijnde as 0/45/90° t.o.v. een referentiepunt. function _ortho(p, ref) { const dx = p[0] - ref[0], dy = p[1] - ref[1]; const len = Math.hypot(dx, dy); if (len < 1e-9) return p; const stap = Math.PI / 4; const hoek = Math.round(Math.atan2(dy, dx) / stap) * stap; return [ref[0] + Math.cos(hoek) * len, ref[1] + Math.sin(hoek) * len]; } function _lijnSnijpunt(L1, L2) { const [x1, y1] = L1.p, [dx1, dy1] = L1.d, [x2, y2] = L2.p, [dx2, dy2] = L2.d; const den = dx1 * dy2 - dy1 * dx2; if (Math.abs(den) < 1e-9) return null; const t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / den; return [x1 + dx1 * t, y1 + dy1 * t]; } // Orthogonaliseer een ring: elke rand naar haakse richting t.o.v. de langste rand, // nieuwe hoekpunten = snijpunten van opeenvolgende rand-lijnen. Faalt → origineel. function orthogonalizeRing(ring) { if (!Array.isArray(ring) || ring.length < 4) return ring; const n = ring.length; let theta = 0, maxLen = -1; for (let i = 0; i < n; i++) { const a = ring[i], b = ring[(i + 1) % n]; const l = Math.hypot(b[0] - a[0], b[1] - a[1]); if (l > maxLen) { maxLen = l; theta = Math.atan2(b[1] - a[1], b[0] - a[0]); } } const lijnen = []; for (let i = 0; i < n; i++) { const a = ring[i], b = ring[(i + 1) % n]; let rel = Math.atan2(b[1] - a[1], b[0] - a[0]) - theta; rel = Math.round(rel / (Math.PI / 2)) * (Math.PI / 2); const sa = theta + rel; lijnen.push({ p: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], d: [Math.cos(sa), Math.sin(sa)] }); } const out = []; for (let i = 0; i < n; i++) { const ip = _lijnSnijpunt(lijnen[(i + n - 1) % n], lijnen[i]); if (!ip) return ring; out.push([_round5(ip[0]), _round5(ip[1])]); } return out; } function ReviewCanvas({ resultaatId, fotoUrl, detectieType = null, editable = true, onSave }) { const [clusters, setClusters] = useState([]); const [drag, setDrag] = useState(null); const [tekenModus, setTekenModus] = useState(null); const [natuurlijkeMaat, setNatuurlijkeMaat] = useState(null); const [stageMaat, setStageMaat] = useState(null); const [zoom, setZoom] = useState({ scale: 1, x: 0, y: 0 }); const [pan, setPan] = useState(null); const [rotatie, setRotatie] = useState(0); const [simplifyTol, setSimplifyTol] = useState(0.4); // AHN-achtergrondtoggle (alleen contour): schone luchtfoto <-> AHN-hoogtekaart. const [ahnAan, setAhnAan] = useState(false); const [ahnBeschikbaar, setAhnBeschikbaar] = useState(false); // Detectie-overlay-toggle (alleen contour): toon het beeld MÉT gevonden contouren + nummers. const [contourAan, setContourAan] = useState(false); // Welk cluster onder de muis hangt (vanuit vlak óf lijstregel) → highlight beide kanten op. const [hoverId, setHoverId] = useState(null); // Snap-guides (BAG geel / perceel cyaan, genormaliseerd) + tekenhulp-state. const [bagRingen, setBagRingen] = useState([]); const [perceelRingen, setPerceelRingen] = useState([]); const [snapAan, setSnapAan] = useState(true); const [lijnenAan, setLijnenAan] = useState(true); // BAG/perceel-guides tonen (snap werkt ook zonder) const [snapHint, setSnapHint] = useState(null); // {punt} → snap gevonden (magenta) const [cursorPunt, setCursorPunt] = useState(null); // huidige (gesnapte) cursorpositie tijdens tekenen const [meet, setMeet] = useState(null); // {m, ang} live lengte/hoek const [geselPunt, setGeselPunt] = useState(null); // {clusterIdx, puntIdx} voor pijltjes-nudge const wrapRef = useRef(null); const svgRef = useRef(null); const rijRefs = useRef({}); // Globale default-tolerantie ophalen als startwaarde van de slider. useEffect(() => { fetch('/api/config/contour') .then(r => r.json()) .then(d => { if (typeof d.contour_simplificatie_m === 'number') setSimplifyTol(d.contour_simplificatie_m); }) .catch(() => {}); }, []); // Per resultaat de AHN-toggle resetten en checken of de dsm_clean-variant bestaat. useEffect(() => { setAhnAan(false); setAhnBeschikbaar(false); setContourAan(false); if (!resultaatId || detectieType !== 'contour') return; fetch(`${API}/afbeelding/${resultaatId}?variant=dsm_clean`, { method: 'HEAD' }) .then(resp => setAhnBeschikbaar(resp.ok)) .catch(() => setAhnBeschikbaar(false)); }, [resultaatId, detectieType]); // Achtergrond-matrix (alleen contour): // contourAan → beeld MÉT gevonden contouren + nummers ('default', of 'dsm' bij AHN) // anders → AHN-blend zonder nummers ('dsm_clean') bij AHN-toggle, anders de schone foto let bgUrl = fotoUrl; if (detectieType === 'contour') { if (contourAan) { bgUrl = `${API}/afbeelding/${resultaatId}?variant=${ahnAan && ahnBeschikbaar ? 'dsm' : 'default'}`; } else if (ahnAan && ahnBeschikbaar) { bgUrl = `${API}/afbeelding/${resultaatId}?variant=dsm_clean`; } } useEffect(() => { if (!resultaatId) return; setTekenModus(null); setDrag(null); setZoom({ scale: 1, x: 0, y: 0 }); setPan(null); setRotatie(0); setSnapHint(null); setMeet(null); setGeselPunt(null); fetch(`/api/review/cluster?resultaat_id=${resultaatId}`) .then(r => r.json()) .then(d => { setClusters(Array.isArray(d.clusters) ? d.clusters : []); setBagRingen(Array.isArray(d.bag_ringen) ? d.bag_ringen : []); setPerceelRingen(Array.isArray(d.perceel_ringen) ? d.perceel_ringen : []); }) .catch(() => { setClusters([]); setBagRingen([]); setPerceelRingen([]); }); }, [resultaatId]); useEffect(() => { if (!editable) { setTekenModus(null); setDrag(null); } }, [editable]); // Cursor-markering wissen zodra je niet meer tekent. useEffect(() => { if (!tekenModus) setCursorPunt(null); }, [tekenModus]); // Toetsenbord: Backspace = laatste tekenpunt terug; pijltjes = geselecteerd hoekpunt nudgen. useEffect(() => { if (!editable) return; const onKey = (e) => { if (e.key === 'Backspace' && tekenModus && tekenModus.punten.length) { e.preventDefault(); setTekenModus({ ...tekenModus, punten: tekenModus.punten.slice(0, -1) }); } else if (geselPunt && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); const stap = pxNaarNorm(1) || 0.001; const dx = e.key === 'ArrowLeft' ? -stap : e.key === 'ArrowRight' ? stap : 0; const dy = e.key === 'ArrowUp' ? -stap : e.key === 'ArrowDown' ? stap : 0; setClusters(prev => prev.map((c, ci) => ci === geselPunt.clusterIdx ? schrijfPunten(c, puntenVan(c).map((p, pi) => pi === geselPunt.puntIdx ? [p[0] + dx, p[1] + dy] : p)) : c)); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [editable, tekenModus, geselPunt, stageMaat, zoom]); useEffect(() => { if (!natuurlijkeMaat || !wrapRef.current) return; function berekenStageMaat() { const el = wrapRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); const padX = parseFloat(style.paddingLeft || 0) + parseFloat(style.paddingRight || 0); const padY = parseFloat(style.paddingTop || 0) + parseFloat(style.paddingBottom || 0); const maxW = Math.max(1, rect.width - padX); const maxH = Math.max(1, rect.height - padY); // Bij 90°/270° draait het beeld → fit op de geswapte (liggende) maat. const gedraaid = rotatie === 90 || rotatie === 270; const effW = gedraaid ? natuurlijkeMaat.height : natuurlijkeMaat.width; const effH = gedraaid ? natuurlijkeMaat.width : natuurlijkeMaat.height; const schaal = Math.min(maxW / effW, maxH / effH); setStageMaat({ width: Math.max(1, Math.floor(effW * schaal)), height: Math.max(1, Math.floor(effH * schaal)), }); } berekenStageMaat(); if (window.ResizeObserver) { const ro = new ResizeObserver(berekenStageMaat); ro.observe(wrapRef.current); return () => ro.disconnect(); } window.addEventListener('resize', berekenStageMaat); return () => window.removeEventListener('resize', berekenStageMaat); }, [natuurlijkeMaat, editable, rotatie]); const naarSvgCoords = (e) => { const rect = svgRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); // svg toont de geroteerde weergave → invoer terug naar originele beeldcoördinaten. return ontroteerPunt([x, y], rotatie); }; const clampZoom = (next) => { if (!stageMaat || !wrapRef.current) return next; const rect = wrapRef.current.getBoundingClientRect(); const style = window.getComputedStyle(wrapRef.current); const padX = parseFloat(style.paddingLeft || 0) + parseFloat(style.paddingRight || 0); const padY = parseFloat(style.paddingTop || 0) + parseFloat(style.paddingBottom || 0); const padLeft = parseFloat(style.paddingLeft || 0); const padTop = parseFloat(style.paddingTop || 0); const maxW = Math.max(1, rect.width - padX); const maxH = Math.max(1, rect.height - padY); const scaledW = stageMaat.width * next.scale; const scaledH = stageMaat.height * next.scale; const baseX = padLeft + Math.max(0, (maxW - stageMaat.width) / 2); const baseY = padTop + Math.max(0, (maxH - stageMaat.height) / 2); const minX = scaledW > maxW ? padLeft + maxW - scaledW - baseX : 0; const maxX = scaledW > maxW ? padLeft - baseX : 0; const minY = scaledH > maxH ? padTop + maxH - scaledH - baseY : 0; const maxY = scaledH > maxH ? padTop - baseY : 0; return { scale: next.scale, x: Math.max(minX, Math.min(maxX, next.x)), y: Math.max(minY, Math.min(maxY, next.y)), }; }; const zoomNaar = (targetScale, clientX, clientY) => { if (!stageMaat || !wrapRef.current) return; const wrapRect = wrapRef.current.getBoundingClientRect(); const style = window.getComputedStyle(wrapRef.current); const padX = parseFloat(style.paddingLeft || 0) + parseFloat(style.paddingRight || 0); const padY = parseFloat(style.paddingTop || 0) + parseFloat(style.paddingBottom || 0); const padLeft = parseFloat(style.paddingLeft || 0); const padTop = parseFloat(style.paddingTop || 0); const maxW = Math.max(1, wrapRect.width - padX); const maxH = Math.max(1, wrapRect.height - padY); const baseX = padLeft + Math.max(0, (maxW - stageMaat.width) / 2); const baseY = padTop + Math.max(0, (maxH - stageMaat.height) / 2); const relX = clientX - wrapRect.left; const relY = clientY - wrapRect.top; setZoom(prev => { const scale = Math.max(1, Math.min(5, targetScale)); const imageX = (relX - baseX - prev.x) / prev.scale; const imageY = (relY - baseY - prev.y) / prev.scale; return clampZoom({ scale, x: relX - baseX - imageX * scale, y: relY - baseY - imageY * scale, }); }); }; const zoomStap = (factor) => { if (!wrapRef.current) return; const rect = wrapRef.current.getBoundingClientRect(); zoomNaar(zoom.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2); }; const resetZoom = () => { setZoom({ scale: 1, x: 0, y: 0 }); setPan(null); }; const onStageDoubleClick = (e) => { e.preventDefault(); zoomNaar(zoom.scale >= 2.8 ? 1 : Math.max(2, zoom.scale * 1.7), e.clientX, e.clientY); }; const onPanStart = (e) => { if (zoom.scale <= 1 || tekenModus) return; e.preventDefault(); setPan({ startX: e.clientX, startY: e.clientY, zoomX: zoom.x, zoomY: zoom.y }); }; const onPanMove = (e) => { if (!pan) return; setZoom(prev => clampZoom({ scale: prev.scale, x: pan.zoomX + e.clientX - pan.startX, y: pan.zoomY + e.clientY - pan.startY, })); }; const onPanEnd = () => setPan(null); // Een cluster bewerkt zijn `contour` (N punten) als die bestaat, anders de `quad`. const isContour = (c) => Array.isArray(c.contour); const puntenVan = (c) => (isContour(c) ? c.contour : c.quad); const schrijfPunten = (c, punten) => (isContour(c) ? { ...c, contour: punten } : { ...c, quad: punten }); const verwijderPunt = (ci, pi) => setClusters(prev => prev.map((c, idx) => { if (idx !== ci || !isContour(c) || c.contour.length <= 3) return c; return { ...c, contour: c.contour.filter((_, k) => k !== pi) }; })); const voegPuntToe = (ci, pi) => setClusters(prev => prev.map((c, idx) => { if (idx !== ci || !isContour(c)) return c; const pts = c.contour; const a = pts[pi], b = pts[(pi + 1) % pts.length]; const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; return { ...c, contour: [...pts.slice(0, pi + 1), mid, ...pts.slice(pi + 1)] }; })); // Slider: her-simplificeer elke contour-cluster vanuit de ruwe bron-ring. const herSimplify = (tolM) => { setSimplifyTol(tolM); setClusters(prev => prev.map(c => { if (!Array.isArray(c.contour_bron) || !Array.isArray(c.foto_bbox)) return c; const breedteM = c.foto_bbox[2] - c.foto_bbox[0]; if (!(breedteM > 0)) return c; return { ...c, contour: simplifyRing(c.contour_bron, tolM / breedteM) }; })); }; const pxNaarNorm = (px) => { const w = stageMaat ? stageMaat.width * zoom.scale : 800; return w > 0 ? px / w : 0.0125; }; const resultFotoBbox = () => (clusters[0] && Array.isArray(clusters[0].foto_bbox) ? clusters[0].foto_bbox : null); // Snap een punt aan dichtstbijzijnde hoekpunt/segment van BAG, perceel, andere // componenten en de lopende tekening. Vertex-snap wint van segment-snap. const snapPunt = (p, excludeCluster) => { const thr = pxNaarNorm(14); let bestV = null, bestVd = thr, bestVsoort = null; let bestS = null, bestSd = thr, bestSeg = null, bestSsoort = null; const ringen = []; bagRingen.forEach(r => ringen.push({ pts: r, closed: true, soort: 'BAG' })); perceelRingen.forEach(r => ringen.push({ pts: r, closed: true, soort: 'perceel' })); clusters.forEach((c, ci) => { if (ci === excludeCluster) return; const pts = puntenVan(c); if (Array.isArray(pts) && pts.length) ringen.push({ pts, closed: true, soort: 'vlak' }); }); if (tekenModus && tekenModus.punten.length) ringen.push({ pts: tekenModus.punten, closed: false, soort: 'tekening' }); for (const { pts, closed, soort } of ringen) { for (let i = 0; i < pts.length; i++) { const d = _afstand(p, pts[i]); if (d < bestVd) { bestVd = d; bestV = pts[i]; bestVsoort = soort; } } const n = closed ? pts.length : pts.length - 1; for (let i = 0; i < n; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; const proj = _projecteerOpSegment(p, a, b); const d = _afstand(p, proj); if (d < bestSd) { bestSd = d; bestS = proj; bestSeg = [a, b]; bestSsoort = soort; } } } if (bestV) return { punt: bestV, type: 'vertex', soort: bestVsoort }; if (bestS) return { punt: bestS, type: 'edge', seg: bestSeg, soort: bestSsoort }; return null; }; // Verwerk een ruw ingevoerd punt: snap (tenzij Alt/uit) → ortho (Ctrl) → meet-label. const bewerkPunt = (raw, e, ref, excludeCluster) => { let p = raw, hint = null; if (snapAan && !(e && e.altKey)) { const s = snapPunt(p, excludeCluster); if (s) { p = s.punt; hint = s; } } if (e && e.ctrlKey && ref && !(hint && hint.type === 'vertex')) p = _ortho(p, ref); setSnapHint(hint); if (ref) { const fb = resultFotoBbox(); const m = fb ? _afstand(ref, p) * (fb[2] - fb[0]) : null; const ang = (Math.atan2(-(p[1] - ref[1]), p[0] - ref[0]) * 180 / Math.PI + 360) % 360; setMeet({ m, ang }); } return p; }; const onMouseMove = (e) => { if (pan) { onPanMove(e); return; } if (editable && tekenModus && !drag) { // Live preview van snap/ortho terwijl je tekent: cursor-markering volgt de muis en // wordt magenta zodra-ie aan een lijn/punt snapt. const raw = naarSvgCoords(e); const ref = tekenModus.punten.length ? tekenModus.punten[tekenModus.punten.length - 1] : null; setCursorPunt(bewerkPunt(raw, e, ref, null)); return; } if (!editable || !drag) return; const raw = naarSvgCoords(e); const pts = puntenVan(clusters[drag.clusterIdx]) || []; const ref = pts.length ? pts[(drag.puntIdx + pts.length - 1) % pts.length] : null; const p = bewerkPunt(raw, e, ref, drag.clusterIdx); setClusters(prev => prev.map((c, ci) => ci === drag.clusterIdx ? schrijfPunten(c, puntenVan(c).map((pp, pi) => pi === drag.puntIdx ? p : pp)) : c )); }; const onMouseUp = () => { setDrag(null); onPanEnd(); setSnapHint(null); setMeet(null); }; const postNieuwCluster = (type_, geomBody) => { fetch('/api/review/cluster', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resultaat_id: resultaatId, type: type_, ...geomBody }), }) .then(r => r.json()) .then(({ id }) => { setClusters(prev => [...prev, { id, type: type_, bron: 'handmatig', ...geomBody }]); setTekenModus(null); if (onSave) onSave(); }) .catch(() => setTekenModus(null)); }; const onSvgClick = (e) => { if (!editable || !tekenModus) return; const raw = naarSvgCoords(e); const ref = tekenModus.punten.length ? tekenModus.punten[tekenModus.punten.length - 1] : null; const p = bewerkPunt(raw, e, ref, null); // Rechthoek-modus: 2 klikken (hoek → tegenoverliggende hoek) → as-gerichte rechthoek. if (tekenModus.modus === 'rechthoek') { if (!tekenModus.punten.length) { setTekenModus({ ...tekenModus, punten: [p] }); return; } const a = tekenModus.punten[0]; postNieuwCluster('contour', { contour: [[a[0], a[1]], [p[0], a[1]], [p[0], p[1]], [a[0], p[1]]] }); return; } // Contour: vrije polygoon — punten blijven verzamelen tot 'Sluiten'. // Andere types: vaste 4-punts quad, sluit automatisch op het 4e punt. const nieuwePunten = [...tekenModus.punten, p]; if (tekenModus.type !== 'contour' && nieuwePunten.length === 4) { postNieuwCluster(tekenModus.type, { quad: nieuwePunten }); } else { setTekenModus({ ...tekenModus, punten: nieuwePunten }); } }; const sluitTekening = () => { if (!tekenModus || tekenModus.punten.length < 3) return; postNieuwCluster(tekenModus.type, { contour: tekenModus.punten }); }; const reviewPost = (cid, body) => fetch(`/api/review/cluster/${cid}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // Huidige (mogelijk versleepte) geometrie van een cluster. Élke review-actie stuurt // deze mee, zodat sleepwerk nooit verloren gaat: voorheen bewaarde alleen opslaan() // de contour, en sloten subtype/OK af met review_actie='akkoord' zónder geometrie -> // verschoven punten verdampten bij reload. const geomVan = (cid) => { const c = clusters.find(x => x.id === cid); if (!c) return {}; return isContour(c) ? { contour: c.contour } : { quad: c.quad }; }; const akkoord = (cid) => reviewPost(cid, { ...geomVan(cid), review_actie: 'akkoord' }).then(() => setClusters(prev => prev.map(c => c.id === cid ? { ...c, review_actie: 'akkoord' } : c) ) ); // Reviewer kent een subtype toe (contour) -> landt in cluster.subtype, telt mee // als 'akkoord'-label in de training-export (zie tools/export_training.py). const subtypeWijzigen = (cid, subtype) => reviewPost(cid, { ...geomVan(cid), subtype, review_actie: 'akkoord' }).then(() => setClusters(prev => prev.map(c => c.id === cid ? { ...c, subtype, review_actie: 'akkoord' } : c) ) ); const opslaan = (cluster) => { const body = isContour(cluster) ? { contour: cluster.contour, review_actie: 'gewijzigd' } : { quad: cluster.quad, review_actie: 'gewijzigd' }; return reviewPost(cluster.id, body).then(() => { setClusters(prev => prev.map(c => c.id === cluster.id ? { ...c, review_actie: 'gewijzigd' } : c) ); if (onSave) onSave(); }); }; const verwijderen = (cid) => reviewPost(cid, { review_actie: 'verwijderd' }).then(() => setClusters(prev => prev.filter(c => c.id !== cid)) ); // Orthogonaliseer een contour naar haakse hoeken en sla direct op (gewijzigd). const regulariseer = (cluster) => { if (!isContour(cluster)) return; const nieuw = orthogonalizeRing(cluster.contour); setClusters(prev => prev.map(c => c.id === cluster.id ? { ...c, contour: nieuw, review_actie: 'gewijzigd' } : c)); reviewPost(cluster.id, { contour: nieuw, review_actie: 'gewijzigd' }).then(() => { if (onSave) onSave(); }); }; const typeWijzigen = (cid, nieuwType) => reviewPost(cid, { ...geomVan(cid), type: nieuwType, review_actie: 'gewijzigd' }).then(() => setClusters(prev => prev.map(c => c.id === cid ? { ...c, type: nieuwType } : c) ) ); const clusterTypeOpties = detectieType === 'contour' ? [{ value: 'contour', label: 'Contourvlak' }] : [ { value: 'zonnepaneel', label: 'Zonnepaneel' }, { value: 'dakkapel', label: 'Dakkapel' }, { value: 'bag_afwijking', label: 'BAG-afwijking' }, ]; // Subtype-opties voor contour-review (zonder de niet-selecteerbare placeholder). const subtypeOpties = Object.entries(CONTOUR_SUBTYPE_LABEL) .filter(([key]) => key !== 'niet_geclassificeerd'); return (