Publish curated GeoIntel source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:16:56 +02:00
commit 0aae654d6e
764 files changed
+169154

No files matched your search

View File
Whitespace-only changes.
+47
View File
@@ -0,0 +1,47 @@
import { Suspense, lazy } from 'react'
import { LandingPage } from './components/auth/LandingPage'
import { useOperatorSession } from './hooks/useOperatorSession'
// De werkbank wordt pas opgehaald wanneer iemand is aangemeld. Daardoor betaalt
// het inlogscherm niet voor de kaartmotor en de paneelopmaak.
const WorkbenchApp = lazy(() => import('./WorkbenchApp'))
function Voorbereiden({ tekst }: { tekst: string }): JSX.Element {
return (
<div className="landing-auth-loading" role="status" aria-live="polite">
<div><span aria-hidden="true" /><strong>{tekst}</strong></div>
</div>
)
}
function App(): JSX.Element {
const { session, sessionError, loggingOut, handleAuthenticated, handleLogout } = useOperatorSession()
if (session === null) {
return <Voorbereiden tekst="GeoIntel wordt voorbereid…" />
}
if (!session.authenticated) {
return (
<LandingPage
serviceError={sessionError}
guestAccessEnabled={session.guest_access_enabled}
authentikEnabled={session.authentik_enabled}
onAuthenticated={handleAuthenticated}
/>
)
}
return (
<Suspense fallback={<Voorbereiden tekst="De werkbank wordt geladen…" />}>
<WorkbenchApp
username={session.username}
accessMode={session.role ?? (session.authentication_required ? 'operator' : 'open')}
loggingOut={loggingOut}
onLogout={handleLogout}
/>
</Suspense>
)
}
export default App
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+804
View File
@@ -0,0 +1,804 @@
import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import type { ExpressionSpecification } from '@maplibre/maplibre-gl-style-spec'
import 'maplibre-gl/dist/maplibre-gl.css'
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
import { basemapGround, basemapPaint, huidigeWerkstand, mapSymbology } from './map/mapSymbology'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
dataFillColor?: string
dataLineColor?: string
areaData?: GeoJSON.FeatureCollection | null
selectedFeature?: GeoJSON.Feature | null
selectionData?: GeoJSON.FeatureCollection | null
qaEvidenceData?: GeoJSON.FeatureCollection | null
imageOverlays?: MapImageOverlay[]
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
bboxSelectionMode?: boolean
visible?: boolean
opacity?: number
areaVisible?: boolean
areaOpacity?: number
fitDataOnChange?: boolean
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
onMapCoordinateSelect?: (coordinate: [number, number]) => void
onMapBboxPreview?: (bbox: VectorSelectionBBox) => void
onMapBboxSelect?: (bbox: VectorSelectionBBox) => void
onViewportChange?: (viewport: MapViewportState) => void
}
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
}
const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
version: 8,
sources: {
'osm-standard': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
maxzoom: 19,
},
},
layers: [
{
// Grondtoon onder de tegels. Zonder deze laag flitst er wit tussen
// tegels die nog niet geladen zijn.
id: 'basemap-ground',
type: 'background',
paint: { 'background-color': basemapGround(huidigeWerkstand()) },
},
{
id: 'osm-standard',
type: 'raster',
source: 'osm-standard',
// De tegel wordt ontkleurd en gedempt tot een operationele ondergrond,
// zodat alleen de eigen data nog kleur draagt. Geen andere tegelbron en
// geen sleutel nodig; wie een echte vectorstijl heeft zet die via
// VITE_MAP_STYLE_URL en omzeilt dit blok volledig. Zie basemapPaint voor
// het verschil tussen de twee werkstanden.
paint: basemapPaint(huidigeWerkstand()),
},
],
}
function datasetFillColor(fallbackColor: string): ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
mapSymbology.boundary,
[
'match',
['get', 'change_type'],
'added',
mapSymbology.added,
'removed',
mapSymbology.removed,
'modified',
mapSymbology.modified,
'unchanged',
mapSymbology.unchanged,
fallbackColor,
],
]
}
function datasetLineColor(fallbackColor: string): ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
mapSymbology.boundaryStrong,
[
'match',
['get', 'change_type'],
'added',
mapSymbology.added,
'removed',
mapSymbology.removed,
'modified',
mapSymbology.modified,
'unchanged',
mapSymbology.unchanged,
fallbackColor,
],
]
}
function defaultMapStyle(): string | maplibregl.StyleSpecification {
return import.meta.env.VITE_MAP_STYLE_URL || DEFAULT_ROAD_BASEMAP_STYLE
}
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
const bounds = featureCollectionBounds(featureCollection)
if (!bounds) {
return null
}
return [
[bounds.minX, bounds.minY],
[bounds.maxX, bounds.maxY],
]
}
function bboxToFeatureCollection(
bbox: { min_x: number; min_y: number; max_x: number; max_y: number } | null | undefined,
): GeoJSON.FeatureCollection {
if (!bbox) {
return EMPTY_FEATURE_COLLECTION
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[bbox.min_x, bbox.min_y],
[bbox.max_x, bbox.min_y],
[bbox.max_x, bbox.max_y],
[bbox.min_x, bbox.max_y],
[bbox.min_x, bbox.min_y],
],
],
},
properties: {
layer_type: 'selection_bbox',
},
},
],
}
}
function GeoMap({
data,
dataFillColor = mapSymbology.dataFill,
dataLineColor = mapSymbology.dataLine,
areaData = null,
selectedFeature = null,
selectionData = null,
qaEvidenceData = null,
imageOverlays = [],
selectionBbox = null,
bboxSelectionMode = false,
visible = true,
opacity = 0.4,
areaVisible = true,
areaOpacity = 0.18,
fitDataOnChange = true,
onFeatureSelect,
onMapCoordinateSelect,
onMapBboxPreview,
onMapBboxSelect,
onViewportChange,
}: GeoMapProps): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
const onMapBboxPreviewRef = useRef<GeoMapProps['onMapBboxPreview']>(onMapBboxPreview)
const onMapBboxSelectRef = useRef<GeoMapProps['onMapBboxSelect']>(onMapBboxSelect)
const onViewportChangeRef = useRef<GeoMapProps['onViewportChange']>(onViewportChange)
const bboxSelectionModeRef = useRef(bboxSelectionMode)
const areaDataRef = useRef<GeoJSON.FeatureCollection | null>(areaData)
const dataRef = useRef<GeoJSON.FeatureCollection | null>(data)
const fitDataOnChangeRef = useRef(fitDataOnChange)
const imageOverlayIdsRef = useRef<string[]>([])
// Onthoudt op welk kader al is ingezoomd. De fit-effecten draaien ook wanneer
// alleen `data` verandert; zonder deze bewaking sprong de kaart na elke
// analyse terug naar het volledige werkgebied en verloor de gebruiker zijn
// ingezoomde beeld direct na het tekenen van een selectie.
const lastFittedBoundsRef = useRef<string | null>(null)
// Zoomt alleen wanneer het kader echt anders is dan waarop we al pasten.
// Herhaalde aanroepen met dezelfde grenzen laten het beeld met rust.
const fitBoundsIfChanged = (
map: maplibregl.Map,
bounds: maplibregl.LngLatBoundsLike | null,
) => {
if (!bounds) {
return
}
const key = JSON.stringify(bounds)
if (lastFittedBoundsRef.current === key) {
return
}
lastFittedBoundsRef.current = key
map.fitBounds(bounds, { padding: 40, duration: 0 })
}
const [mapStyleReady, setMapStyleReady] = useState(false)
areaDataRef.current = areaData
dataRef.current = data
fitDataOnChangeRef.current = fitDataOnChange
useEffect(() => {
onFeatureSelectRef.current = onFeatureSelect
}, [onFeatureSelect])
useEffect(() => {
onMapCoordinateSelectRef.current = onMapCoordinateSelect
}, [onMapCoordinateSelect])
useEffect(() => {
onMapBboxPreviewRef.current = onMapBboxPreview
}, [onMapBboxPreview])
useEffect(() => {
onMapBboxSelectRef.current = onMapBboxSelect
}, [onMapBboxSelect])
useEffect(() => {
onViewportChangeRef.current = onViewportChange
}, [onViewportChange])
useEffect(() => {
bboxSelectionModeRef.current = bboxSelectionMode
if (mapRef.current) {
mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : ''
if (bboxSelectionMode) {
mapRef.current.dragPan.disable()
} else {
mapRef.current.dragPan.enable()
}
}
}, [bboxSelectionMode])
// De kaart wordt eenmalig opgebouwd, dus bij het wisselen van werkstand moet
// alleen de verf van de ondergrond mee. setPaintProperty laat alle datalagen
// ongemoeid; een volledige setStyle zou ze opnieuw moeten opbouwen.
useEffect(() => {
const pasAan = () => {
const map = mapRef.current
if (!map || !map.isStyleLoaded()) return
const werkstand = huidigeWerkstand()
if (!map.getLayer('osm-standard')) return
for (const [naam, waarde] of Object.entries(basemapPaint(werkstand))) {
map.setPaintProperty('osm-standard', naam as never, waarde as never)
}
if (map.getLayer('basemap-ground')) {
map.setPaintProperty('basemap-ground', 'background-color', basemapGround(werkstand))
}
}
const waarnemer = new MutationObserver(pasAan)
waarnemer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] })
return () => waarnemer.disconnect()
}, [])
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return
}
const map = new maplibregl.Map({
container: containerRef.current,
style: defaultMapStyle(),
center: NATIONAL_MAP_CENTER,
zoom: NATIONAL_MAP_ZOOM,
attributionControl: false,
})
const resizeObserver = new ResizeObserver(() => {
map.resize()
const fitCollection = areaDataRef.current
?? (fitDataOnChangeRef.current ? dataRef.current : null)
// Bij het aanpassen van de venstergrootte het beeld behouden in plaats
// van terugspringen naar het volledige werkgebied.
fitBoundsIfChanged(map, fitCollection ? collectCoordinates(fitCollection) : null)
})
resizeObserver.observe(containerRef.current)
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(
new maplibregl.AttributionControl({
compact: true,
}),
'bottom-right',
)
const emitViewport = () => {
const bounds = map.getBounds()
onViewportChangeRef.current?.({
bbox: {
min_x: bounds.getWest(),
min_y: bounds.getSouth(),
max_x: bounds.getEast(),
max_y: bounds.getNorth(),
crs: 'EPSG:4326',
},
zoom: map.getZoom(),
})
}
map.on('load', () => {
map.resize()
setMapStyleReady(true)
emitViewport()
})
map.on('moveend', emitViewport)
let dragStart: [number, number] | null = null
let draggedSelection = false
const bboxFromCoordinates = (start: [number, number], end: [number, number]): VectorSelectionBBox => ({
min_x: Math.min(start[0], end[0]),
min_y: Math.min(start[1], end[1]),
max_x: Math.max(start[0], end[0]),
max_y: Math.max(start[1], end[1]),
crs: 'EPSG:4326',
})
map.on('mousedown', (event) => {
if (!bboxSelectionModeRef.current || event.originalEvent.button !== 0) {
return
}
event.preventDefault()
dragStart = [event.lngLat.lng, event.lngLat.lat]
draggedSelection = false
map.getCanvas().style.cursor = 'crosshair'
})
map.on('mousemove', (event) => {
if (!bboxSelectionModeRef.current || !dragStart) {
return
}
draggedSelection = true
onMapBboxPreviewRef.current?.(bboxFromCoordinates(dragStart, [event.lngLat.lng, event.lngLat.lat]))
})
map.on('mouseup', (event) => {
if (!bboxSelectionModeRef.current || !dragStart) {
return
}
const start = dragStart
dragStart = null
const bbox = bboxFromCoordinates(start, [event.lngLat.lng, event.lngLat.lat])
if (bbox.max_x > bbox.min_x && bbox.max_y > bbox.min_y) {
onMapBboxSelectRef.current?.(bbox)
}
})
map.on('click', (event) => {
if (bboxSelectionModeRef.current) {
if (draggedSelection) {
draggedSelection = false
return
}
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
return
}
const layers = [
'qa-evidence-fill',
'qa-evidence-line',
'qa-evidence-circle',
'selection-result-fill',
'selection-result-line',
'selection-result-circle',
'dataset-fill',
'dataset-line',
'area-fill',
'area-line',
].filter((layerId) => map.getLayer(layerId))
if (layers.length === 0) {
onFeatureSelectRef.current?.(null)
return
}
const features = map.queryRenderedFeatures(event.point, {
layers,
})
if (features.length === 0) {
onFeatureSelectRef.current?.(null)
return
}
const feature = features[0] as unknown as GeoJSON.Feature
onFeatureSelectRef.current?.(feature)
})
mapRef.current = map
return () => {
resizeObserver.disconnect()
map.remove()
mapRef.current = null
setMapStyleReady(false)
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
for (const overlayId of [...imageOverlayIdsRef.current].reverse()) {
if (map.getLayer(overlayId)) {
map.removeLayer(overlayId)
}
if (map.getSource(overlayId)) {
map.removeSource(overlayId)
}
}
imageOverlayIdsRef.current = []
const beforeLayer = ['area-fill', 'dataset-fill', 'selection-bbox-fill'].find((layerId) => map.getLayer(layerId))
imageOverlays.forEach((imageOverlay, index) => {
const overlayId = `bounded-raster-${index}`
const [minX, minY, maxX, maxY] = imageOverlay.bbox
map.addSource(overlayId, {
type: 'image',
url: imageOverlay.url,
coordinates: [
[minX, maxY],
[maxX, maxY],
[maxX, minY],
[minX, minY],
],
})
map.addLayer({
id: overlayId,
type: 'raster',
source: overlayId,
paint: { 'raster-opacity': imageOverlay.opacity ?? 0.88 },
}, beforeLayer)
imageOverlayIdsRef.current.push(overlayId)
})
}, [imageOverlays, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
if (map.getSource('dataset')) {
if (data) {
;(map.getSource('dataset') as maplibregl.GeoJSONSource).setData(data)
} else {
if (map.getLayer('dataset-fill')) {
map.removeLayer('dataset-fill')
}
if (map.getLayer('dataset-line')) {
map.removeLayer('dataset-line')
}
map.removeSource('dataset')
return
}
} else if (data) {
map.addSource('dataset', { type: 'geojson', data })
map.addLayer({
id: 'dataset-fill',
type: 'fill',
source: 'dataset',
paint: {
'fill-color': datasetFillColor(dataFillColor),
'fill-opacity': 0.4,
},
})
map.addLayer({
id: 'dataset-line',
type: 'line',
source: 'dataset',
paint: {
'line-color': datasetLineColor(dataLineColor),
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.25, 12, 0.8, 16, 2],
},
})
}
if (data && fitDataOnChange && !areaData) {
const collection = data
if (collection.type === 'FeatureCollection' && collection.features.length > 0) {
fitBoundsIfChanged(map, collectCoordinates(collection))
}
}
}, [areaData, data, dataFillColor, dataLineColor, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
if (map.getLayer('dataset-fill')) {
map.setPaintProperty('dataset-fill', 'fill-color', datasetFillColor(dataFillColor))
}
if (map.getLayer('dataset-line')) {
map.setPaintProperty('dataset-line', 'line-color', datasetLineColor(dataLineColor))
}
}, [dataFillColor, dataLineColor, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
if (map.getSource('area')) {
if (areaData) {
;(map.getSource('area') as maplibregl.GeoJSONSource).setData(areaData)
} else {
if (map.getLayer('area-fill')) {
map.removeLayer('area-fill')
}
if (map.getLayer('area-line')) {
map.removeLayer('area-line')
}
map.removeSource('area')
return
}
} else if (areaData) {
map.addSource('area', { type: 'geojson', data: areaData })
map.addLayer(
{
id: 'area-fill',
type: 'fill',
source: 'area',
paint: {
'fill-color': mapSymbology.boundary,
'fill-opacity': 0.18,
},
},
map.getLayer('dataset-fill') ? 'dataset-fill' : undefined,
)
map.addLayer(
{
id: 'area-line',
type: 'line',
source: 'area',
paint: {
'line-color': mapSymbology.boundary,
'line-width': 3,
'line-dasharray': [2, 1],
},
},
map.getLayer('dataset-line') ? 'dataset-line' : undefined,
)
}
// Dit effect draait ook wanneer alleen `data` verandert, bijvoorbeeld zodra
// een analyse resultaten oplevert. Ongewaakt zoomde de kaart dan terug naar
// het volledige werkgebied, meteen nadat de gebruiker een rechthoek had
// getekend. De bewaking laat het beeld staan zolang het gebied gelijk blijft.
const activeCollection = areaData ?? (fitDataOnChange ? data : null)
if (activeCollection) {
fitBoundsIfChanged(map, collectCoordinates(activeCollection))
}
}, [areaData, data, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
const visibility = visible ? 'visible' : 'none'
if (map.getLayer('dataset-fill')) {
map.setLayoutProperty('dataset-fill', 'visibility', visibility)
map.setPaintProperty('dataset-fill', 'fill-opacity', opacity)
}
if (map.getLayer('dataset-line')) {
map.setLayoutProperty('dataset-line', 'visibility', visibility)
map.setPaintProperty('dataset-line', 'line-opacity', visible ? 1 : 0)
}
}, [visible, opacity, data, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
const visibility = areaVisible ? 'visible' : 'none'
if (map.getLayer('area-fill')) {
map.setLayoutProperty('area-fill', 'visibility', visibility)
map.setPaintProperty('area-fill', 'fill-opacity', areaOpacity)
}
if (map.getLayer('area-line')) {
map.setLayoutProperty('area-line', 'visibility', visibility)
map.setPaintProperty('area-line', 'line-opacity', areaVisible ? 1 : 0)
}
}, [areaVisible, areaOpacity, areaData, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
const selectedCollection: GeoJSON.FeatureCollection = selectedFeature
? { type: 'FeatureCollection', features: [selectedFeature] }
: EMPTY_FEATURE_COLLECTION
if (map.getSource('selected-feature')) {
;(map.getSource('selected-feature') as maplibregl.GeoJSONSource).setData(selectedCollection)
return
}
map.addSource('selected-feature', { type: 'geojson', data: selectedCollection })
map.addLayer({
id: 'selected-feature-fill',
type: 'fill',
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': mapSymbology.selectionFill,
'fill-opacity': 0.32,
},
})
map.addLayer({
id: 'selected-feature-line',
type: 'line',
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': mapSymbology.selectionLine,
'line-width': 4,
},
})
map.addLayer({
id: 'selected-feature-circle',
type: 'circle',
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': mapSymbology.selectionFill,
'circle-radius': 7,
'circle-stroke-color': mapSymbology.selectionLine,
'circle-stroke-width': 2,
},
})
}, [selectedFeature, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
const bboxCollection = bboxToFeatureCollection(selectionBbox)
if (map.getSource('selection-bbox')) {
;(map.getSource('selection-bbox') as maplibregl.GeoJSONSource).setData(bboxCollection)
} else {
map.addSource('selection-bbox', { type: 'geojson', data: bboxCollection })
map.addLayer({
id: 'selection-bbox-fill',
type: 'fill',
source: 'selection-bbox',
paint: {
'fill-color': mapSymbology.waterFill,
'fill-opacity': 0.12,
},
})
map.addLayer({
id: 'selection-bbox-line',
type: 'line',
source: 'selection-bbox',
paint: {
'line-color': mapSymbology.waterLine,
'line-width': 2,
'line-dasharray': [2, 1],
},
})
}
}, [selectionBbox, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
const resultCollection = selectionData ?? EMPTY_FEATURE_COLLECTION
if (map.getSource('selection-result')) {
;(map.getSource('selection-result') as maplibregl.GeoJSONSource).setData(resultCollection)
return
}
map.addSource('selection-result', { type: 'geojson', data: resultCollection })
map.addLayer({
id: 'selection-result-fill',
type: 'fill',
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': mapSymbology.detectionFill,
'fill-opacity': 0.24,
},
})
map.addLayer({
id: 'selection-result-line',
type: 'line',
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': mapSymbology.detectionLine,
'line-width': 3,
},
})
map.addLayer({
id: 'selection-result-circle',
type: 'circle',
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': mapSymbology.detectionFill,
'circle-radius': 6,
'circle-stroke-color': mapSymbology.pointStroke,
'circle-stroke-width': 2,
},
})
}, [selectionData, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady) {
return
}
const evidenceCollection = qaEvidenceData ?? EMPTY_FEATURE_COLLECTION
if (map.getSource('qa-evidence')) {
;(map.getSource('qa-evidence') as maplibregl.GeoJSONSource).setData(evidenceCollection)
return
}
map.addSource('qa-evidence', { type: 'geojson', data: evidenceCollection })
const evidenceColor = [
'match',
['get', 'qa_evidence_role'],
'match_candidate',
mapSymbology.unchanged,
'match_reference',
mapSymbology.boundary,
'false_positive',
mapSymbology.removed,
'false_negative',
mapSymbology.modified,
mapSymbology.fallback,
] as ExpressionSpecification
map.addLayer({
id: 'qa-evidence-fill',
type: 'fill',
source: 'qa-evidence',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': evidenceColor,
'fill-opacity': 0.28,
},
})
map.addLayer({
id: 'qa-evidence-line',
type: 'line',
source: 'qa-evidence',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': evidenceColor,
'line-width': [
'match',
['get', 'qa_evidence_role'],
'false_positive',
4,
'false_negative',
4,
3,
],
},
})
map.addLayer({
id: 'qa-evidence-circle',
type: 'circle',
source: 'qa-evidence',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': evidenceColor,
'circle-radius': 7,
'circle-stroke-color': mapSymbology.pointStroke,
'circle-stroke-width': 2,
},
})
}, [qaEvidenceData, mapStyleReady])
return (
<div
className="map-container"
ref={containerRef}
role="region"
aria-label="Interactieve kaart. Gebruik de kaartbediening om te zoomen en de tekenknop om een gebied te selecteren."
/>
)
}
// Geen React.memo hier. Het is geprobeerd en het scheelde niets: van de
// negentien props worden er te veel per render opnieuw gemaakt, dus de
// vergelijking slaat nooit over. Zinvol wordt dat pas wanneer die props
// gestabiliseerd zijn; tot die tijd is het schijnzekerheid.
export default GeoMap
@@ -0,0 +1,67 @@
import { cleanup, render } from '@testing-library/react'
import { screen } from '@testing-library/dom'
import { afterEach, describe, expect, it } from 'vitest'
import type { AreaRead, DatasetCreateResponse, ProjectRead } from '../types'
import { WorkbenchStatusStrip } from './WorkbenchStatusStrip'
const project = {
id: 'project-1',
name: 'Belgium and North Sea Workbench',
region: 'Belgium and Belgian North Sea',
} as unknown as ProjectRead
const areas = [{ id: 'area-1', name: 'Belgium land' }] as unknown as AreaRead[]
function renderStatus(datasets: DatasetCreateResponse[]): void {
render(
<WorkbenchStatusStrip
selectedProject={project}
areas={areas}
datasets={datasets}
qualityChecks={[]}
exports={[]}
activeLayerFeatureCount={1}
selectedAreaHasGeometry
/>,
)
}
afterEach(cleanup)
describe('WorkbenchStatusStrip analytical readiness', () => {
it('treats the persisted NGI administrative baseline as analyzable', () => {
renderStatus([
{
id: 'dataset-1',
dataset_type: 'vector',
source: 'operator_official_import',
source_name: 'ngi_adminvector',
reference_layer_name: 'belgium_municipalities',
dataset_role: 'reference',
status: 'ready',
} as unknown as DatasetCreateResponse,
])
expect(screen.getByText('Kaartwerkruimte is gebruiksklaar')).toBeTruthy()
expect(screen.getByText("1 analysethema")).toBeTruthy()
expect(screen.getByLabelText('4 van 6 onderdelen gereed').getAttribute('data-ready')).toBe('4')
})
it('does not call an unrelated stored context file an analysis theme', () => {
renderStatus([
{
id: 'dataset-2',
dataset_type: 'vector',
source: 'manual',
source_name: 'manual',
reference_layer_name: 'custom_context',
dataset_role: 'reference',
status: 'ready',
} as unknown as DatasetCreateResponse,
])
expect(screen.getByText('Kaartwerkruimte vraagt aandacht')).toBeTruthy()
expect(screen.getByText("0 analysethema's")).toBeTruthy()
expect(screen.getByLabelText('3 van 6 onderdelen gereed').getAttribute('data-ready')).toBe('3')
})
})
@@ -0,0 +1,220 @@
import type { AreaRead, DatasetCreateResponse, ExportRead, ProjectRead, QualityCheckRead } from '../types'
import {
FLANDERS_WORKSPACE_LABEL,
FLANDERS_WORKSPACE_PROJECT_NAME,
REGIONAL_WORKSPACE_LABEL,
REGIONAL_WORKSPACE_PROJECT_NAME,
} from '../config/primaryFocus'
interface StatusItem {
key: string
label: string
value: string
detail: string
state: 'ready' | 'warning' | 'waiting'
}
interface WorkbenchStatusStripProps {
selectedProject: ProjectRead | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
qualityChecks: QualityCheckRead[]
exports: ExportRead[]
activeLayerFeatureCount: number
selectedAreaHasGeometry: boolean
}
function countByDatasetType(datasets: DatasetCreateResponse[], datasetType: string): number {
return datasets.filter((dataset) => dataset.dataset_type === datasetType).length
}
function countReferenceDatasets(datasets: DatasetCreateResponse[]): number {
return datasets.filter((dataset) => dataset.dataset_role === 'reference').length
}
const ANALYTICAL_SOURCE_NAMES = new Set([
'ngi_adminvector',
'rbins_marine_reporting_units',
'rbins_msp_2026',
'grb',
'department_omgeving_land_use',
'historical_landuse',
'department_omgeving_thematic_raster',
'statbel',
'inbo_bwk_natura2000',
'agentschap_landbouw_zeevisserij_agricultural_parcels',
'dov_soil_map',
'digitaal_vlaanderen_dhmv',
'spw_terrain',
'vmm_flood_hazard',
'vmm_vha_bathymetry_profiles',
])
const ANALYTICAL_LAYER_NAMES = new Set([
'administrative',
'belgium_land_boundary',
'belgium_regions',
'belgium_provinces',
'belgium_municipalities',
'buildings',
'roads',
'water',
'parcels',
'population',
'forest',
'nature_value',
'agriculture',
'soil',
'bathymetry',
'flood_hazard',
'elevation',
'maritime_planning',
'marine_environment',
'marine_legal_scopes',
'marine_spatial_plan_2026',
])
function isAnalyzableDataset(dataset: DatasetCreateResponse): boolean {
if (dataset.status !== 'ready') return false
const sourceName = String(dataset.source_name ?? dataset.source ?? '').toLowerCase()
const layerName = String(
dataset.source_metadata?.['theme']
?? dataset.reference_layer_name
?? dataset.source_metadata?.['layer_type']
?? '',
).toLowerCase()
return ANALYTICAL_SOURCE_NAMES.has(sourceName) || ANALYTICAL_LAYER_NAMES.has(layerName)
}
function statusLabel(state: StatusItem['state']): string {
if (state === 'ready') return 'gereed'
if (state === 'warning') return 'aandacht'
return 'nog open'
}
function projectLabel(project: ProjectRead | null): string {
if (!project) return 'Geen werkruimte'
if (project.name === FLANDERS_WORKSPACE_PROJECT_NAME) return FLANDERS_WORKSPACE_LABEL
return project.name === REGIONAL_WORKSPACE_PROJECT_NAME ? REGIONAL_WORKSPACE_LABEL : project.name
}
function nextAction(items: StatusItem[]): string {
if (!items.some((item) => item.key === 'project' && item.state === 'ready')) {
return 'De regionale werkruimte kon niet worden geopend.'
}
if (!items.some((item) => item.key === 'area' && item.state === 'ready')) {
return 'Kies een gemeente of de volledige regio.'
}
if (!items.some((item) => item.key === 'datasets' && item.state === 'ready')) {
return 'Laad een kaartlaag of luchtbeeld in.'
}
if (!items.some((item) => item.key === 'qa' && item.state === 'ready')) {
return 'Kaart en bronnen zijn klaar. Kwaliteitscontrole is beschikbaar zodra er een analyseresultaat is.'
}
if (!items.some((item) => item.key === 'exports' && item.state === 'ready')) {
return 'De analyse is klaar; maak eventueel een rapport of download.'
}
return 'Alle onderdelen van de werkruimte bevatten bewaarde resultaten.'
}
export function WorkbenchStatusStrip({
selectedProject,
areas,
datasets,
qualityChecks,
exports,
activeLayerFeatureCount,
selectedAreaHasGeometry,
}: WorkbenchStatusStripProps): JSX.Element {
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
const analyzableDatasets = datasets.filter(isAnalyzableDataset).length
const vectorDatasets = countByDatasetType(datasets, 'vector') + countByDatasetType(datasets, 'geojson')
const rasterDatasets = countByDatasetType(datasets, 'raster')
const referenceDatasets = countReferenceDatasets(datasets)
const latestQualityCheck = qualityChecks[0]
const latestExport = exports[0]
const items: StatusItem[] = [
{
key: 'project',
label: 'Werkruimte',
value: projectLabel(selectedProject),
detail: selectedProject ? selectedProject.region : 'Niet geladen',
state: selectedProject ? 'ready' : 'waiting',
},
{
key: 'area',
label: 'Gebieden',
value: `${areas.length} beschikbaar`,
detail: selectedAreaHasGeometry ? 'Kaartgrens beschikbaar' : 'Geen kaartgrens geselecteerd',
state: selectedAreaHasGeometry ? 'ready' : areas.length > 0 ? 'warning' : 'waiting',
},
{
key: 'datasets',
label: 'Bronnen',
value: `${analyzableDatasets} analysethema${analyzableDatasets === 1 ? '' : "'s"}`,
detail: (
`${readyDatasets}/${datasets.length} bronnen gereed · `
+ `${vectorDatasets} kaartlagen, ${rasterDatasets} rasters, ${referenceDatasets} officiële referenties`
),
state: analyzableDatasets === 0 ? 'waiting' : readyDatasets === datasets.length ? 'ready' : 'warning',
},
{
key: 'map',
label: 'Kaart',
value: `${activeLayerFeatureCount} zichtbare objecten`,
detail: selectedAreaHasGeometry ? 'Gebiedsgrens beschikbaar' : 'Gebiedsgrens ontbreekt',
state: selectedAreaHasGeometry || activeLayerFeatureCount > 0 ? 'ready' : 'waiting',
},
{
key: 'qa',
label: 'Kwaliteit',
value: `${qualityChecks.length} controles`,
detail: latestQualityCheck ? `${latestQualityCheck.check_type}: ${latestQualityCheck.status}` : 'Nog geen controle uitgevoerd',
state: qualityChecks.length > 0 ? 'ready' : 'waiting',
},
{
key: 'exports',
label: 'Downloads',
value: `${exports.length} bestanden`,
detail: latestExport ? `${latestExport.export_type}: ${latestExport.status}` : 'Nog geen bestand gemaakt',
state: exports.length > 0 ? 'ready' : 'waiting',
},
]
const readyCount = items.filter((item) => item.state === 'ready').length
const coreReady = items.slice(0, 4).every((item) => item.state === 'ready')
return (
<section className="workbench-status-strip" aria-label="Workbench readiness">
<div className="status-strip-header">
<div>
<p className="eyebrow">Platformstatus</p>
<h2>
<span className="sr-only">Status werkruimte: </span>
{coreReady ? 'Kaartwerkruimte is gebruiksklaar' : 'Kaartwerkruimte vraagt aandacht'}
</h2>
<p className="status-next-action">{nextAction(items)}</p>
</div>
<div className="status-readiness" data-ready={readyCount} aria-label={`${readyCount} van ${items.length} onderdelen gereed`}>
<span className="status-readiness-orbit" aria-hidden="true" />
<div className="status-readiness-copy">
<strong>{readyCount}/{items.length}</strong>
<span>onderdelen gereed</span>
</div>
</div>
</div>
<div className="status-strip-grid">
{items.map((item) => (
<div className={`status-tile status-tile-${item.state}`} key={item.key}>
<div className="status-tile-topline">
<span>{item.label}</span>
<span className="status-pill">{statusLabel(item.state)}</span>
</div>
<strong title={item.value}>{item.value}</strong>
<p>{item.detail}</p>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,151 @@
import type { ChangeDetectionSummary, DatasetCreateResponse } from '../../types'
interface ChangeDetectionPanelProps {
vectorDatasets: DatasetCreateResponse[]
sourceDatasetId: string
targetDatasetId: string
iouThreshold: number
includeUnchanged: boolean
running: boolean
result: ChangeDetectionSummary | null
error: string | null
onSourceDatasetChange: (value: string) => void
onTargetDatasetChange: (value: string) => void
onIouThresholdChange: (value: number) => void
onIncludeUnchangedChange: (value: boolean) => void
onRun: () => void
}
function datasetLabel(dataset: DatasetCreateResponse): string {
const role = dataset.dataset_role ? ` (${dataset.dataset_role})` : ''
return `${dataset.name}${role}`
}
export function ChangeDetectionPanel({
vectorDatasets,
sourceDatasetId,
targetDatasetId,
iouThreshold,
includeUnchanged,
running,
result,
error,
onSourceDatasetChange,
onTargetDatasetChange,
onIouThresholdChange,
onIncludeUnchangedChange,
onRun,
}: ChangeDetectionPanelProps): JSX.Element {
return (
<section className="panel change-detection-shell">
<div className="panel-header change-detection-heading">
<div>
<p className="eyebrow">Historische analyse</p>
<h2>Veranderingen vergelijken</h2>
</div>
<button disabled={running || vectorDatasets.length < 2} onClick={onRun} type="button">
{running ? 'Vergelijken...' : 'Kaartlagen vergelijken'}
</button>
</div>
<div className="change-detection-input-surface" aria-label="Instellingen voor veranderingsanalyse">
<div className="form-grid">
<label>
Eerdere kaartlaag
<select value={sourceDatasetId} onChange={(event) => onSourceDatasetChange(event.target.value)}>
<option value="">Kies de eerdere kaartlaag</option>
{vectorDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{datasetLabel(dataset)}
</option>
))}
</select>
</label>
<label>
Latere kaartlaag
<select value={targetDatasetId} onChange={(event) => onTargetDatasetChange(event.target.value)}>
<option value="">Kies de latere kaartlaag</option>
{vectorDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{datasetLabel(dataset)}
</option>
))}
</select>
</label>
<label>
Minimale geometrische overlap
<input
max="1"
min="0"
step="0.05"
type="number"
value={iouThreshold}
onChange={(event) => onIouThresholdChange(Number(event.target.value))}
/>
</label>
<label className="checkbox-row">
<input
checked={includeUnchanged}
type="checkbox"
onChange={(event) => onIncludeUnchangedChange(event.target.checked)}
/>
Ongewijzigde objecten opnemen
</label>
</div>
</div>
<div className="change-detection-state-stack">
{error ? (
<div className="result-state result-state-error">
<strong>De veranderingsanalyse is mislukt.</strong>
<p>{error}</p>
</div>
) : null}
{!error && vectorDatasets.length < 2 ? (
<div className="result-state result-state-empty">
<strong>Onvoldoende vergelijkbare kaartlagen</strong>
<p>Voeg minstens twee vectorlagen toe om periodes te vergelijken.</p>
</div>
) : null}
</div>
{result ? (
<div className="change-detection-result-surface" aria-label="Samenvatting veranderingsanalyse">
<div className="summary-grid">
<div>
<span className="metric">{result.added_count}</span>
<span>Nieuw</span>
</div>
<div>
<span className="metric">{result.removed_count}</span>
<span>Verdwenen</span>
</div>
<div>
<span className="metric">{result.modified_count ?? 0}</span>
<span>Gewijzigd</span>
</div>
<div>
<span className="metric">{result.unchanged_count}</span>
<span>Ongewijzigd</span>
</div>
<div>
<span className="metric">{result.geojson.features.length}</span>
<span>Kaartobjecten</span>
</div>
</div>
</div>
) : null}
{result?.warnings?.length ? (
<div className="change-detection-warning-surface">
<strong>Aandachtspunten</strong>
<ul className="compact-list">
{result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
) : null}
</section>
)
}
@@ -0,0 +1,200 @@
import { useState } from 'react'
import { useGeoAssistant } from '../../hooks/useGeoAssistant'
import { ModelSelector, type ModelSelectionOption } from '../models/ModelSelector'
import type { VectorSelectionBBox } from '../../types'
interface GeoAssistantPanelProps {
selectedProjectId: string | null
selectedAreaId: string | null
selectedAreaName: string | null
selectionBbox: VectorSelectionBBox | null
}
function friendlyModelName(name: string): string {
const base = name.split(':')[0].replace(/[-_]+/g, ' ')
return base.replace(/\b\w/g, (character) => character.toUpperCase())
}
function toAssistantModelOption(model: import('../../types').AssistantModelRead): ModelSelectionOption {
const parameterText = model.parameter_size?.toLowerCase() ?? ''
const parameterCount = Number.parseFloat(parameterText)
const tone = Number.isFinite(parameterCount) && parameterCount <= 9 ? 'fast' : 'analytical'
return {
id: model.name,
name: friendlyModelName(model.name),
description: model.parameter_size ? `Lokaal taalmodel van ${model.parameter_size}.` : 'Lokaal beschikbaar taalmodel.',
recommendation: tone === 'fast' ? 'Voor vlotte samenvattingen en gerichte gebiedsvragen.' : 'Voor uitgebreidere interpretatie van meerdere bronnen.',
status: 'available',
statusLabel: 'Beschikbaar',
tone,
provider: 'Ollama',
environment: 'Lokale uitvoering',
speed: tone === 'fast' ? 'Vlot' : undefined,
capability: tone === 'analytical' ? 'Grondig' : 'Gebalanceerd',
technicalName: model.name,
details: [model.parameter_size ? `Omvang: ${model.parameter_size}` : '', model.quantization_level ? `Quantisatie: ${model.quantization_level}` : ''].filter(Boolean),
}
}
const SUGGESTIONS = [
'Vat de belangrijkste gebiedsmetingen samen.',
'Hoe evolueerden bevolking en bosoppervlakte?',
'Welke gegevens ontbreken nog voor een volledige wateranalyse?',
'Welke officiële bronnen zijn voor dit gebied beschikbaar?',
]
export function GeoAssistantPanel({
selectedProjectId,
selectedAreaId,
selectedAreaName,
selectionBbox,
}: GeoAssistantPanelProps): JSX.Element {
const [question, setQuestion] = useState('')
const {
status,
models,
selectedModel,
selectedModelChoice,
defaultModel,
messages,
loading,
loadingModels,
error,
loadModels,
ask,
clear,
setSelectedModel,
} = useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox })
const submitQuestion = async (value: string) => {
if (await ask(value)) setQuestion('')
}
const scopeLabel = selectedAreaId
? selectedAreaName ?? 'Volledig geselecteerd gebied'
: selectionBbox
? 'Getekende kaartselectie'
: selectedAreaName ?? 'Volledig werkgebied'
const ready = Boolean(selectedProjectId && status?.reachable && selectedModel)
return (
<section className="workspace-panel geo-assistant-panel" data-testid="geo-assistant-panel">
<div className="panel-title-row">
<div>
<span className="section-kicker">Lokale AI · Ollama</span>
<h2>Vraag GeoIntel</h2>
<p className="muted">Stel vragen over de gemeten kaartgegevens en officiële tijdreeksen van het actieve gebied.</p>
</div>
<span className={status?.reachable ? 'status-badge status-badge-ready' : 'status-badge'}>
{status?.reachable ? 'Lokaal verbonden' : loadingModels ? 'Verbinden…' : 'Niet bereikbaar'}
</span>
</div>
<div className="assistant-context-strip">
<div>
<span>Context</span>
<strong>{scopeLabel}</strong>
</div>
<ModelSelector
label="AI-model"
value={selectedModelChoice}
options={models.map(toAssistantModelOption)}
automaticOption={defaultModel ? {
id: 'automatic',
name: 'Automatisch aanbevolen',
description: `GeoIntel gebruikt ${friendlyModelName(defaultModel)}, de beschikbare serverstandaard.`,
recommendation: 'Geschikt wanneer u geen technische modelkeuze wilt maken.',
status: 'available',
statusLabel: 'Aanbevolen',
tone: 'recommended',
environment: 'Lokale server',
} : undefined}
onChange={setSelectedModel}
loading={loadingModels}
error={error}
disabled={!status?.reachable}
onRefresh={() => void loadModels()}
advancedLabel="Geavanceerde modelkeuze"
/>
</div>
{!selectedProjectId ? (
<div className="result-state result-state-empty">
<strong>Geen werkgebied actief.</strong>
<p>Open eerst de regionale werkruimte of een project.</p>
</div>
) : null}
{status && !status.reachable ? (
<div className="result-state result-state-error">
<strong>Ollama is niet bereikbaar.</strong>
<p>{status.limitation_message}</p>
</div>
) : null}
<div className="assistant-suggestions" aria-label="Voorbeeldvragen">
{SUGGESTIONS.map((suggestion) => (
<button type="button" key={suggestion} onClick={() => void submitQuestion(suggestion)} disabled={!ready || loading}>
{suggestion}
</button>
))}
</div>
<div className="assistant-conversation" aria-live="polite">
{messages.length === 0 ? (
<div className="assistant-empty-state">
<strong>Begin bij een concrete gebiedsvraag</strong>
<p>GeoIntel stuurt alleen samengevatte, persistente GIS-metingen en broninformatie naar het lokale model.</p>
</div>
) : null}
{messages.map((message) => (
<article className={`assistant-message assistant-message-${message.role}`} key={message.id}>
<span>{message.role === 'user' ? 'Jij' : 'GeoIntel'}</span>
<p>{message.content}</p>
{message.response ? (
<details>
<summary>Gebruikte gegevens</summary>
<div className="assistant-evidence-grid">
<span>{message.response.context_metrics.length} metriek</span>
<span>{message.response.temporal_series.length} tijdreeksen</span>
<span>{message.response.source_dataset_ids.length} brondatasets</span>
<span>{message.response.scope_label}</span>
</div>
{message.response.warnings.map((warning) => <p className="geo-data-notice" key={warning}>{warning}</p>)}
</details>
) : null}
</article>
))}
{loading ? (
<div className="assistant-thinking" role="status">
<span />
<strong>Lokale gegevens worden samengevat en beantwoord…</strong>
</div>
) : null}
</div>
{error ? <p className="error">{error}</p> : null}
<form
className="assistant-composer"
onSubmit={(event) => {
event.preventDefault()
void submitQuestion(question)
}}
>
<label htmlFor="geo-assistant-question">Vraag over het actieve gebied</label>
<textarea
id="geo-assistant-question"
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="Bijvoorbeeld: hoeveel bos verdween er sinds 2013?"
maxLength={2000}
rows={3}
disabled={!ready || loading}
/>
<div>
<p>Antwoorden zijn lokaal gegenereerd. Controleer beslissingen altijd tegen de vermelde brondata.</p>
<button type="button" className="secondary-action" onClick={clear} disabled={messages.length === 0 || loading}>Wis gesprek</button>
<button type="submit" className="primary-action" disabled={!ready || loading || question.trim().length < 2}>Stel vraag</button>
</div>
</form>
</section>
)
}
@@ -0,0 +1,95 @@
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LandingPage } from './LandingPage'
import { login, loginAsGuest } from '../../services/api/auth'
vi.mock('../../services/api/auth', () => ({
login: vi.fn(),
loginAsGuest: vi.fn(),
}))
const operatorSession = {
authentication_required: true,
authenticated: true,
username: 'operator',
expires_at: '2026-07-27T20:00:00Z',
role: 'operator' as const,
guest_access_enabled: true,
authentik_enabled: false,
guest_project_id: null,
}
const guestSession = {
authentication_required: true,
authenticated: true,
username: 'Gast',
expires_at: '2026-07-27T20:00:00Z',
role: 'guest' as const,
guest_access_enabled: true,
authentik_enabled: false,
guest_project_id: '00000000-0000-0000-0000-000000000123',
}
describe('LandingPage', () => {
beforeEach(() => {
vi.mocked(login).mockReset()
vi.mocked(loginAsGuest).mockReset()
})
afterEach(() => cleanup())
it('presents the professionalised landing page and submits the operator login', async () => {
const onAuthenticated = vi.fn()
vi.mocked(login).mockResolvedValue(operatorSession)
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
expect(screen.getByRole('heading', { name: /Van kaartlaag naar aantoonbaar inzicht/i })).toBeTruthy()
expect(screen.getByRole('img', { name: 'ITWorx.tech' })).toBeTruthy()
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: ' operator ' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
expect(onAuthenticated).toHaveBeenCalledWith(operatorSession)
})
it('opens a guest session without asking for credentials', async () => {
const onAuthenticated = vi.fn()
vi.mocked(loginAsGuest).mockResolvedValue(guestSession)
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
fireEvent.click(screen.getByRole('button', { name: /Als gast verkennen/i }))
await waitFor(() => expect(loginAsGuest).toHaveBeenCalledOnce())
expect(login).not.toHaveBeenCalled()
expect(onAuthenticated).toHaveBeenCalledWith(guestSession)
})
it('does not advertise guest access when the runtime has it disabled', () => {
render(<LandingPage onAuthenticated={vi.fn()} guestAccessEnabled={false} />)
expect(screen.queryByRole('button', { name: /gast/i })).toBeNull()
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
})
it('offers Authentik without removing the local operator recovery login', () => {
render(<LandingPage onAuthenticated={vi.fn()} authentikEnabled />)
const authentik = screen.getByRole('link', { name: 'Aanmelden met Authentik' })
expect(authentik.getAttribute('href')).toBe('/api/v1/auth/authentik/start')
expect(screen.getByLabelText('Gebruikersnaam')).toBeTruthy()
expect(screen.getByLabelText('Wachtwoord')).toBeTruthy()
})
it('surfaces a useful authentication error without entering the workbench', async () => {
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
render(<LandingPage onAuthenticated={vi.fn()} />)
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'wrong' } })
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
})
})
@@ -0,0 +1,426 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
ArrowRight,
BrainCircuit,
Check,
ChevronRight,
Database,
Eye,
EyeOff,
LockKeyhole,
LogIn,
MapPinned,
Menu,
MousePointer2,
ShieldCheck,
Sparkles,
UserRound,
X,
} from 'lucide-react'
import { login, loginAsGuest, type AuthSession } from '../../services/api/auth'
import { formatAuthError } from '../../lib/authError'
import '../../styles/landing.css'
import { GeoIntelMark } from '../brand/GeoIntelBrand'
import { ItWorxSignature } from '../brand/ItWorxSignature'
import { LandingProjectStory } from './LandingProjectStory'
interface LandingPageProps {
onAuthenticated: (session: AuthSession) => void
serviceError?: string | null
guestAccessEnabled?: boolean
authentikEnabled?: boolean
}
const capabilityItems = [
{
icon: MapPinned,
title: 'Eén kaartgerichte werkruimte',
description:
'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.',
},
{
icon: Database,
title: 'Bronnen blijven herkenbaar',
description:
'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.',
},
{
icon: ShieldCheck,
title: 'Kwaliteit vóór resultaat',
description:
'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.',
},
]
const workflowSteps = [
['Selecteer', 'Kies een officiële grens of teken zelf een gebied.'],
['Controleer', 'Bekijk dekking, bronkwaliteit en toepasselijke beperkingen.'],
['Analyseer', 'Meet toestand, evolutie of modelresultaten op de kaart.'],
['Onderbouw', 'Bewaar controleerbare resultaten en provenance.'],
]
export function LandingPage({
onAuthenticated,
serviceError = null,
guestAccessEnabled = false,
authentikEnabled = false,
}: LandingPageProps): JSX.Element {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null)
const [authError, setAuthError] = useState<string | null>(null)
const [attempted, setAttempted] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const usernameRef = useRef<HTMLInputElement | null>(null)
const accessPanelRef = useRef<HTMLElement | null>(null)
const busy = pendingAction !== null
useEffect(() => {
document.body.classList.add('landing-body')
return () => document.body.classList.remove('landing-body')
}, [])
useEffect(() => {
const query = new URLSearchParams(window.location.search)
if (query.get('authentik') !== 'error') return
setAttempted(true)
setAuthError('Aanmelden via Authentik is niet gelukt. Probeer opnieuw of gebruik de lokale operatorlogin.')
query.delete('authentik')
const suffix = query.toString()
window.history.replaceState(null, '', `${window.location.pathname}${suffix ? `?${suffix}` : ''}${window.location.hash}`)
}, [])
const scrollAccessPanelIntoView = () => {
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
accessPanelRef.current.scrollIntoView({
behavior: reducedMotion ? 'auto' : 'smooth',
block: 'center',
})
}
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setPendingAction('operator')
setAttempted(true)
setAuthError(null)
try {
const session = await login(username.trim(), password)
onAuthenticated(session)
} catch (error) {
setAuthError(formatAuthError(error, 'Aanmelden is momenteel niet gelukt. Controleer de verbinding en probeer opnieuw.'))
} finally {
setPendingAction(null)
}
}
const submitGuestLogin = async () => {
setMenuOpen(false)
setPendingAction('guest')
setAttempted(true)
setAuthError(null)
scrollAccessPanelIntoView()
try {
const session = await loginAsGuest()
onAuthenticated(session)
} catch (error) {
setAuthError(formatAuthError(error, 'De gastdemo kon niet worden voorbereid. Probeer het over enkele ogenblikken opnieuw.'))
} finally {
setPendingAction(null)
}
}
const focusLogin = () => {
setMenuOpen(false)
scrollAccessPanelIntoView()
window.requestAnimationFrame(() => usernameRef.current?.focus())
}
return (
<div className="landing-page">
<a className="landing-skip-link" href="#login-panel">Ga naar toegang</a>
<header className="landing-header">
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
<GeoIntelMark className="landing-brand-mark" />
<span className="landing-brand-copy">
<strong>GeoIntel</strong>
</span>
</a>
<button
className="landing-menu-toggle"
type="button"
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
aria-expanded={menuOpen}
aria-controls="landing-navigation"
onClick={() => setMenuOpen((current) => !current)}
>
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
</button>
<nav
id="landing-navigation"
className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'}
aria-label="Landingspagina"
>
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Mogelijkheden</a>
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Werkproces</a>
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
</nav>
<div className="landing-header-actions">
{guestAccessEnabled ? (
<button className="landing-header-guest" type="button" onClick={submitGuestLogin} disabled={busy}>
Gastdemo
</button>
) : null}
<button className="landing-header-login" type="button" onClick={focusLogin} disabled={busy}>
Inloggen
</button>
</div>
</header>
<main id="top">
<section className="landing-hero" aria-labelledby="landing-title">
<div className="landing-hero-orbit landing-hero-orbit-one" aria-hidden="true" />
<div className="landing-hero-orbit landing-hero-orbit-two" aria-hidden="true" />
<div className="landing-hero-content">
<div className="landing-hero-copy">
<p className="landing-kicker">
<Sparkles aria-hidden="true" /> GeoAI-workbench voor België en de Noordzee
</p>
<h1 id="landing-title">
Van kaartlaag naar <span>aantoonbaar inzicht.</span>
</h1>
<p className="landing-lead">
GeoIntel brengt officiële bronnen, ruimtelijke analyse, AI-resultaten en kwaliteitscontrole samen in één professionele werkomgeving. Niet alleen zien wat er op de kaart staat, maar ook weten waarop het resultaat steunt.
</p>
<div className="landing-hero-actions">
{guestAccessEnabled ? (
<button className="landing-primary-action" type="button" onClick={submitGuestLogin} disabled={busy}>
<UserRound aria-hidden="true" />
{pendingAction === 'guest' ? 'Gastdemo voorbereiden…' : 'Verkennen als gast'}
</button>
) : (
<button className="landing-primary-action" type="button" onClick={focusLogin}>
<LogIn aria-hidden="true" /> Open de workbench
</button>
)}
<a className="landing-secondary-action" href="#mogelijkheden">
Bekijk mogelijkheden <ArrowRight aria-hidden="true" />
</a>
</div>
<div className="landing-proof-row" aria-label="Kernkwaliteiten">
<div><Check aria-hidden="true" /><span><strong>Officiële bronnen</strong><small>per rechtsgebied</small></span></div>
<div><Check aria-hidden="true" /><span><strong>Meetbare kwaliteit</strong><small>vóór export</small></span></div>
<div><Check aria-hidden="true" /><span><strong>Volledige provenance</strong><small>bij ieder resultaat</small></span></div>
</div>
</div>
<section
className="landing-access-card"
id="login-panel"
ref={accessPanelRef}
aria-labelledby="access-title"
>
<div className="landing-access-preview" aria-hidden="true">
<div className="landing-preview-toolbar">
<span><i /> Live werkcontext</span>
<span>België · Noordzee</span>
</div>
<div className="landing-preview-focus">
<span className="landing-preview-pin"><MapPinned /></span>
<div>
<small>Actieve analyse</small>
<strong>Gebouwen &amp; referentiedata</strong>
<span>Brondekking gecontroleerd</span>
</div>
</div>
<div className="landing-preview-metrics">
<span><strong>4</strong><small>rechtsgebieden</small></span>
<span><strong>QA</strong><small>bewijs zichtbaar</small></span>
<span><strong>GIS</strong><small>exporteerbaar</small></span>
</div>
</div>
<div className="landing-access-body">
<div className="landing-access-heading">
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
<div>
<p>Veilige toegang</p>
<h2 id="access-title">Open de GeoIntel-workbench</h2>
<span>Meld u aan als operator of start een projectgebonden demosessie met volledige analysetoegang.</span>
</div>
</div>
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
{authentikEnabled ? (
<a className="landing-authentik-submit" href="/api/v1/auth/authentik/start">
<ShieldCheck aria-hidden="true" />
Aanmelden met Authentik
</a>
) : null}
{authentikEnabled ? (
<div className="landing-access-divider">
<span>of met lokale operatorgegevens</span>
</div>
) : null}
<label htmlFor="login-username">Gebruikersnaam</label>
<input
ref={usernameRef}
id="login-username"
name="username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={busy}
required
/>
<label htmlFor="login-password">Wachtwoord</label>
<div className="landing-password-field">
<input
id="login-password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={busy}
required
/>
<button
type="button"
aria-label={showPassword ? 'Wachtwoord verbergen' : 'Wachtwoord tonen'}
aria-pressed={showPassword}
onClick={() => setShowPassword((current) => !current)}
disabled={busy}
>
{showPassword ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
</button>
</div>
{authError || (serviceError && attempted) ? (
<p className="landing-login-error" role="alert">{authError ?? serviceError}</p>
) : null}
<button
className="landing-operator-submit"
type="submit"
disabled={busy || !username.trim() || !password}
>
<LogIn aria-hidden="true" />
{pendingAction === 'operator' ? 'Veilig aanmelden…' : 'Inloggen als operator'}
</button>
{/* De storingsmelding stond als rood blok midden in de hero,
voordat de bezoeker iets gedaan had. Zolang er nog niets
geprobeerd is, is het een mededeling en geen fout. */}
{serviceError && !attempted && !authError ? (
<p className="landing-login-note">{serviceError}</p>
) : null}
</form>
{guestAccessEnabled ? (
<div className="landing-access-divider"><span>of verken zonder account</span></div>
) : null}
{guestAccessEnabled ? (
<div className="landing-guest-access">
<span className="landing-guest-icon" aria-hidden="true"><UserRound /></span>
<div>
<strong>Gastmodus</strong>
<p>Gebruik dezelfde modellen, analyses en downloads als een gebruiker. Alleen beheer, instellingen en uploads blijven afgeschermd.</p>
</div>
<button type="button" onClick={submitGuestLogin} disabled={busy}>
{pendingAction === 'guest' ? 'Demo voorbereiden…' : 'Als gast verkennen'}
<ChevronRight aria-hidden="true" />
</button>
</div>
) : null}
<p className="landing-session-note">
<ShieldCheck aria-hidden="true" /> HttpOnly-sessie · geen wachtwoordopslag in de browser
</p>
</div>
</section>
</div>
</section>
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
<div className="landing-section-heading">
<p>Gebouwd voor controleerbare analyse</p>
<h2 id="capabilities-title">Geen los dashboard, maar één samenhangende GIS-keten</h2>
<span>De interface volgt de manier waarop een onderbouwde ruimtelijke analyse werkelijk tot stand komt.</span>
</div>
<div className="landing-capability-grid">
{capabilityItems.map(({ icon: Icon, title, description }) => (
<article key={title} className="landing-capability">
<div className="landing-capability-topline">
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
</div>
<h3>{title}</h3>
<p>{description}</p>
</article>
))}
</div>
</section>
<LandingProjectStory />
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
<div className="landing-workflow-visual">
<div className="landing-workflow-map-image" aria-hidden="true" />
<div className="landing-workflow-overlay">
<p>Kaart als werkomgeving</p>
<h2 id="workflow-title">Van selectie naar een resultaat dat u kunt verdedigen</h2>
<span>Bron, gebied, meetmoment en kwaliteitsbewijs blijven samen zichtbaar.</span>
</div>
<div className="landing-workflow-floating" aria-hidden="true">
<MousePointer2 />
<span><small>Selectie gecontroleerd</small><strong>2,14 km² · 186 objecten</strong></span>
</div>
</div>
<div className="landing-workflow-content">
<p className="landing-workflow-eyebrow">Een helder werkproces</p>
<h2>Vier stappen, zonder de technische context te verliezen</h2>
<div className="landing-workflow-steps">
{workflowSteps.map(([title, description], index) => (
<article key={title}>
<span>{String(index + 1).padStart(2, '0')}</span>
<div><h3>{title}</h3><p>{description}</p></div>
</article>
))}
</div>
<div className="landing-quality-callout" id="kwaliteit">
<BrainCircuit aria-hidden="true" />
<div>
<strong>AI blijft een gecontroleerde analysemethode</strong>
<p>Modelversie, confidence, referentiedata en validatiegrenzen blijven zichtbaar; ontbrekende configuratie wordt niet als succes voorgesteld.</p>
</div>
</div>
</div>
</section>
</main>
<footer className="landing-footer">
<div className="landing-footer-brand">
<GeoIntelMark className="landing-footer-mark" />
<div><strong>GeoIntel</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
</div>
<div className="landing-footer-ownership">
<ItWorxSignature />
<p>© {new Date().getFullYear()} GeoIntel · Professionele operator- en demonstratieomgeving</p>
</div>
</footer>
</div>
)
}
@@ -0,0 +1,28 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { LandingProjectStory } from './LandingProjectStory'
afterEach(cleanup)
describe('LandingProjectStory', () => {
it('moves through the evidence-first project chain', () => {
render(<LandingProjectStory />)
expect(screen.getByRole('tabpanel').textContent).toContain('Teken één ruimtelijke vraag')
fireEvent.click(screen.getByRole('tab', { name: '03 Analyseer' }))
expect(screen.getByRole('tab', { name: '03 Analyseer' }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('tabpanel').textContent).toContain('Laat GIS en PyTorch samenwerken')
expect(screen.getByRole('tabpanel').textContent).toContain('186')
})
it('supports arrow-key navigation between stages', () => {
render(<LandingProjectStory />)
fireEvent.keyDown(screen.getByRole('tab', { name: '01 Selecteer' }), { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: '02 Bronnen' }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('tabpanel').textContent).toContain('Hou herkomst en dekking zichtbaar')
})
})
@@ -0,0 +1,153 @@
import { useState } from 'react'
import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react'
import '../../styles/landing-project-story.css'
import { useDeferredBackground } from '../../hooks/useDeferredBackground'
const stages = [
{
key: 'select',
number: '01',
label: 'Selecteer',
title: 'Teken één ruimtelijke vraag',
description: 'Een officiële grens of vrije AOI wordt de vaste context voor elke volgende stap.',
metric: '2,14 km²',
metricLabel: 'geselecteerd',
icon: MapPinned,
},
{
key: 'source',
number: '02',
label: 'Bronnen',
title: 'Hou herkomst en dekking zichtbaar',
description: 'Bronautoriteit, CRS, meetmoment en lokale dekking blijven aan ieder resultaat gekoppeld.',
metric: '7',
metricLabel: 'bronnen gecontroleerd',
icon: Database,
},
{
key: 'analyse',
number: '03',
label: 'Analyseer',
title: 'Laat GIS en PyTorch samenwerken',
description: 'Ruimtelijke selectie, beeldanalyse en regionale context komen samen zonder ontbrekende AI-configuratie te verbergen.',
metric: '186',
metricLabel: 'objecten gevonden',
icon: ScanSearch,
},
{
key: 'prove',
number: '04',
label: 'Onderbouw',
title: 'Controleer bewijs vóór export',
description: 'QA/QC, uitzonderingen en provenance maken het resultaat navolgbaar en verdedigbaar.',
metric: '0,91',
metricLabel: 'kwaliteitsscore',
icon: BadgeCheck,
},
] as const
export function LandingProjectStory(): JSX.Element {
const kaartRef = useDeferredBackground<HTMLDivElement>()
const [activeIndex, setActiveIndex] = useState(0)
const activeStage = stages[activeIndex]
return (
<section className="landing-project-story" aria-labelledby="project-story-title">
<div className="landing-project-story-copy">
<p className="landing-workflow-eyebrow">Interactieve projectillustratie</p>
<h2 id="project-story-title">Van gebied naar resultaat, zonder de bewijsvoering kwijt te raken</h2>
<p className="landing-project-story-intro">
Verken de vier schakels van de GeoIntel-keten. Elke stap verandert de kaart en toont welke context behouden blijft.
</p>
<div className="landing-story-tabs" role="tablist" aria-label="GeoIntel-projectketen">
{stages.map(({ key, number, label, icon: Icon }, index) => (
<button
key={key}
id={`story-tab-${key}`}
type="button"
role="tab"
aria-selected={activeIndex === index}
aria-controls="story-stage-panel"
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
setActiveIndex((current) => (current + direction + stages.length) % stages.length)
}}
>
<span>{number}</span>
<Icon aria-hidden="true" />
<strong>{label}</strong>
</button>
))}
</div>
<div
className="landing-story-detail"
id="story-stage-panel"
role="tabpanel"
aria-live="polite"
aria-labelledby={`story-tab-${activeStage.key}`}
key={activeStage.key}
>
<div>
<small>Actieve schakel</small>
<h3>{activeStage.title}</h3>
<p>{activeStage.description}</p>
</div>
<span className="landing-story-metric">
<strong>{activeStage.metric}</strong>
<small>{activeStage.metricLabel}</small>
</span>
</div>
</div>
<div className={`landing-story-visual is-stage-${activeIndex + 1}`} aria-hidden="true">
<div className="landing-story-map" ref={kaartRef} />
<svg viewBox="0 0 720 610" role="presentation">
<defs>
<linearGradient id="story-area" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8ee7d2" stopOpacity=".34" />
<stop offset="1" stopColor="#23a68e" stopOpacity=".1" />
</linearGradient>
<filter id="story-glow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="9" />
</filter>
</defs>
<g className="story-tile-grid">
<path d="M0 122H720M0 244H720M0 366H720M0 488H720" />
<path d="M144 0V610M288 0V610M432 0V610M576 0V610" />
</g>
<path className="story-aoi" d="M210 152 493 123 582 284 515 473 258 504 139 319Z" fill="url(#story-area)" />
<path className="story-route" d="M157 419C231 345 236 223 339 241s121 126 219 45" />
<g className="story-buildings">
<path d="m260 286 48-15 21 68-49 15Z" />
<path d="m340 207 69-11 10 57-67 13Z" />
<path d="m392 330 74-23 28 88-76 24Z" />
<path d="m220 376 52-18 19 55-51 19Z" />
</g>
<g className="story-evidence">
<circle cx="307" cy="314" r="8" />
<circle cx="402" cy="224" r="8" />
<circle cx="445" cy="365" r="8" />
<circle cx="255" cy="396" r="8" />
</g>
<g className="story-pulse">
<circle cx="445" cy="365" r="27" filter="url(#story-glow)" />
<circle cx="445" cy="365" r="12" />
</g>
<path className="story-scan" d="M142 219H581" />
</svg>
<div className="landing-story-status">
<span><i /> Live analyse</span>
<strong>{activeStage.label}</strong>
<small>{activeStage.metricLabel}</small>
</div>
<div className="landing-story-export"><Download /><span>Controleerbaar resultaat</span></div>
</div>
</section>
)
}
@@ -0,0 +1,14 @@
interface GeoIntelMarkProps {
className?: string
}
export function GeoIntelMark({ className = '' }: GeoIntelMarkProps): JSX.Element {
return (
<img
className={`geointel-mark ${className}`.trim()}
src="/geointel-icon.svg"
alt=""
aria-hidden="true"
/>
)
}
@@ -0,0 +1,8 @@
export function ItWorxSignature(): JSX.Element {
return (
<div className="itworx-signature" aria-label="Ontwikkeld door Jens van ITWorx.tech">
<span>Ontwikkeld door Jens</span>
<img src="/itworx-wordmark.webp" alt="ITWorx.tech" />
</div>
)
}
@@ -0,0 +1,273 @@
import type {
AreaRead,
DatasetCreateResponse,
JobRead,
RasterMetadataResponse,
RasterPreviewResponse,
RasterStatsResponse,
RasterTileHandoff,
VectorSummary,
} from '../../types'
import { RasterControls } from './RasterControls'
import { VectorControls } from './VectorControls'
export interface DatasetDetailPanelProps {
areas: AreaRead[]
availableVectorTargets: DatasetCreateResponse[]
selectedDatasetId: string | null
selectedDataset: DatasetCreateResponse | null
selectedDatasetSummary: VectorSummary | null
selectedRasterMetadata: RasterMetadataResponse | null
selectedRasterStats: RasterStatsResponse | null
rasterPreview: RasterPreviewResponse | null
latestRasterTileManifestPath: string
latestRasterTileManifest: RasterTileHandoff | null
rasterUnavailableMessage: string | null
selectedClipAreaId: string
selectedIntersectTargetId: string
rasterTileSize: number
rasterTileOverlap: number
rasterTileOutputName: string
rasterReprojectCrs: string
rasterReprojectResampling: string
ndviNirBand: number
ndviRedBand: number
ndwiGreenBand: number
ndwiNirBand: number
ndbiSwirBand: number
ndbiNirBand: number
isRasterTileInputValid: boolean
jobs: JobRead[]
loadingDatasetDetails: boolean
datasetDetailError: string | null
isVectorDatasetType: (datasetType: string) => boolean
onSetSelectedClipAreaId: (value: string) => void
onSetSelectedIntersectTargetId: (value: string) => void
onSetRasterTileSize: (value: number) => void
onSetRasterTileOverlap: (value: number) => void
onSetRasterTileOutputName: (value: string) => void
onSetRasterReprojectCrs: (value: string) => void
onSetRasterReprojectResampling: (value: string) => void
onSetNdviNirBand: (value: number) => void
onSetNdviRedBand: (value: number) => void
onSetNdwiGreenBand: (value: number) => void
onSetNdwiNirBand: (value: number) => void
onSetNdbiSwirBand: (value: number) => void
onSetNdbiNirBand: (value: number) => void
onRunRasterInspect: () => void
onRunRasterPreview: () => void
onRunRasterStats: () => void
onRunRasterReproject: () => void
onRunRasterClip: () => void
onRunRasterTile: () => void
onUseTileManifestForDetection: () => void
onUseTileManifestForSegmentation: () => void
onRunRasterNdvi: () => void
onRunRasterNdwi: () => void
onRunRasterNdbi: () => void
onRunVectorClip: () => void
onRunVectorBuffer: () => void
onRunVectorIntersect: () => void
onPickDerivedDataset: (datasetId: string) => void
}
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n.v.t.'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n.v.t.'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n.v.t.'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
export function DatasetDetailPanel({
areas,
availableVectorTargets,
selectedDatasetId,
selectedDataset,
selectedDatasetSummary,
selectedRasterMetadata,
selectedRasterStats,
rasterPreview,
latestRasterTileManifestPath,
latestRasterTileManifest,
rasterUnavailableMessage,
selectedClipAreaId,
selectedIntersectTargetId,
rasterTileSize,
rasterTileOverlap,
rasterTileOutputName,
rasterReprojectCrs,
rasterReprojectResampling,
ndviNirBand,
ndviRedBand,
ndwiGreenBand,
ndwiNirBand,
ndbiSwirBand,
ndbiNirBand,
isRasterTileInputValid,
jobs,
loadingDatasetDetails,
datasetDetailError,
isVectorDatasetType,
onSetSelectedClipAreaId,
onSetSelectedIntersectTargetId,
onSetRasterTileSize,
onSetRasterTileOverlap,
onSetRasterTileOutputName,
onSetRasterReprojectCrs,
onSetRasterReprojectResampling,
onSetNdviNirBand,
onSetNdviRedBand,
onSetNdwiGreenBand,
onSetNdwiNirBand,
onSetNdbiSwirBand,
onSetNdbiNirBand,
onRunRasterInspect,
onRunRasterPreview,
onRunRasterStats,
onRunRasterReproject,
onRunRasterClip,
onRunRasterTile,
onUseTileManifestForDetection,
onUseTileManifestForSegmentation,
onRunRasterNdvi,
onRunRasterNdwi,
onRunRasterNdbi,
onRunVectorClip,
onRunVectorBuffer,
onRunVectorIntersect,
onPickDerivedDataset,
}: DatasetDetailPanelProps) {
return (
<section className="dataset-detail-panel">
<h2>Details van de databron</h2>
{!selectedDatasetId ? <p>Geen databron geselecteerd.</p> : null}
{selectedDataset ? (
<div>
<p>
<strong>{selectedDataset.name}</strong>
</p>
<p>Type: {selectedDataset.dataset_type}</p>
<p>Status: {selectedDataset.status}</p>
<p>Aantal objecten: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n.v.t.'}</p>
<p>Ruimtelijke begrenzing: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
<details className="technical-inline-details">
<summary>Technische bestandsgegevens</summary>
<div className="entity-meta">
<span>Dataset-ID: {selectedDataset.id}</span>
<span>Oorspronkelijk bestand: {selectedDataset.original_filename ?? 'n.v.t.'}</span>
<span>Bewaard bestand: {selectedDataset.stored_filename ?? 'n.v.t.'}</span>
<span>Inhoudstype: {selectedDataset.content_type ?? 'n.v.t.'}</span>
<span>Bestandsgrootte: {formatBytes(selectedDataset.size_bytes)}</span>
<span>SHA-256: {selectedDataset.checksum_sha256 ?? 'n.v.t.'}</span>
</div>
</details>
{selectedDataset.dataset_type === 'raster' ? (
<RasterControls
areas={areas}
selectedDatasetId={selectedDatasetId}
selectedRasterMetadata={selectedRasterMetadata}
selectedRasterStats={selectedRasterStats}
rasterPreview={rasterPreview}
latestRasterTileManifestPath={latestRasterTileManifestPath}
latestRasterTileManifest={latestRasterTileManifest}
rasterUnavailableMessage={rasterUnavailableMessage}
selectedClipAreaId={selectedClipAreaId}
rasterTileSize={rasterTileSize}
rasterTileOverlap={rasterTileOverlap}
rasterTileOutputName={rasterTileOutputName}
rasterReprojectCrs={rasterReprojectCrs}
rasterReprojectResampling={rasterReprojectResampling}
ndviNirBand={ndviNirBand}
ndviRedBand={ndviRedBand}
ndwiGreenBand={ndwiGreenBand}
ndwiNirBand={ndwiNirBand}
ndbiSwirBand={ndbiSwirBand}
ndbiNirBand={ndbiNirBand}
isRasterTileInputValid={isRasterTileInputValid}
onSetSelectedClipAreaId={onSetSelectedClipAreaId}
onSetRasterTileSize={onSetRasterTileSize}
onSetRasterTileOverlap={onSetRasterTileOverlap}
onSetRasterTileOutputName={onSetRasterTileOutputName}
onSetRasterReprojectCrs={onSetRasterReprojectCrs}
onSetRasterReprojectResampling={onSetRasterReprojectResampling}
onSetNdviNirBand={onSetNdviNirBand}
onSetNdviRedBand={onSetNdviRedBand}
onSetNdwiGreenBand={onSetNdwiGreenBand}
onSetNdwiNirBand={onSetNdwiNirBand}
onSetNdbiSwirBand={onSetNdbiSwirBand}
onSetNdbiNirBand={onSetNdbiNirBand}
onRunRasterInspect={onRunRasterInspect}
onRunRasterPreview={onRunRasterPreview}
onRunRasterStats={onRunRasterStats}
onRunRasterReproject={onRunRasterReproject}
onRunRasterClip={onRunRasterClip}
onRunRasterTile={onRunRasterTile}
onUseTileManifestForDetection={onUseTileManifestForDetection}
onUseTileManifestForSegmentation={onUseTileManifestForSegmentation}
onRunRasterNdvi={onRunRasterNdvi}
onRunRasterNdwi={onRunRasterNdwi}
onRunRasterNdbi={onRunRasterNdbi}
/>
) : null}
{isVectorDatasetType(selectedDataset.dataset_type) ? (
<VectorControls
areas={areas}
availableVectorTargets={availableVectorTargets}
selectedClipAreaId={selectedClipAreaId}
selectedIntersectTargetId={selectedIntersectTargetId}
onSetSelectedClipAreaId={onSetSelectedClipAreaId}
onSetSelectedIntersectTargetId={onSetSelectedIntersectTargetId}
onRunVectorClip={onRunVectorClip}
onRunVectorBuffer={onRunVectorBuffer}
onRunVectorIntersect={onRunVectorIntersect}
/>
) : null}
<details className="technical-inline-details">
<summary>Technische verwerking ({jobs.length})</summary>
{jobs.length === 0 ? <p>Nog geen verwerkingen.</p> : null}
<ul>
{jobs.map((job) => (
<li key={job.id}>
<div>
{job.job_type} - {job.status}
</div>
{job.result_json ? (
<pre className="job-result">{JSON.stringify(job.result_json, null, 2)}</pre>
) : null}
{job.error_message ? <div className="error">Fout: {job.error_message}</div> : null}
{job.result_json?.output_dataset_id ? (
<button type="button" onClick={() => onPickDerivedDataset(String(job.result_json?.output_dataset_id))}>
Open afgeleide databron
</button>
) : null}
</li>
))}
</ul>
</details>
</div>
) : null}
{loadingDatasetDetails ? <p>Details van de databron laden...</p> : null}
{datasetDetailError ? <p className="error">De details konden niet worden geladen: {datasetDetailError}</p> : null}
</section>
)
}
@@ -0,0 +1,436 @@
import { useEffect, useMemo, useState, type Dispatch, type FormEvent, type SetStateAction } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../../types'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
const DATASET_CATALOG_PAGE_SIZE = 10
interface DatasetFormState {
datasetType: string
source: string
datasetRole: string
sourceName: string
referenceLayerName: string
sourceMetadataJson: string
provenanceMetadataJson: string
areaId: string
file: File | null
}
interface DatasetPanelProps {
selectedProjectId: string | null
selectedDatasetId: string | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
datasetForm: DatasetFormState
loadingDatasets: boolean
onDatasetFormChange: Dispatch<SetStateAction<DatasetFormState>>
onUploadDataset: (event: FormEvent) => void
onLoadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => void
onOpenInspector: () => void
onRefreshMetadata: (datasetId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onOpenDatasetExport: (dataset: DatasetCreateResponse) => void
}
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n.v.t.'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n.v.t.'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n.v.t.'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
function normalizeDatasetRole(dataset: DatasetCreateResponse): 'reference' | 'candidate' | 'source' {
if (dataset.dataset_role === 'reference') {
return 'reference'
}
if (dataset.dataset_role === 'source') {
return 'source'
}
if (dataset.dataset_role === 'derived' || isVectorDatasetType(dataset.dataset_type)) {
return 'candidate'
}
return 'source'
}
function datasetRoleLabel(role: string): string {
if (role === 'reference') {
return 'Officiële referentie'
}
if (role === 'candidate') {
return 'Analyse-resultaat'
}
return 'Basisbron'
}
function datasetActionHint(dataset: DatasetCreateResponse, role: string): string {
if (role === 'reference') {
return 'Officiële laag voor kaartanalyse en kwaliteitscontrole.'
}
if (role === 'candidate') {
return 'Bewaard resultaat dat opnieuw op de kaart, in kwaliteit of als download kan worden gebruikt.'
}
if (dataset.dataset_type === 'raster') {
return 'Luchtbeeld of rasterbron voor kaart- en beeldanalyse.'
}
return 'Ingeladen gegevensbron voor verdere analyse.'
}
function formatObservationDate(value: string | null | undefined): string {
if (!value) return 'huidige toestand'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('nl-BE', { day: 'numeric', month: 'short', year: 'numeric' }).format(date)
}
function datasetKindLabel(dataset: DatasetCreateResponse): string {
if (dataset.dataset_type === 'raster') return 'Luchtbeeld'
if (isVectorDatasetType(dataset.dataset_type)) return 'Kaartlaag'
return 'Gegevensbron'
}
export function DatasetPanel({
selectedProjectId,
selectedDatasetId,
areas,
datasets,
datasetForm,
loadingDatasets,
onDatasetFormChange,
onUploadDataset,
onLoadDatasetDetails,
onOpenInspector,
onRefreshMetadata,
onOpenDatasetInMap,
onOpenDatasetExport,
}: DatasetPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [page, setPage] = useState(1)
const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId)
const latestDatasetBySeries = new Map<string, DatasetCreateResponse>()
datasets.forEach((dataset) => {
if (!dataset.temporal_series_key) return
const current = latestDatasetBySeries.get(dataset.temporal_series_key)
const currentTimestamp = current?.observed_at ? Date.parse(current.observed_at) : Number.NEGATIVE_INFINITY
const candidateTimestamp = dataset.observed_at ? Date.parse(dataset.observed_at) : Number.NEGATIVE_INFINITY
if (!current || candidateTimestamp > currentTimestamp) {
latestDatasetBySeries.set(dataset.temporal_series_key, dataset)
}
})
const primaryDatasets = datasets.filter(
(dataset) => !dataset.temporal_series_key || latestDatasetBySeries.get(dataset.temporal_series_key)?.id === dataset.id,
)
const historicalDatasets = datasets.filter(
(dataset) => dataset.temporal_series_key && latestDatasetBySeries.get(dataset.temporal_series_key)?.id !== dataset.id,
)
const filteredPrimaryDatasets = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase('nl-BE')
if (!query) return primaryDatasets
return primaryDatasets.filter((dataset) => {
const searchable = [
getDatasetDisplayName(dataset),
getDatasetSourceDisplayName(dataset),
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
String(dataset.source_metadata?.['municipality'] ?? ''),
datasetRoleLabel(normalizeDatasetRole(dataset)),
]
return searchable.some((value) => String(value ?? '').toLocaleLowerCase('nl-BE').includes(query))
})
}, [primaryDatasets, searchQuery])
const pageCount = Math.max(1, Math.ceil(filteredPrimaryDatasets.length / DATASET_CATALOG_PAGE_SIZE))
const visiblePrimaryDatasets = filteredPrimaryDatasets.slice(
(page - 1) * DATASET_CATALOG_PAGE_SIZE,
page * DATASET_CATALOG_PAGE_SIZE,
)
const readyDatasets = primaryDatasets.filter((dataset) => dataset.status === 'ready').length
const roleSummaries = [
{
key: 'selected',
label: 'Geselecteerd',
count: selectedDatasetId ? 1 : 0,
className: 'dataset-role-selected',
},
{
key: 'reference',
label: 'Referentie',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
className: 'dataset-role-reference',
},
{
key: 'candidate',
label: 'Resultaat',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
className: 'dataset-role-candidate',
},
{
key: 'source',
label: 'Basisbron',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
className: 'dataset-role-source',
},
]
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
useEffect(() => {
setSearchQuery('')
setPage(1)
setHistoryOpen(false)
}, [selectedProjectId])
return (
<section className="workspace-panel" data-testid="dataset-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Ingeladen gegevens</p>
<h2>Bronnen</h2>
</div>
<span className="count-pill">{readyDatasets}/{primaryDatasets.length} bronnen beschikbaar</span>
</div>
<div className="data-selection-summary data-selection-summary-dataset">
<span>Actieve bron</span>
<strong>{selectedDataset ? getDatasetDisplayName(selectedDataset) : 'Geen bron geselecteerd'}</strong>
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} · ${selectedDataset.status === 'ready' ? 'beschikbaar' : selectedDataset.status}` : 'Kies een bron om deze op de kaart te openen.'}</small>
</div>
<details className="dataset-upload-block data-panel-form-block">
<summary>Eigen bronbestand toevoegen</summary>
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
<label>
Bestandstype
<select
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
>
<option value="vector">Kaartlaag</option>
<option value="geojson">GeoJSON-kaartlaag</option>
<option value="raster">Luchtbeeld of raster</option>
</select>
</label>
<label>
Naam van de bron
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
placeholder="bijvoorbeeld eigen luchtbeeld"
/>
</label>
<label>
Gebied
<select
value={datasetForm.areaId}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
>
<option value="">Volledige bron</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<label className="file-input-label">
Bestand
<input
type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff"
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Bron inladen
</button>
</form>
</details>
<div className="dataset-role-summary-grid" aria-label="Overzicht van bronrollen">
{roleSummaries.map((summary) => (
<div className={`dataset-role-summary ${summary.className}`} key={summary.key}>
<strong>{summary.count}</strong>
<span>{summary.label}</span>
</div>
))}
</div>
{loadingDatasets ? <p role="status" aria-live="polite">Bronnen laden...</p> : null}
{!loadingDatasets && datasets.length === 0 ? (
<div className="empty-state">
<strong>Nog geen bronnen beschikbaar</strong>
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
</div>
) : null}
<div className="data-panel-list-block dataset-catalog-block">
<p className="data-section-label">Beschikbare bronnen</p>
<div className="catalog-browser-toolbar catalog-browser-toolbar-datasets">
<label className="catalog-search-field">
Zoek in beschikbare bronnen
<input
type="search"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
setPage(1)
}}
placeholder="Naam, bron, gemeente of type"
/>
</label>
<div className="catalog-pagination" aria-label="Paginering van bronnen">
<span>{filteredPrimaryDatasets.length} van {primaryDatasets.length}</span>
<button
className="secondary-action"
type="button"
disabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Vorige
</button>
<strong>{page} / {pageCount}</strong>
<button
className="secondary-action"
type="button"
disabled={page === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
>
Volgende
</button>
</div>
</div>
<ul className="dataset-list">
{visiblePrimaryDatasets.map((dataset) => {
const datasetRole = normalizeDatasetRole(dataset)
const roleLabel = datasetRoleLabel(datasetRole)
return (
<li className={dataset.id === selectedDatasetId ? 'dataset-card dataset-card-active' : 'dataset-card'} key={dataset.id}>
<div className="dataset-card-header">
<div>
<div className="dataset-card-kicker">
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
<span>{datasetKindLabel(dataset)}</span>
</div>
<strong className="dataset-card-title">{getDatasetDisplayName(dataset)}</strong>
<div className="entity-meta dataset-card-primary-meta">
<span>{(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
<span>{getDatasetSourceDisplayName(dataset)} · {formatObservationDate(dataset.observed_at)}</span>
</div>
</div>
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{dataset.status === 'ready' ? 'beschikbaar' : dataset.status === 'failed' ? 'mislukt' : 'bezig'}
</span>
</div>
<p className="dataset-action-hint">{datasetActionHint(dataset, datasetRole)}</p>
<div className="dataset-action-grid">
<button
className="primary-action dataset-action-button"
type="button"
onClickCapture={onOpenInspector}
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`}
>
<span>Bekijken</span>
<small>Details en acties</small>
</button>
<button
className="secondary-action dataset-action-button"
type="button"
onClick={() => onOpenDatasetInMap(dataset)}
aria-label="Open op kaart"
>
<span>Kaart</span>
<small>Laag openen</small>
</button>
<button
className="secondary-action dataset-action-button"
type="button"
onClick={() => onOpenDatasetExport(dataset)}
disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}
>
<span>Downloaden</span>
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'GeoJSON of rapport' : 'Enkel vectorlagen'}</small>
</button>
</div>
<details className="dataset-technical-details">
<summary>Broninformatie</summary>
<div className="entity-meta">
<span>Bestand: {dataset.name}</span>
<span>Bron: {dataset.source_name ?? dataset.source}</span>
{dataset.reference_layer_name ? <span>Laag: {dataset.reference_layer_name}</span> : null}
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'onbekend'}</span>
</div>
<div className="dataset-metrics data-density-grid">
<span>Grootte: {formatBytes(dataset.size_bytes)}</span>
<span>Objecten: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'onbekend'}</span>
<span>Begrenzing: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<button
className="secondary-action"
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
aria-label="Bronmetadata vernieuwen"
>
Metadata vernieuwen
</button>
</details>
</li>
)
})}
</ul>
{visiblePrimaryDatasets.length === 0 && datasets.length > 0 ? (
<p className="muted">Geen bron gevonden voor deze zoekterm.</p>
) : null}
{historicalDatasets.length > 0 ? (
<details
className="dataset-history-disclosure"
onToggle={(event) => setHistoryOpen(event.currentTarget.open)}
>
<summary>
<span>Historische meetmomenten</span>
<strong>{historicalDatasets.length} oudere lagen</strong>
</summary>
{historyOpen ? <ul className="dataset-history-list">
{historicalDatasets.map((dataset) => (
<li key={dataset.id}>
<div>
<strong>{getDatasetDisplayName(dataset)}</strong>
<span>{formatObservationDate(dataset.observed_at)} · {(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
</div>
<div className="panel-action-row">
<button className="secondary-action" type="button" onClick={() => onOpenDatasetInMap(dataset)}>Kaart</button>
<button className="secondary-action" type="button" onClick={() => onOpenDatasetExport(dataset)}>Downloaden</button>
</div>
</li>
))}
</ul> : null}
</details>
) : null}
</div>
</section>
)
}
@@ -0,0 +1,424 @@
import type { AreaRead, RasterMetadataResponse, RasterPreviewResponse, RasterStatsResponse, RasterTileHandoff } from '../../types'
interface RasterControlsProps {
areas: AreaRead[]
selectedDatasetId: string | null
selectedRasterMetadata: RasterMetadataResponse | null
selectedRasterStats: RasterStatsResponse | null
rasterPreview: RasterPreviewResponse | null
latestRasterTileManifestPath: string
latestRasterTileManifest: RasterTileHandoff | null
rasterUnavailableMessage: string | null
selectedClipAreaId: string
rasterTileSize: number
rasterTileOverlap: number
rasterTileOutputName: string
rasterReprojectCrs: string
rasterReprojectResampling: string
ndviNirBand: number
ndviRedBand: number
ndwiGreenBand: number
ndwiNirBand: number
ndbiSwirBand: number
ndbiNirBand: number
isRasterTileInputValid: boolean
onSetSelectedClipAreaId: (value: string) => void
onSetRasterTileSize: (value: number) => void
onSetRasterTileOverlap: (value: number) => void
onSetRasterTileOutputName: (value: string) => void
onSetRasterReprojectCrs: (value: string) => void
onSetRasterReprojectResampling: (value: string) => void
onSetNdviNirBand: (value: number) => void
onSetNdviRedBand: (value: number) => void
onSetNdwiGreenBand: (value: number) => void
onSetNdwiNirBand: (value: number) => void
onSetNdbiSwirBand: (value: number) => void
onSetNdbiNirBand: (value: number) => void
onRunRasterInspect: () => void
onRunRasterPreview: () => void
onRunRasterStats: () => void
onRunRasterReproject: () => void
onRunRasterClip: () => void
onRunRasterTile: () => void
onUseTileManifestForDetection: () => void
onUseTileManifestForSegmentation: () => void
onRunRasterNdvi: () => void
onRunRasterNdwi: () => void
onRunRasterNdbi: () => void
}
function formatRasterBounds(bounds: number[] | undefined | null): string {
if (!bounds || bounds.length < 4) {
return 'n.v.t.'
}
const [minX, minY, maxX, maxY] = bounds
return `${minX.toFixed(4)}, ${minY.toFixed(4)} -> ${maxX.toFixed(4)}, ${maxY.toFixed(4)}`
}
export function RasterControls({
areas,
selectedDatasetId,
selectedRasterMetadata,
selectedRasterStats,
rasterPreview,
latestRasterTileManifestPath,
latestRasterTileManifest,
rasterUnavailableMessage,
selectedClipAreaId,
rasterTileSize,
rasterTileOverlap,
rasterTileOutputName,
rasterReprojectCrs,
rasterReprojectResampling,
ndviNirBand,
ndviRedBand,
ndwiGreenBand,
ndwiNirBand,
ndbiSwirBand,
ndbiNirBand,
isRasterTileInputValid,
onSetSelectedClipAreaId,
onSetRasterTileSize,
onSetRasterTileOverlap,
onSetRasterTileOutputName,
onSetRasterReprojectCrs,
onSetRasterReprojectResampling,
onSetNdviNirBand,
onSetNdviRedBand,
onSetNdwiGreenBand,
onSetNdwiNirBand,
onSetNdbiSwirBand,
onSetNdbiNirBand,
onRunRasterInspect,
onRunRasterPreview,
onRunRasterStats,
onRunRasterReproject,
onRunRasterClip,
onRunRasterTile,
onUseTileManifestForDetection,
onUseTileManifestForSegmentation,
onRunRasterNdvi,
onRunRasterNdwi,
onRunRasterNdbi,
}: RasterControlsProps) {
const previewState = rasterPreview ? 'gereed' : 'nog niet aangemaakt'
const rasterReadinessItems = [
{
label: 'Bestandsprofiel',
state: selectedRasterMetadata ? 'gereed' : 'controle nodig',
detail: selectedRasterMetadata
? `${selectedRasterMetadata.driver} | ${selectedRasterMetadata.width} x ${selectedRasterMetadata.height} | ${selectedRasterMetadata.band_count} banden`
: 'Controleer eerst de metadata voordat je afgeleide rasters maakt.',
},
{
label: 'Coördinatenstelsel',
state: selectedRasterMetadata?.crs ? 'gereed' : 'ontbreekt',
detail: selectedRasterMetadata?.crs ?? 'Een coördinatenstelsel is vereist om veilig te begrenzen en op de kaart te tonen.',
},
{
label: 'Voorbeeldweergave',
state: previewState,
detail: rasterPreview
? `${rasterPreview.preview.width ?? 'n.v.t.'} x ${rasterPreview.preview.height ?? 'n.v.t.'} pixels`
: 'Maak een voorbeeld om de oriëntatie te controleren vóór beeldanalyse.',
},
{
label: 'Beeldtegels',
state: isRasterTileInputValid ? 'instellingen geldig' : 'instellingen geblokkeerd',
detail: isRasterTileInputValid
? 'GeoIntel kan een manifest maken voor detectie en segmentatie.'
: 'De tegelgrootte moet positief zijn en de overlap moet kleiner blijven.',
},
{
label: 'Werkgebied voor begrenzing',
state: areas.length > 0 ? 'beschikbaar' : 'ontbreekt',
detail: areas.length > 0 ? `${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'} beschikbaar.` : 'Maak eerst een gebied aan.',
},
]
const rasterGuardrailItems = [
!selectedDatasetId ? 'Kies eerst een rasterbestand.' : null,
rasterUnavailableMessage ? `Raster niet beschikbaar: ${rasterUnavailableMessage}` : null,
selectedDatasetId && !selectedRasterMetadata ? 'Controleer de metadata vóór herprojectie, begrenzing, tegels of indexberekening.' : null,
selectedRasterMetadata && !selectedRasterMetadata?.crs ? 'Het coördinatenstelsel ontbreekt; herstel dit vóór verdere ruimtelijke analyse.' : null,
!rasterPreview ? 'Er is nog geen voorbeeldweergave gemaakt.' : null,
!isRasterTileInputValid ? 'De tegelinstellingen zijn ongeldig.' : null,
areas.length === 0 ? 'Er bestaat nog geen werkgebied; begrenzen is daarom uitgeschakeld.' : null,
].filter((item): item is string => Boolean(item))
return (
<div className="dataset-tool-panel raster-tool-panel">
<section className="raster-readiness-surface" aria-label="Gereedheid van rasterverwerking">
<div className="panel-title-row">
<div>
<h3>Gereedheid van het raster</h3>
<p>Status van metadata, voorbeeldweergave, begrenzing en beeldtegels.</p>
</div>
</div>
<div className="raster-readiness-grid">
{rasterReadinessItems.map((item) => (
<div className="raster-readiness-item" key={item.label}>
<span>{item.label}</span>
<strong>{item.state}</strong>
<p>{item.detail}</p>
</div>
))}
</div>
<div className="raster-manifest-handoff">
<span>Laatste beeldtegelmanifest</span>
<p>
{latestRasterTileManifestPath ||
'Nog geen beeldtegels gemaakt. Maak beeldtegels voordat je dit raster gebruikt voor beeldanalyse.'}
</p>
{latestRasterTileManifest ? (
<dl className="raster-manifest-details">
<div>
<dt>Aantal tegels</dt>
<dd>{latestRasterTileManifest.count ?? 'n.v.t.'}</dd>
</div>
<div>
<dt>Tegelgrootte</dt>
<dd>{latestRasterTileManifest.tile_size ?? 'n.v.t.'}</dd>
</div>
<div>
<dt>Overlap</dt>
<dd>{latestRasterTileManifest.overlap ?? 'n.v.t.'}</dd>
</div>
<div>
<dt>Tegelset</dt>
<dd>{latestRasterTileManifest.tile_set_id ?? 'n.v.t.'}</dd>
</div>
</dl>
) : null}
<button
type="button"
aria-label="Gebruik huidig manifest voor gebouwdetectie"
onClick={onUseTileManifestForDetection}
disabled={!latestRasterTileManifestPath}
>
Gebruik voor gebouwdetectie
</button>
<button
type="button"
aria-label="Gebruik huidig manifest voor segmentatie"
onClick={onUseTileManifestForSegmentation}
disabled={!latestRasterTileManifestPath}
>
Gebruik voor segmentatie
</button>
</div>
</section>
<section className="raster-guardrail-surface" aria-label="Veiligheidscontroles">
<div className="panel-title-row">
<div>
<h3>Veiligheidscontroles</h3>
<p>Controles die onduidelijke of ruimtelijk onveilige verwerking voorkomen.</p>
</div>
</div>
{rasterGuardrailItems.length > 0 ? (
<ul className="raster-guardrail-list">
{rasterGuardrailItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : (
<p className="raster-readiness-state">Het raster is gereed voor veilige verwerking.</p>
)}
</section>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Rastermetadata</h4>
<p>Bestandsdriver: {selectedRasterMetadata?.driver ?? 'n.v.t.'}</p>
<p>Afmetingen: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n.v.t.'}</p>
<p>Controlesom: {selectedRasterMetadata?.checksum_sha256 ?? 'n.v.t.'}</p>
<p>
Profiel: CRS {selectedRasterMetadata?.crs ?? 'n.v.t.'} | banden {selectedRasterMetadata?.band_count ?? 'n.v.t.'} | datatype {
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n.v.t.'}
</p>
<p>Begrenzing: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
<p>Resolutie: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n.v.t.'}</p>
{rasterUnavailableMessage ? <p className="error">Raster niet beschikbaar: {rasterUnavailableMessage}</p> : null}
</div>
<h3>Rasterbewerkingen</h3>
<p>Controleer metadata en statistieken, herprojecteer, begrens of maak beeldtegels.</p>
<p>Voorbeeld: {rasterPreview ? 'aangemaakt' : 'nog niet aangemaakt'}</p>
<p>Afmetingen voorbeeld: {rasterPreview?.preview.width ?? 'n.v.t.'} x {rasterPreview?.preview.height ?? 'n.v.t.'}</p>
<button type="button" onClick={onRunRasterInspect}>
Metadata controleren
</button>
<button type="button" onClick={onRunRasterPreview} disabled={!selectedDatasetId}>
Voorbeeld maken
</button>
<button type="button" onClick={onRunRasterStats}>
Bandstatistieken berekenen
</button>
{selectedRasterStats ? (
<div>
<h4>Bandstatistieken</h4>
<p>Aangemaakt: {selectedRasterStats.generated_at ?? 'n.v.t.'}</p>
<ul>
{selectedRasterStats.bands.map((band) => (
<li key={band.band_index}>
Band {band.band_index}: minimum {band.min ?? 'n.v.t.'}, maximum {band.max ?? 'n.v.t.'}, gemiddelde {band.mean ?? 'n.v.t.'}, standaardafwijking {band.std ?? 'n.v.t.'},
geldig {band.valid_pixel_count}, aandeel zonder data {(band.nodata_ratio * 100).toFixed(2)}%, datatype {band.dtype ?? 'n.v.t.'}
</li>
))}
</ul>
</div>
) : null}
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Raster herprojecteren</h4>
<p className="dataset-tool-helper">Maak een afgeleid raster in een ander coördinatenstelsel.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Doel-CRS</span>
<input
value={rasterReprojectCrs}
onChange={(event) => onSetRasterReprojectCrs(event.target.value)}
placeholder="EPSG:31370"
/>
<span className="dataset-tool-helper">Coördinatenstelsel van het afgeleide raster.</span>
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Herbemonstering</span>
<select value={rasterReprojectResampling} onChange={(event) => onSetRasterReprojectResampling(event.target.value)}>
<option value="nearest">nearest</option>
<option value="bilinear">bilinear</option>
<option value="cubic">cubic</option>
</select>
<span className="dataset-tool-helper">Dichtstbijzijnd behoudt klassen; bilineair en kubisch verzachten continue rasters.</span>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterReproject}>
Raster herprojecteren
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Raster begrenzen</h4>
<p className="dataset-tool-helper">Beperk het raster tot een bestaand werkgebied.</p>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Werkgebied</span>
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterClip} disabled={areas.length === 0}>
Raster begrenzen
</button>
</div>
{areas.length === 0 ? <p className="dataset-tool-error">Maak eerst een werkgebied aan.</p> : null}
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Beeldtegels maken</h4>
<p className="dataset-tool-helper">Maak beeldtegels voor detectie of segmentatie.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Tegelgrootte</span>
<input
type="number"
min={1}
value={rasterTileSize}
onChange={(event) => onSetRasterTileSize(Number(event.target.value))}
/>
<span className="dataset-tool-helper">De tegelgrootte moet groter zijn dan nul.</span>
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Overlap</span>
<input
type="number"
min={0}
value={rasterTileOverlap}
onChange={(event) => onSetRasterTileOverlap(Number(event.target.value))}
/>
<span className="dataset-tool-helper">De overlap moet kleiner zijn dan de tegelgrootte.</span>
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Bestandsnaamvoorvoegsel</span>
<input
value={rasterTileOutputName}
onChange={(event) => onSetRasterTileOutputName(event.target.value)}
placeholder="optioneel"
/>
<span className="dataset-tool-helper">Optioneel voorvoegsel voor de aangemaakte tegels.</span>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterTile} disabled={!isRasterTileInputValid}>
Beeldtegels maken
</button>
</div>
{!isRasterTileInputValid ? (
<p className="dataset-tool-error">
De tegelgrootte moet groter zijn dan nul en de overlap moet positief en kleiner zijn dan de tegelgrootte.
</p>
) : null}
</div>
<h4>Spectrale indexen</h4>
<div>
<p>Gebruik de beschikbare bandnummers uit het rasterbestand, beginnend bij 1.</p>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">NDVI</h4>
<p className="dataset-tool-helper">Vegetatie-index op basis van nabij-infrarood en rood.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">NIR-band</span>
<input type="number" min={1} value={ndviNirBand} onChange={(event) => onSetNdviNirBand(Number(event.target.value))} />
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Rode band</span>
<input type="number" min={1} value={ndviRedBand} onChange={(event) => onSetNdviRedBand(Number(event.target.value))} />
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterNdvi}>
NDVI berekenen
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">NDWI</h4>
<p className="dataset-tool-helper">Waterindex op basis van nabij-infrarood en groen.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">NIR-band</span>
<input type="number" min={1} value={ndwiNirBand} onChange={(event) => onSetNdwiNirBand(Number(event.target.value))} />
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Groene band</span>
<input type="number" min={1} value={ndwiGreenBand} onChange={(event) => onSetNdwiGreenBand(Number(event.target.value))} />
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterNdwi}>
NDWI berekenen
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">NDBI</h4>
<p className="dataset-tool-helper">Bebouwingsindex op basis van kortgolvig en nabij-infrarood.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">SWIR-band</span>
<input type="number" min={1} value={ndbiSwirBand} onChange={(event) => onSetNdbiSwirBand(Number(event.target.value))} />
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">NIR-band</span>
<input type="number" min={1} value={ndbiNirBand} onChange={(event) => onSetNdbiNirBand(Number(event.target.value))} />
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterNdbi}>
NDBI berekenen
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,381 @@
import { useState } from 'react'
import { datasetsApi } from '../../services/api'
import type { BathymetrySourceProbeRead, DatasetCreateResponse } from '../../types'
import { getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import {
OFFICIAL_SOURCE_PORTFOLIO,
SOURCE_DOMAINS,
operationalSources,
sourcesForDomain,
type SourcePriority,
} from '../../lib/sourcePortfolio'
interface SourceCatalogPanelProps {
datasets: DatasetCreateResponse[]
projectId?: string
loading?: boolean
}
const THEME_LABELS: Record<string, string> = {
buildings: 'Bebouwing',
population: 'Bevolking',
forest: 'Bos',
nature_value: 'Natuurwaarde',
agriculture: 'Landbouw',
water: 'Water',
bathymetry: 'Waterbodem en dwarsprofielen',
flood_hazard: 'Overstromingsgevaar',
elevation: 'Hoogte en reliëf',
roads: 'Wegen en transport',
parcels: 'Percelen',
}
function datasetTheme(dataset: DatasetCreateResponse): string | null {
const configured = String(dataset.source_metadata?.['theme'] ?? dataset.reference_layer_name ?? '').toLowerCase()
if (configured === 'built' || configured === 'building') return 'buildings'
if (configured === 'transport' || configured === 'road') return 'roads'
if (configured in THEME_LABELS) return configured
return null
}
function priorityLabel(priority: SourcePriority): string {
if (priority === 'next') return 'Eerstvolgend'
if (priority === 'planned') return 'Daarna'
return 'Later'
}
function timelineSummary(
temporalCount: number,
temporalSeriesCount: number,
otherMethodCount: number,
firstYear: number | null,
lastYear: number | null,
): string {
if (temporalCount < 2 || !firstYear || !lastYear) {
return 'Alleen de huidige toestand is vergelijkbaar beschikbaar.'
}
const seriesNote = temporalSeriesCount > 1
? ` in ${temporalSeriesCount} afzonderlijke reeksen`
: ''
const methodNote = otherMethodCount > 0
? `plus ${otherMethodCount} andere bronmethode${otherMethodCount === 1 ? '' : 'n'}`
: null
return [
`${temporalCount} vergelijkbare meetmomenten${seriesNote}`,
`${firstYear}-${lastYear}`,
methodNote,
].filter(Boolean).join(' · ')
}
function mdkStatusLabel(status: BathymetrySourceProbeRead['status']): string {
if (status === 'reachable') return 'Service bereikbaar'
if (status === 'tls_error') return 'Geblokkeerd: certificaat'
if (status === 'endpoint_unavailable') return 'Service niet bereikbaar'
if (status === 'invalid_capabilities') return 'Ongeldige service-informatie'
if (status === 'disabled') return 'Controle uitgeschakeld'
return 'Configuratie controleren'
}
export function SourceCatalogPanel({
datasets,
projectId,
loading = false,
}: SourceCatalogPanelProps): JSX.Element {
const [mdkProbe, setMdkProbe] = useState<BathymetrySourceProbeRead | null>(null)
const [mdkProbeLoading, setMdkProbeLoading] = useState(false)
const [mdkProbeError, setMdkProbeError] = useState<string | null>(null)
const ready = datasets.filter((dataset) => dataset.status === 'ready')
const waterinfoDatasets = ready.filter((dataset) => dataset.source_name === 'waterinfo')
const historicalOrthophotos = ready.filter(
(dataset) =>
dataset.source_name === 'digitaal_vlaanderen_orthophoto' &&
String(dataset.source_metadata?.['product_key'] ?? 'most_recent') !== 'most_recent',
)
const bwkDatasets = ready.filter((dataset) => dataset.source_name === 'inbo_bwk_natura2000')
const largestBwkSnapshot = bwkDatasets.reduce<DatasetCreateResponse | null>(
(largest, dataset) => !largest || (dataset.feature_count ?? 0) > (largest.feature_count ?? 0) ? dataset : largest,
null,
)
const agricultureDatasets = ready.filter((dataset) => dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels')
const buildingsRegisterDatasets = ready.filter(
(dataset) => dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register',
)
const dhmvDatasets = ready.filter((dataset) => ['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? ''))
const floodHazardDatasets = ready.filter((dataset) => dataset.source_name === 'vmm_flood_hazard')
const bathymetryProfileDatasets = ready.filter(
(dataset) => dataset.source_name === 'vmm_vha_bathymetry_profiles',
)
const latestBuildingsRegister = [...buildingsRegisterDatasets].sort(
(left, right) => new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime(),
)[0]
const agricultureYears = agricultureDatasets
.flatMap((dataset) => dataset.observed_at ? [new Date(dataset.observed_at).getUTCFullYear()] : [])
const availableSources = operationalSources(ready)
const availableSourceKeys = new Set(availableSources.map((source) => source.key))
const pendingSources = OFFICIAL_SOURCE_PORTFOLIO.filter((source) => {
if (source.key === 'bwk') return bwkDatasets.length === 0
return !availableSourceKeys.has(source.key)
})
const domains = SOURCE_DOMAINS.map((domain) => {
const sources = sourcesForDomain(domain.key)
const loaded = sources.filter((source) => availableSourceKeys.has(source.key))
const next = sources.filter((source) => source.priority === 'next' && !availableSourceKeys.has(source.key))
return {
...domain,
sourceCount: sources.length,
loadedCount: loaded.length,
nextCount: next.length,
loadedNames: loaded.map((source) => source.name),
}
})
const themes = Object.keys(THEME_LABELS).map((theme) => {
const matches = ready.filter((dataset) => datasetTheme(dataset) === theme)
const temporal = matches.filter((dataset) => dataset.temporal_series_key && dataset.observed_at)
const seriesGroups = new Map<string, DatasetCreateResponse[]>()
temporal.forEach((dataset) => {
const seriesKey = dataset.temporal_series_key as string
seriesGroups.set(seriesKey, [...(seriesGroups.get(seriesKey) ?? []), dataset])
})
const comparableSeries = [...seriesGroups.values()].filter((series) => series.length >= 2)
const comparableObservations = comparableSeries.flat()
const years = comparableObservations.map((dataset) => new Date(dataset.observed_at as string).getUTCFullYear())
const latest = [...matches].sort(
(left, right) => new Date(right.observed_at ?? right.imported_at ?? 0).getTime() - new Date(left.observed_at ?? left.imported_at ?? 0).getTime(),
)[0]
return {
theme,
label: THEME_LABELS[theme],
datasetCount: matches.length,
temporalCount: comparableObservations.length,
temporalSeriesCount: comparableSeries.length,
otherMethodCount: matches.length - comparableObservations.length,
firstYear: years.length ? Math.min(...years) : null,
lastYear: years.length ? Math.max(...years) : null,
source: latest ? getDatasetSourceDisplayName(latest) : null,
}
})
const checkMdkReadiness = async (): Promise<void> => {
if (!projectId || mdkProbeLoading) return
setMdkProbeLoading(true)
setMdkProbeError(null)
try {
setMdkProbe(await datasetsApi.probeMdkBathymetry(projectId))
} catch (error) {
setMdkProbeError(error instanceof Error ? error.message : 'De Noordzee-bron kon niet worden gecontroleerd.')
} finally {
setMdkProbeLoading(false)
}
}
if (loading) {
return (
<section
className="workspace-panel source-catalog-panel"
aria-label="Beschikbare databronnen"
aria-busy="true"
>
<div className="panel-title-row">
<div>
<span className="section-kicker">Broninventaris</span>
<h2>Welke vragen kan GeoIntel beantwoorden?</h2>
</div>
<span className="status-badge">bronnen laden</span>
</div>
<div className="geo-bootstrap-status" role="status" aria-live="polite">
<span className="geo-loading-indicator" aria-hidden="true" />
<div>
<strong>Broninventaris wordt opgebouwd</strong>
<small>Operationele en geplande bronnen worden pas getoond wanneer de projectdata compleet is.</small>
</div>
</div>
</section>
)
}
return (
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
<div className="panel-title-row">
<div>
<span className="section-kicker">Broninventaris</span>
<h2>Welke vragen kan GeoIntel beantwoorden?</h2>
<p className="muted">Zes domeinen vormen samen het platform. Alleen werkelijk ingeladen bronnen krijgen de status operationeel; de rest blijft een transparante aansluitplanning.</p>
</div>
<span className="status-badge status-badge-ready">{availableSources.length} officiële bronnen operationeel</span>
</div>
<div className="source-domain-grid" aria-label="Platformbrede brondomeinen">
{domains.map((domain) => (
<article className="source-domain-card" key={domain.key}>
<div>
<strong>{domain.label}</strong>
<span>{domain.loadedCount} van {domain.sourceCount} bronnen operationeel</span>
</div>
<b className={domain.loadedCount > 0 ? 'source-status-operational' : 'source-status-planned'}>
{domain.loadedCount > 0 ? 'Operationeel' : 'Aansluiting nodig'}
</b>
<p>{domain.question}</p>
<small>
{domain.loadedNames.length > 0
? domain.loadedNames.slice(0, 2).join(' · ')
: `${domain.nextCount} geverifieerde bron${domain.nextCount === 1 ? '' : 'nen'} eerstvolgend`}
</small>
</article>
))}
</div>
<details className="source-theme-coverage">
<summary>Dekking per kaartthema en tijdreeks</summary>
<div className="source-catalog-grid">
{themes.map((theme) => (
<article className="source-catalog-card" key={theme.theme}>
<div>
<strong>{theme.label}</strong>
<span>{theme.source ?? 'Nog niet ingeladen'}</span>
</div>
<b>{theme.datasetCount > 0 ? 'Beschikbaar' : 'Ontbreekt'}</b>
<p>{timelineSummary(
theme.temporalCount,
theme.temporalSeriesCount,
theme.otherMethodCount,
theme.firstYear,
theme.lastYear,
)}</p>
</article>
))}
</div>
</details>
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 || dhmvDatasets.length > 0 || floodHazardDatasets.length > 0 || bathymetryProfileDatasets.length > 0 ? (
<details className="source-loaded-disclosure">
<summary>Actieve broncollecties en hun beperkingen</summary>
<div className="source-catalog-loaded" aria-label="Aanvullende ingeladen bronnen">
{waterinfoDatasets.length > 0 ? (
<article>
<strong>Waterinfo meetreeksen</strong>
<span>{new Set(waterinfoDatasets.map((dataset) => dataset.temporal_series_key).filter(Boolean)).size} stationsreeksen · {waterinfoDatasets.length} jaarmetingen</span>
<p>Puntmetingen blijven afzonderlijk per station en worden niet als gebiedsgemiddelde of watervolume voorgesteld.</p>
</article>
) : null}
{historicalOrthophotos.length > 0 ? (
<article>
<strong>Historische luchtbeelden</strong>
<span>{historicalOrthophotos.length} begrensde kaartselecties bewaard</span>
<p>Officiële jaargangen en periodes zijn via de kaart beschikbaar zonder actuele GRB-validatie.</p>
</article>
) : null}
{bwkDatasets.length > 0 ? (
<article>
<strong>BWK / Natura 2000</strong>
<span>
Toestand 2025 · {bwkDatasets.length} gebiedsdekking{bwkDatasets.length === 1 ? '' : 'en'} ·{' '}
{(largestBwkSnapshot?.feature_count ?? 0).toLocaleString('nl-BE')} kaartvlakken in de grootste dekking
</span>
<p>Biologische waardering en habitataandelen blijven afzonderlijke, brongetrouwe metingen met herkomstinformatie.</p>
</article>
) : null}
{agricultureDatasets.length > 0 ? (
<article>
<strong>Landbouwgebruikspercelen</strong>
<span>{agricultureDatasets.length} definitieve jaargangen{agricultureYears.length ? ` · ${Math.min(...agricultureYears)}-${Math.max(...agricultureYears)}` : ''}</span>
<p>Oppervlakte en officiële hoofdteeltgroepen zijn historisch vergelijkbaar; perceelidentiteiten blijven bewust niet gekoppeld tussen jaren.</p>
</article>
) : null}
{latestBuildingsRegister ? (
<article>
<strong>Gebouwen- en Adressenregister</strong>
<span>
{(latestBuildingsRegister.feature_count ?? 0).toLocaleString('nl-BE')} gebouwen · {' '}
{Number(latestBuildingsRegister.source_metadata?.['building_unit_count'] ?? 0).toLocaleString('nl-BE')} eenheden · {' '}
{Number(latestBuildingsRegister.source_metadata?.['linked_address_count'] ?? 0).toLocaleString('nl-BE')} gekoppelde adressen
</span>
<p>Registerstatus en geaggregeerde koppelingen binnen de werkelijk ingeladen dekking; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
</article>
) : null}
{dhmvDatasets.length > 0 ? (
<article>
<strong>Digitaal Hoogtemodel Vlaanderen II</strong>
<span>{dhmvDatasets.length} rasterproducten · DTM/DSM · analyse op 5 m</span>
<p>Hoogte in TAW, reliëf en helling uit opnameperiode 2013-2015. Geen waterdiepte of watervolume.</p>
</article>
) : null}
{floodHazardDatasets.length > 0 ? (
<article>
<strong>VMM-overstromingsgevaarkaarten</strong>
<span>{floodHazardDatasets.length} scenario&apos;s · fluviaal en pluviaal · huidig en 2050</span>
<p>Gemodelleerde maximumdiepte per kansscenario. Geen actuele waterstand, bathymetrie of permanent watervolume.</p>
</article>
) : null}
{bathymetryProfileDatasets.length > 0 ? (
<article>
<strong>VHA-dwarsprofielen waterbodem</strong>
<span>
{bathymetryProfileDatasets.reduce(
(total, dataset) => total + Number(dataset.feature_count ?? 0),
0,
).toLocaleString('nl-BE')} profielpunten · historische meetcampagnes
</span>
<p>Meetvelden en officiële profielbladen per punt. Geen continue actuele bathymetrie of volume zonder een gelijktijdig waterpeil.</p>
</article>
) : null}
</div>
</details>
) : null}
<details className="source-loaded-disclosure">
<summary>Maritieme bronstatus</summary>
<div className="source-catalog-loaded">
<article>
<strong>MDK-dieptemodel Belgische Noordzee</strong>
<span>{mdkProbe ? mdkStatusLabel(mdkProbe.status) : 'Nog niet live gecontroleerd'}</span>
<p>
{mdkProbe?.message ??
'Controleert uitsluitend de officiële service-informatie met geldige TLS. Er wordt geen raster gedownload.'}
</p>
{mdkProbeError ? <small className="error-text">{mdkProbeError}</small> : null}
<button
className="secondary-button"
type="button"
disabled={!projectId || mdkProbeLoading}
onClick={() => void checkMdkReadiness()}
>
{mdkProbeLoading ? 'Controleren...' : 'Controleer Noordzee-bron'}
</button>
</article>
</div>
</details>
<details className="source-opportunity-list">
<summary>Officiële bronnen die hierna kunnen worden ingeladen</summary>
<div className="source-opportunity-domains">
{SOURCE_DOMAINS.map((domain) => {
const sources = pendingSources.filter((source) => source.domain === domain.key)
if (sources.length === 0) return null
return (
<section className="source-opportunity-domain" key={domain.key}>
<header>
<strong>{domain.label}</strong>
<span>{sources.length} gecontroleerde kandidaatbron{sources.length === 1 ? '' : 'nen'}</span>
</header>
<div>
{sources.map((source) => (
<article key={source.name}>
<div>
<strong>{source.name}</strong>
<span>{source.owner} · {source.coverage}</span>
</div>
<span className={`source-priority source-priority-${source.priority}`}>{priorityLabel(source.priority)}</span>
<p>{source.value}</p>
<small>Meetbaar als: {source.metricExamples}</small>
<a href={source.url} target="_blank" rel="noreferrer">Bekijk officiële bron</a>
</article>
))}
</div>
</section>
)
})}
</div>
</details>
</section>
)
}
@@ -0,0 +1,84 @@
import type { AreaRead, DatasetCreateResponse } from '../../types'
interface VectorControlsProps {
areas: AreaRead[]
availableVectorTargets: DatasetCreateResponse[]
selectedClipAreaId: string
selectedIntersectTargetId: string
onSetSelectedClipAreaId: (value: string) => void
onSetSelectedIntersectTargetId: (value: string) => void
onRunVectorClip: () => void
onRunVectorBuffer: () => void
onRunVectorIntersect: () => void
}
export function VectorControls({
areas,
availableVectorTargets,
selectedClipAreaId,
selectedIntersectTargetId,
onSetSelectedClipAreaId,
onSetSelectedIntersectTargetId,
onRunVectorClip,
onRunVectorBuffer,
onRunVectorIntersect,
}: VectorControlsProps) {
return (
<div className="dataset-tool-panel vector-tool-panel">
<h3>Vectorbewerkingen</h3>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Begrenzen tot gebied</h4>
<p className="dataset-tool-helper">Beperk objecten tot het gekozen werkgebied.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Werkgebied</span>
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunVectorClip} disabled={areas.length === 0}>
Begrenzen
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Invloedszone</h4>
<p className="dataset-tool-helper">Maak een zone van 25 meter rond ieder object.</p>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunVectorBuffer}>
Zone van 25 m maken
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Lagen doorsnijden</h4>
<p className="dataset-tool-helper">Bereken de overlap met een andere bewaarde vectorlaag.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Tweede kaartlaag</span>
<select value={selectedIntersectTargetId} onChange={(event) => onSetSelectedIntersectTargetId(event.target.value)}>
<option value="">Automatisch de eerste geschikte laag</option>
{availableVectorTargets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
<span className="dataset-tool-helper">Laat automatisch staan om de eerste beschikbare vectorlaag te gebruiken.</span>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunVectorIntersect}>
Overlap berekenen
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { AiPipelineIllustration } from './AiPipelineIllustration'
afterEach(cleanup)
describe('AiPipelineIllustration', () => {
it('shows the real readiness state and explains the selected stage', () => {
render(
<AiPipelineIllustration
hasImagery
hasTiles
gpuReady
hasDetections={false}
hasQualityEvidence={false}
running={false}
/>,
)
expect(screen.getByText('GPU gereed')).toBeTruthy()
expect(screen.getByRole('tab', { name: /Detecties/ }).textContent).toContain('volgende stap')
fireEvent.click(screen.getByRole('tab', { name: /Berekening/ }))
expect(screen.getByRole('tabpanel').textContent).toContain('De herkenning draait lokaal')
})
it('moves selection and focus through the tablist with keyboard controls', () => {
render(
<AiPipelineIllustration
hasImagery
hasTiles
gpuReady
hasDetections={false}
hasQualityEvidence={false}
running={false}
/>,
)
const tabs = screen.getAllByRole('tab') as HTMLButtonElement[]
const selectedTab = screen.getByRole('tab', { name: /Detecties/ }) as HTMLButtonElement
const panel = screen.getByRole('tabpanel')
expect(selectedTab.tabIndex).toBe(0)
expect(tabs.filter((tab) => tab.tabIndex === 0)).toHaveLength(1)
expect(selectedTab.getAttribute('aria-controls')).toBe(panel.id)
expect(panel.getAttribute('aria-labelledby')).toBe(selectedTab.id)
selectedTab.focus()
fireEvent.keyDown(selectedTab, { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: /QA-bewijs/ }).getAttribute('aria-selected')).toBe('true')
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowLeft' })
const wrappedTab = screen.getByRole('tab', { name: /QA-bewijs/ })
expect(document.activeElement).toBe(wrappedTab)
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(wrappedTab.id)
})
})
@@ -0,0 +1,116 @@
import { useId, useRef, useState, type KeyboardEvent } from 'react'
import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react'
interface AiPipelineIllustrationProps {
hasImagery: boolean
hasTiles: boolean
gpuReady: boolean
hasDetections: boolean
hasQualityEvidence: boolean
running: boolean
}
const pipelineStages = [
{ key: 'imagery', label: 'Orthofoto', title: 'Gegeorefereerd bronbeeld', detail: 'CRS, resolutie en ruimtelijke dekking blijven bij de dataset bewaard.', icon: Image },
{ key: 'tiles', label: 'Beeldtegels', title: 'Beeld opgedeeld in controleerbare tegels', detail: 'Elke tegel houdt zijn overlap en herkomst bij, zodat elke detectie terug te voeren is op de bronpixel.', icon: Boxes },
{ key: 'gpu', label: 'Berekening', title: 'De herkenning draait lokaal', detail: 'De analyse gebruikt de GPU van de server. Is die niet beschikbaar, dan stopt de analyse in plaats van een resultaat te maken waarop u niet kunt bouwen.', icon: Cpu },
{ key: 'detections', label: 'Detecties', title: 'Gegeorefereerde gebouwobjecten', detail: 'Confidence, modelversie, brontegel en geometrie worden als reproduceerbaar resultaat bewaard.', icon: ScanSearch },
{ key: 'quality', label: 'QA-bewijs', title: 'Controle vóór vrijgave', detail: 'Precision, recall, IoU en foutbewijs bepalen of een resultaat alleen verkennend of operationeel bruikbaar is.', icon: BadgeCheck },
] as const
export function AiPipelineIllustration({
hasImagery,
hasTiles,
gpuReady,
hasDetections,
hasQualityEvidence,
running,
}: AiPipelineIllustrationProps): JSX.Element {
const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence]
const firstIncomplete = readiness.findIndex((ready) => !ready)
const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete)
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const componentId = useId()
const titleId = `${componentId}-title`
const panelId = `${componentId}-panel`
const selected = pipelineStages[selectedIndex]
const selectAndFocus = (index: number) => {
setSelectedIndex(index)
tabRefs.current[index]?.focus()
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let nextIndex: number | null = null
switch (event.key) {
case 'ArrowRight':
nextIndex = (index + 1) % pipelineStages.length
break
case 'ArrowLeft':
nextIndex = (index - 1 + pipelineStages.length) % pipelineStages.length
break
case 'Home':
nextIndex = 0
break
case 'End':
nextIndex = pipelineStages.length - 1
break
default:
return
}
event.preventDefault()
selectAndFocus(nextIndex)
}
return (
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby={titleId}>
<div className="ai-pipeline-heading">
<div>
<p className="eyebrow">Van pixel naar bewijs</p>
<h3 id={titleId}>Van luchtbeeld naar controleerbare detectie</h3>
<p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p>
</div>
<span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}>
<i /> {gpuReady ? 'GPU gereed' : 'GPU controleren'}
</span>
</div>
<div className="ai-pipeline-track" role="tablist" aria-label="Stappen in de analyseketen">
<span className="ai-pipeline-flow" aria-hidden="true" />
{pipelineStages.map(({ key, label, icon: Icon }, index) => (
<button
key={key}
id={`${componentId}-${key}`}
ref={(element) => { tabRefs.current[index] = element }}
type="button"
role="tab"
aria-selected={selectedIndex === index}
aria-controls={panelId}
tabIndex={selectedIndex === index ? 0 : -1}
className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'}
onClick={() => setSelectedIndex(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
>
<span><Icon aria-hidden="true" /></span>
<strong>{label}</strong>
<small>{readiness[index] ? 'gereed' : index === firstIncomplete ? 'volgende stap' : 'wachten'}</small>
</button>
))}
</div>
<div
id={panelId}
className="ai-pipeline-detail"
role="tabpanel"
aria-labelledby={`${componentId}-${selected.key}`}
tabIndex={0}
key={selected.key}
>
<span>{String(selectedIndex + 1).padStart(2, '0')}</span>
<div><strong>{selected.title}</strong><p>{selected.detail}</p></div>
</div>
</section>
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,323 @@
import type {
DetectionModelCapability,
ModelAssetRead,
YoloPreflightResponse,
} from '../../types'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { analysisModelAvailabilityMessage } from '../models/modelOptions'
interface DetectionModelManagementProps {
detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean
detectionModelError: string | null
modelAssetError: string | null
selectedDetectionModelId: string
selectedModelAssetId: string
detectionConfidenceThreshold: number
yoloPreflight: YoloPreflightResponse | null
loadingYoloPreflight: boolean
yoloPreflightError: string | null
onRefreshYoloPreflight: () => void
onSelectModelAsset: (modelAssetId: string) => void
onApplyOperatorProfile: (profile: DetectionOperatorProfile) => void
}
export function detectionModelLabel(model: DetectionModelCapability): string {
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
return model.display_name
}
function formatModelAssetSize(sizeBytes: number): string {
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) return 'n.v.t.'
const megabytes = sizeBytes / (1024 * 1024)
return `${megabytes.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} MB`
}
function statusLabel(value: string): string {
if (value === 'configured' || value === 'ready') return 'gereed'
if (value === 'not_configured') return 'niet geconfigureerd'
if (value === 'dependency_unavailable') return 'software ontbreekt'
if (value === 'accelerator_unavailable') return 'GPU niet beschikbaar'
if (value === 'contract_incomplete') return 'provenance onvolledig'
return value.replace(/_/g, ' ')
}
function governanceStatusLabel(value: string): string {
if (value === 'not_verified_by_catalog') return 'niet geverifieerd door deze catalogus'
return statusLabel(value)
}
export function DetectionModelManagement({
detectionModels,
modelAssets,
loadingDetectionModels,
detectionModelError,
modelAssetError,
selectedDetectionModelId,
selectedModelAssetId,
detectionConfidenceThreshold,
yoloPreflight,
loadingYoloPreflight,
yoloPreflightError,
onRefreshYoloPreflight,
onSelectModelAsset,
onApplyOperatorProfile,
}: DetectionModelManagementProps): JSX.Element {
const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null
const selectedOperatorProfile = DETECTION_OPERATOR_PROFILES.find(
(profile) => profile.modelAssetId === selectedModelAssetId,
) ?? null
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled
&& yoloPreflight.checks.dependencies_available
&& yoloPreflight.checks.accelerator_ready === true
&& yoloPreflight.checks.model_file_exists,
)
return (
<div className="detection-model-management">
<details className="ai-lab-model-surface" aria-label="Technische modelmogelijkheden">
<summary>
<span>Technische modelinformatie</span>
<strong>{detectionModels.length} registraties</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
{loadingDetectionModels ? (
<div className="result-state result-state-loading">
<strong>Analysemodellen worden gecontroleerd.</strong>
<p>GeoIntel leest de modelregistratie en lokale bestanden.</p>
</div>
) : null}
{detectionModelError ? (
<div className="result-state result-state-error">
<strong>De modelregistratie is niet bereikbaar.</strong>
<p>{detectionModelError}</p>
</div>
) : null}
{modelAssetError ? (
<div className="result-state result-state-error">
<strong>De lokale modelbestanden konden niet worden gelezen.</strong>
<p>{modelAssetError}</p>
</div>
) : null}
{detectionModels.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>De backend meldt geen analysemodellen.</strong>
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</p>
</div>
) : null}
</div>
<ul className="model-list">
{detectionModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{detectionModelLabel(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>
{statusLabel(model.status)}
</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
<span>Model-ID: {model.model_id}</span>
<span>Framework: {model.framework}</span>
<span>Taaktype: {model.task_type}</span>
</div>
</details>
</li>
))}
</ul>
</div>
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<details className="ai-lab-model-surface" aria-label="Lokale modelkeuze">
<summary>
<span>Modelkeuze voor beheerders</span>
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Lokaal modelbestand</h3>
<p>GeoIntel kiest automatisch het actieve lokale model. Een andere keuze geldt alleen voor deze analyse.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="operator-profile-grid" aria-label="Historische YOLO-controleprofielen">
{DETECTION_OPERATOR_PROFILES.map((profile) => {
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
const profileSelected =
selectedModelAssetId === profile.modelAssetId
&& Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001
return (
<div
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
key={profile.id}
>
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className="status-badge">
{profile.independentTestProven ? 'onafhankelijk getoetst' : 'historisch, geen releasebewijs'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
<span>historische precisie {profile.precision.toFixed(3)}</span>
<span>historische herkenningsgraad {profile.recall.toFixed(3)}</span>
<span>historische F1 {profile.f1.toFixed(3)}</span>
<span>positieve controles {profile.positiveSampleCount}</span>
<span>
ergste achtergrondmonster {profile.maxBackgroundDetections} van{' '}
{profile.backgroundSampleCount}
</span>
</div>
<p className="field-guidance">
Achtergrondtoets: {profile.backgroundGate}. Meting: {profile.evidenceReference}.
</p>
<p className="field-guidance">{profile.limitationMessage}</p>
<button
className="secondary-action"
type="button"
onClick={() => onApplyOperatorProfile(profile)}
disabled={!profileAsset}
>
Profiel gebruiken
</button>
</div>
)
})}
</div>
<label>
Lokaal modelbestand
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Kies een lokaal modelbestand</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(actief)' : ''} · {governanceStatusLabel(asset.governed_validation_status)}
</option>
))}
</select>
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een lokaal model in de modelmap of configureer het bestaande YOLO-modelpad. Beschikbaarheid alleen is geen kwaliteits- of releasebewijs.</p>
</div>
) : null}
{selectedModelAsset ? (
<>
{selectedModelAsset.governed_validation_status === 'not_verified_by_catalog' ? (
<div className="result-state result-state-warning" role="status">
<strong>Modelkwaliteit niet geverifieerd</strong>
<p>Dit bestand is technisch beschikbaar, maar heeft in deze catalogus geen gevalideerde kwaliteits- of promotiestatus.</p>
</div>
) : null}
<details className="technical-inline-details">
<summary>Bestands- en integriteitsgegevens</summary>
<div className="result-summary-card">
<p>Bestand: {selectedModelAsset.filename}</p>
<p>Runtimestatus: {statusLabel(selectedModelAsset.runtime_status)}</p>
<p>Actief servermodel: {selectedModelAsset.active ? 'ja' : 'nee'}</p>
<p>Gevalideerde kwaliteit: {governanceStatusLabel(selectedModelAsset.governed_validation_status)}</p>
<p>Promotiestatus: {governanceStatusLabel(selectedModelAsset.promotion_status)}</p>
<p>Automatisch downloaden: {selectedModelAsset.will_download_models ? 'ja' : 'nee'}</p>
<p>Grootte: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Pad: {selectedModelAsset.model_path}</p>
<p>{selectedModelAsset.limitation_message}</p>
</div>
</details>
</>
) : null}
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="Technische YOLO-runtimecontrole">
<summary>
<span>Technische runtimecontrole</span>
<strong>{yoloRuntimeReady ? 'gereed' : statusLabel(yoloPreflight?.status ?? 'niet geladen')}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>YOLO-runtimecontrole</h3>
<p>Deze alleen-lezen controle start geen analyse en downloadt geen modelbestanden.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
Controle vernieuwen
</button>
</div>
<div className="ai-lab-state-stack">
{loadingYoloPreflight ? (
<div className="result-state result-state-loading">
<strong>De YOLO-runtime wordt gecontroleerd.</strong>
<p>GeoIntel controleert configuratie, optionele software en lokale bestanden.</p>
</div>
) : null}
{yoloPreflightError ? (
<div className="result-state result-state-error">
<strong>De YOLO-runtimecontrole is niet beschikbaar.</strong>
<p>{yoloPreflightError}</p>
</div>
) : null}
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
<div className="result-state result-state-empty">
<strong>Nog geen runtimecontrole geladen.</strong>
<p>Vernieuw de controle voordat je een lokaal model gebruikt.</p>
</div>
) : null}
</div>
{yoloPreflight ? (
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
<div className="lab-readiness-grid">
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>YOLO ingeschakeld</span>
<strong>{yoloPreflight.checks.enabled ? 'ja' : 'nee'}</strong>
</div>
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Benodigde software</span>
<strong>{yoloPreflight.checks.dependencies_available === true ? 'beschikbaar' : yoloPreflight.checks.dependencies_available === false ? 'ontbreekt' : 'niet gecontroleerd'}</strong>
</div>
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Lokaal modelbestand</span>
<strong>{yoloPreflight.checks.model_file_exists === true ? 'gevonden' : yoloPreflight.checks.model_path_set ? 'ontbreekt' : 'niet geconfigureerd'}</strong>
</div>
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>GPU-versnelling</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'beschikbaar' : yoloPreflight.runtime.cuda_available === false ? 'niet beschikbaar' : 'niet gecontroleerd'}</strong>
</div>
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{yoloPreflight.checks.manifest_valid === true ? 'geldig' : yoloPreflight.checks.manifest_path_set ? 'ongeldig' : 'niet opgegeven'}</strong>
</div>
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Aantal beeldtegels</span>
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
</div>
</div>
<details className="technical-inline-details">
<summary>Versies en serverpaden</summary>
<div className="entity-meta">
<span>PyTorch: {yoloPreflight.runtime.torch_version ?? 'n.v.t.'}</span>
<span>Ultralytics: {yoloPreflight.runtime.ultralytics_version ?? 'n.v.t.'}</span>
<span>YOLO-configuratiemap: {yoloPreflight.runtime.yolo_config_dir ?? 'n.v.t.'}</span>
<span>Modelmap: {yoloPreflight.runtime.model_directory ?? 'n.v.t.'}</span>
<span>Model-ID: {yoloPreflight.model_asset_id ?? 'n.v.t.'}</span>
</div>
</details>
</div>
) : null}
</div>
</details>
</div>
)
}
@@ -0,0 +1,83 @@
export interface DetectionOperatorProfile {
id: string
displayName: string
modelAssetId: string
confidenceThreshold: number
independentTestProven: boolean
precision: number
recall: number
f1: number
positiveSampleCount: number
maxBackgroundDetections: number
backgroundSampleCount: number
backgroundGate: string
evidenceReference: string
validationScope: string
description: string
limitationMessage: string
}
// Every number below is a value recorded in docs/CODEX_EXECUTION_LOG.md for
// this exact model, tile size, overlap and threshold. Publishing a figure the
// evidence record does not contain is the one thing an operator cannot check,
// so tests/test_detection_profile_metrics_are_traceable.py refuses it.
export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
{
id: 'small-building-balanced-review',
displayName: 'Aanbevolen controleprofiel kleine gebouwen',
modelAssetId: 'geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt',
confidenceThreshold: 0.15,
independentTestProven: false,
precision: 0.5898197518,
recall: 0.5769921004,
f1: 0.5824578632,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
backgroundSampleCount: 3,
backgroundGate: 'strikte lege-poort (Postel, Lommel, Arendonk)',
evidenceReference: 'promotierapport smallbld-minpx3-img640-ft30 | tegel 512 | overlap 64 | drempel 0.15',
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
description:
'Historisch controleprofiel met een evenwicht tussen gevonden en gemiste kleine gebouwen. Gebruik dit als startpunt voor lokale QA, niet als vrijgavebewijs.',
limitationMessage:
'Slechts drie pure-achtergrondbeelden en geen onafhankelijke hold-out ondersteunen deze historische meting. Dit profiel ruilt precisie in voor dekking: het vindt meer kleine gebouwen dan het vorige, maar levert een hogere controlelast op. Postel bleef het moeilijkste gebied; behandel elke detectie als controlekandidaat en niet als grondwaarheid.',
},
{
id: 'expanded-balanced-review',
displayName: 'Voorgaand gebalanceerd controleprofiel',
modelAssetId: 'geointel-building-yolov8s-aoi1024expandedminpx4vis035e50-pt',
confidenceThreshold: 0.15,
independentTestProven: false,
precision: 0.6470590036,
recall: 0.4699913837,
f1: 0.5432865391,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
backgroundSampleCount: 3,
backgroundGate: 'strikte lege-poort (Postel, Lommel, Arendonk)',
evidenceReference: 'zeven-AOI positieve matrix aoi1024expandedminpx4vis035e50 | tegel 512 | overlap 64 | drempel 0.15',
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
description: 'Historisch profiel voor lokale controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
limitationMessage:
'Dit profiel vond historisch minder onterechte objecten, maar miste meer kleine gebouwen. De meting is geen onafhankelijke productbenchmark.',
},
{
id: 'conservative-review',
displayName: 'Conservatief controleprofiel',
modelAssetId: 'geointel-building-yolov8s-aoi1024bg512r3e50-pt',
confidenceThreshold: 0.35,
independentTestProven: false,
precision: 0.840006,
recall: 0.202135,
f1: 0.32086574003576274,
positiveSampleCount: 7,
maxBackgroundDetections: 55,
backgroundSampleCount: 9,
backgroundGate: 'hard-negative matrix (negen monsters, 198 detecties in totaal)',
evidenceReference: 'conservatieve zeven-AOI matrix + negen-monster hard-negative matrix aoi1024bg512r3e50 | drempel 0.35',
validationScope: 'historische operatorcontrole in Mol en de Kempen; ruimtelijke onafhankelijkheid niet bewezen',
description: 'Historisch profiel voor lokale controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
limitationMessage:
'De historische precisie gaat samen met zeer lage herkenningsgraad. De hoge precisie geldt binnen bebouwde AOI-gebieden: op de negen hard-negative monsters produceerde dit profiel nog 198 achtergronddetecties, met 55 in het zwaarste monster. Resultaten blijven controlebewijs en geen automatische waarheid of releasebewijs.',
},
]
@@ -0,0 +1,496 @@
import { useMemo, useState } from 'react'
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../../types'
interface ExportCenterProps {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
selectedDetectionRunId: string
selectedSegmentationRunId: string
exports: ExportRead[]
latestExport: ExportCreateResponse | null
exportError: string | null
loadingExports: boolean
exporting: boolean
onRefresh: () => void
onExportDataset: () => void
onExportDetectionRun: () => void
onExportSegmentationRun: () => void
onExportProjectMetadata: () => void
onExportProjectReport: () => void
onPreviewContent: (exportId: string) => void
onDownload: (exportId: string) => void
}
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
function canPreviewJson(item: ExportRead): boolean {
if (item.export_type === 'project_report_html') {
return false
}
if (item.metadata_json?.format === 'html') {
return false
}
return true
}
function compactPath(value: string): string {
const parts = value.split('/').filter(Boolean)
if (parts.length <= 2) {
return value
}
return parts.slice(-2).join('/')
}
function formatExportType(value: string): string {
const labels: Record<string, string> = {
vector_selection_geojson: 'Kaartselectie (GeoJSON)',
map_analysis_json: 'Gebiedsanalyse (JSON)',
map_evolution_json: 'Historische vergelijking (JSON)',
dataset_geojson: 'Kaartlaag (GeoJSON)',
detection_geojson: 'Gebouwdetecties - controlelaag (GeoJSON)',
segmentation_geojson: 'Segmentaties (GeoJSON)',
project_metadata_json: 'Werkruimtedata (JSON)',
project_report_html: 'Projectrapport (HTML)',
}
if (labels[value]) {
return labels[value]
}
return value
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function formatExportCreated(value?: string | null): string {
if (!value) {
return 'Tijdstip onbekend'
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('nl-BE')
}
function getLatestExportByType(exports: ExportRead[], exportType: string): ExportRead | null {
return exports.find((item) => item.export_type === exportType) ?? null
}
function exportMatchesSearch(item: ExportRead, query: string): boolean {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
return true
}
return [item.export_type, item.status, item.id, item.storage_path]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedQuery))
}
export function ExportCenter({
selectedProjectId,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
exports,
latestExport,
exportError,
loadingExports,
exporting,
onRefresh,
onExportDataset,
onExportDetectionRun,
onExportSegmentationRun,
onExportProjectMetadata,
onExportProjectReport,
onPreviewContent,
onDownload,
}: ExportCenterProps): JSX.Element {
const [showAllExports, setShowAllExports] = useState(false)
const [exportTypeFilter, setExportTypeFilter] = useState('all')
const [exportStatusFilter, setExportStatusFilter] = useState('all')
const [exportSearchQuery, setExportSearchQuery] = useState('')
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length
const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length
const handoffStatus = selectedProjectId ? 'werkruimte gekozen' : 'kies een werkruimte'
const exportTypes = useMemo(() => Array.from(new Set(exports.map((item) => item.export_type))).sort(), [exports])
const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
const latestHandoffArtifacts = useMemo(
() => [
{
key: 'map_analysis_json',
label: 'Gebiedsanalyse',
detail: 'Server-side herberekende metingen van de kaartselectie',
item: getLatestExportByType(exports, 'map_analysis_json'),
},
{
key: 'map_evolution_json',
label: 'Historische vergelijking',
detail: 'Bewaarde evolutie van het geselecteerde gebied',
item: getLatestExportByType(exports, 'map_evolution_json'),
},
{
key: 'vector_selection_geojson',
label: 'Kaartselectie (GeoJSON)',
detail: 'Persistente objecten binnen de geselecteerde rechthoek',
item: getLatestExportByType(exports, 'vector_selection_geojson'),
},
{
key: 'project_report_html',
label: 'Leesbaar rapport',
detail: 'HTML-rapport om te bekijken en te delen',
item: getLatestExportByType(exports, 'project_report_html'),
},
{
key: 'project_metadata_json',
label: 'Werkruimtedata',
detail: 'Technische inventaris in JSON-formaat',
item: getLatestExportByType(exports, 'project_metadata_json'),
},
{
key: 'dataset_geojson',
label: 'Kaartlaag (GeoJSON)',
detail: 'De geselecteerde vector- of referentielaag',
item: getLatestExportByType(exports, 'dataset_geojson'),
},
{
key: 'detection_geojson',
label: 'Herkende gebouwen - controlelaag (GeoJSON)',
detail: 'Bewaarde geometrieën uit beeldanalyse',
item: getLatestExportByType(exports, 'detection_geojson'),
},
{
key: 'segmentation_geojson',
label: 'Segmentaties (GeoJSON)',
detail: 'Bewaarde segmentatiepolygonen',
item: getLatestExportByType(exports, 'segmentation_geojson'),
},
],
[exports],
)
const availableLatestArtifacts = latestHandoffArtifacts.filter((artifact) => artifact.item)
const filteredExports = useMemo(
() =>
exports.filter((item) => {
const typeMatches = exportTypeFilter === 'all' || item.export_type === exportTypeFilter
const statusMatches = exportStatusFilter === 'all' || item.status === exportStatusFilter
return typeMatches && statusMatches && exportMatchesSearch(item, exportSearchQuery)
}),
[exports, exportTypeFilter, exportStatusFilter, exportSearchQuery],
)
const visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)
const hiddenExportCount = Math.max(filteredExports.length - visibleExports.length, 0)
const hasActiveExportFilters = exportTypeFilter !== 'all' || exportStatusFilter !== 'all' || exportSearchQuery.trim().length > 0
return (
<section className="export-center" data-testid="export-center">
<div className="export-center-shell">
<div className="panel-title-row">
<div>
<h2>Downloads en rapporten</h2>
<p className="muted">Bewaar de geselecteerde kaartlaag, analyseresultaten of een leesbaar projectrapport.</p>
</div>
<span className="count-pill">{exports.length} bestanden</span>
</div>
<div className="export-summary-surface" aria-label="Samenvatting van de downloads">
<div className="quality-summary-grid">
<div>
<span>GeoJSON</span>
<strong>{geojsonExports}</strong>
</div>
<div>
<span>Rapporten</span>
<strong>{reportExports}</strong>
</div>
<div>
<span>Geselecteerde kaartlaag</span>
<strong>{selectedDataset?.name ?? 'geen'}</strong>
</div>
</div>
</div>
<div className="export-handoff-surface" aria-label="Gereedheid voor overdracht">
<div className="handoff-summary-card">
<div className="panel-title-row">
<div>
<h3>Klaar om te delen</h3>
<p className="muted">Controleer het juiste bestand voordat je gegevens buiten GeoIntel gebruikt.</p>
</div>
<span className="status-badge">{handoffStatus}</span>
</div>
<div className="handoff-readiness-grid">
<div>
<span>Vectorkaartlaag</span>
<strong>{canExportDataset ? selectedDataset?.name : 'geen beschikbaar'}</strong>
</div>
<div>
<span>Gebouwanalyse</span>
<strong>{selectedDetectionRunId || 'geen'}</strong>
</div>
<div>
<span>Segmentatieanalyse</span>
<strong>{selectedSegmentationRunId || 'geen'}</strong>
</div>
<div>
<span>Laatste bestand</span>
<strong>{latestExport ? formatExportType(latestExport.export_type) : 'geen'}</strong>
</div>
</div>
</div>
</div>
<div className="latest-artifact-surface" aria-label="Latest handoff artifacts">
<div>
<h3>Laatste downloads</h3>
<p className="muted">Open de recentste bestanden zonder in de volledige geschiedenis te zoeken.</p>
</div>
<div className="latest-artifact-grid">
{availableLatestArtifacts.map((artifact) => {
const item = artifact.item
return (
<div className={item ? 'latest-artifact-card' : 'latest-artifact-card latest-artifact-card-empty'} key={artifact.key}>
<span>{artifact.label}</span>
<strong>{item ? (item.status === 'ready' ? 'Beschikbaar' : item.status) : 'Nog niet gemaakt'}</strong>
<p>{item ? compactPath(item.storage_path) : artifact.detail}</p>
{item ? (
<>
<small>{formatExportCreated(item.created_at)}</small>
<div className="latest-artifact-actions">
{canPreviewJson(item) ? (
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
Bekijken
</button>
) : (
<span className="status-badge">alleen downloaden</span>
)}
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
Downloaden
</button>
</div>
</>
) : null}
</div>
)
})}
{availableLatestArtifacts.length === 0 ? (
<div className="result-state result-state-empty latest-download-empty">
<strong>Nog geen recente downloads</strong>
<p>De laatst bewaarde bestanden verschijnen hier.</p>
</div>
) : null}
</div>
</div>
<div className="export-actions-surface" aria-label="Acties om resultaten te bewaren">
<div className="panel-title-row">
<div>
<h3>Wat wil je bewaren?</h3>
<p className="muted">Maak een leesbaar rapport of download gegevens voor verder gebruik.</p>
</div>
</div>
<div className="export-action-grid">
<div className="handoff-action-grid">
<div className="handoff-action-card handoff-action-card-technical">
<span>Vernieuwen</span>
<strong>Bewaarde bestanden</strong>
<p>Laad de persistente downloads van de geselecteerde werkruimte opnieuw.</p>
<button
type="button"
className="secondary-action"
onClick={onRefresh}
disabled={!selectedProjectId || loadingExports}
data-testid="refresh-exports"
>
Downloads vernieuwen
</button>
</div>
<div className="handoff-action-card handoff-action-card-primary">
<span>Rapport</span>
<strong>Leesbaar projectoverzicht</strong>
<p>Maak een HTML-rapport met de beschikbare bronnen, controles en resultaten.</p>
<button type="button" className="primary-action" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
Projectrapport maken
</button>
</div>
<div className="handoff-action-card">
<span>Technische gegevens</span>
<strong>Projectgegevens (JSON)</strong>
<p>Download de volledige projectinventaris voor geautomatiseerde verwerking.</p>
<button
type="button"
className="secondary-action"
onClick={onExportProjectMetadata}
disabled={!selectedProjectId || exporting}
data-testid="export-project-metadata"
>
Projectgegevens downloaden
</button>
</div>
<div className="handoff-action-card">
<span>Kaartlaag</span>
<strong>Geselecteerde bron (GeoJSON)</strong>
<p>Download de actieve vector- of referentielaag.</p>
<button type="button" className="secondary-action" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
Kaartlaag downloaden
</button>
</div>
{selectedDetectionRunId ? <div className="handoff-action-card">
<span>Gebouwanalyse</span>
<strong>Herkende gebouwen - controlelaag (GeoJSON)</strong>
<p>Bewaar de persistente detectiegeometrie van de geselecteerde analyserun.</p>
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
Gebouwanalyse bewaren
</button>
</div> : null}
{selectedSegmentationRunId ? <div className="handoff-action-card">
<span>Segmentatieanalyse</span>
<strong>Segmentaties (GeoJSON)</strong>
<p>Bewaar de persistente polygonen van de geselecteerde segmentatierun.</p>
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
Segmentatie bewaren
</button>
</div> : null}
</div>
</div>
</div>
<div className="export-state-stack">
{loadingExports ? (
<div className="result-state result-state-loading">
<strong>Downloads laden.</strong>
<p>De bewaarde bestanden van deze werkruimte worden opgehaald.</p>
</div>
) : null}
{exportError ? (
<div className="result-state result-state-error">
<strong>Download kon niet worden gemaakt.</strong>
<p>{exportError}</p>
</div>
) : null}
{latestExport ? (
<div className="latest-export-card">
<span>Laatste download</span>
<strong>{formatExportType(latestExport.export_type)}</strong>
<p>{latestExport.path}</p>
</div>
) : null}
{exports.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Nog niets bewaard</strong>
<p>Maak een leesbaar rapport, een projectoverzicht, of bewaar de actieve kaartlaag.</p>
</div>
) : null}
</div>
<details className="export-history-surface export-history-disclosure" aria-label="Downloadgeschiedenis">
<summary>
<span>Downloadgeschiedenis</span>
<strong>{exports.length} bestanden</strong>
</summary>
<div className="export-history-body">
<div>
<h3>Bewaarde downloads</h3>
<p className="muted">Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.</p>
</div>
{exports.length > 0 ? (
<div className="export-history-controls" aria-label="Filters op de downloadgeschiedenis">
<label>
Downloads zoeken
<input
type="search"
placeholder="Type, id of pad"
value={exportSearchQuery}
onChange={(event) => setExportSearchQuery(event.target.value)}
/>
</label>
<label>
Type
<select value={exportTypeFilter} onChange={(event) => setExportTypeFilter(event.target.value)}>
<option value="all">Alle types</option>
{exportTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</label>
<label>
Status
<select value={exportStatusFilter} onChange={(event) => setExportStatusFilter(event.target.value)}>
<option value="all">Alle statussen</option>
{exportStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<button
type="button"
className="secondary-action"
onClick={() => {
setExportSearchQuery('')
setExportTypeFilter('all')
setExportStatusFilter('all')
setShowAllExports(false)
}}
disabled={!hasActiveExportFilters && !showAllExports}
>
Filters wissen
</button>
</div>
) : null}
{filteredExports.length > 10 ? (
<div className="list-limit-banner">
<span>
{visibleExports.length} van {filteredExports.length} passende downloads zichtbaar.
{hiddenExportCount > 0 ? ` ${hiddenExportCount} oudere bestanden verborgen.` : ' Alle passende bestanden zijn zichtbaar.'}
</span>
<button type="button" className="secondary-action" onClick={() => setShowAllExports((value) => !value)}>
{showAllExports ? 'Toon laatste 10' : 'Toon alle downloads'}
</button>
</div>
) : null}
{exports.length > 0 && filteredExports.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen downloads passen bij deze filters.</strong>
<p>Wis de zoekopdracht of filters om de recentste bestanden opnieuw te tonen.</p>
</div>
) : null}
<ul className="export-list">
{visibleExports.map((item) => (
<li className="export-card" key={item.id}>
<div className="export-card-header">
<div>
<span className="export-type-badge">{formatExportType(item.export_type)}</span>
<div className="entity-meta">
<span>download-id: {item.id}</span>
{item.analysis_run_id ? <span>analyserun: {item.analysis_run_id}</span> : null}
{item.created_at ? <span>gemaakt: {formatExportCreated(item.created_at)}</span> : null}
</div>
</div>
<span className={item.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>{item.status}</span>
</div>
<p title={item.storage_path}>{compactPath(item.storage_path)}</p>
<div className="button-row">
{canPreviewJson(item) ? (
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
JSON bekijken
</button>
) : (
<span className="status-badge">HTML alleen downloaden</span>
)}
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
Downloaden
</button>
</div>
</li>
))}
</ul>
</div>
</details>
</div>
</section>
)
}
@@ -0,0 +1,68 @@
interface ExportPreviewProps {
content: Record<string, unknown> | null
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${(value / (1024 * 1024)).toFixed(1)} MB`
}
export function ExportPreview({ content }: ExportPreviewProps): JSX.Element {
const previewJson = content ? JSON.stringify(content, null, 2) : ''
const previewStats = content
? {
rootKeys: Object.keys(content).length,
previewSize: formatBytes(new Blob([previewJson]).size),
rootType: Array.isArray(content) ? 'lijst' : 'object',
}
: null
return (
<section className="export-preview-panel">
<div className="panel-title-row">
<div>
<h2>Voorbeeld</h2>
<p className="muted">Bekijk hier de inhoud van een gekozen JSON- of GeoJSON-bestand.</p>
</div>
<span className="count-pill">voorbeeld</span>
</div>
{content ? (
<>
<div className="export-preview-summary" aria-label="Samenvatting van het voorbeeld">
<div className="export-preview-summary-card">
<span>Type inhoud</span>
<strong>{previewStats?.rootType}</strong>
</div>
<div className="export-preview-summary-card">
<span>Velden</span>
<strong>{previewStats?.rootKeys}</strong>
</div>
<div className="export-preview-summary-card">
<span>Grootte</span>
<strong>{previewStats?.previewSize}</strong>
</div>
</div>
<div className="export-preview-json-shell">
<div className="export-preview-json-toolbar">
<span>Bewaarde JSON-inhoud</span>
<span>{previewStats?.previewSize}</span>
</div>
<div className="export-preview-json-body">
<pre className="job-result">{previewJson}</pre>
</div>
</div>
</>
) : (
<div className="empty-state">
<strong>Nog geen bestand gekozen.</strong>
<p>Kies bij een JSON- of GeoJSON-download voor Bekijken om de bewaarde inhoud hier te openen.</p>
</div>
)}
</section>
)
}
@@ -0,0 +1,244 @@
import { useMemo, useState } from 'react'
import type {
AreaRead,
DetectionRead,
DetectionRunRead,
ExportCreateResponse,
ExportRead,
ProjectRead,
QualityCheckRead,
SegmentationRead,
SegmentationRunRead,
} from '../../types'
import { DatasetDetailPanel, type DatasetDetailPanelProps } from '../datasets/DatasetDetailPanel'
type InspectorTab = 'context' | 'dataset' | 'quality' | 'ai'
interface WorkbenchInspectorProps {
selectedProject: ProjectRead | null
selectedArea: AreaRead | null
selectedMapFeature: GeoJSON.Feature | null
areas: AreaRead[]
datasetsCount: number
qualityChecks: QualityCheckRead[]
exports: ExportRead[]
latestExport: ExportCreateResponse | null
detectionRuns: DetectionRunRead[]
selectedDetectionRunId: string
detectionItems: DetectionRead[]
segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string
segmentationItems: SegmentationRead[]
datasetDetailProps: DatasetDetailPanelProps
onOpenDataWorkspace: () => void
onOpenMapWorkspace: () => void
onOpenQualityWorkspace: () => void
onOpenExportsWorkspace: () => void
onOpenAiWorkspace: () => void
onClose: () => void
}
function formatValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return 'n.v.t.'
}
return String(value)
}
function InspectorField({ label, value }: { label: string; value: string | number | null | undefined }): JSX.Element {
return (
<div className="inspector-field">
<span>{label}</span>
<strong>{formatValue(value)}</strong>
</div>
)
}
export function WorkbenchInspector({
selectedProject,
selectedArea,
selectedMapFeature,
areas,
datasetsCount,
qualityChecks,
exports,
latestExport,
detectionRuns,
selectedDetectionRunId,
detectionItems,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
datasetDetailProps,
onOpenDataWorkspace,
onOpenMapWorkspace,
onOpenQualityWorkspace,
onOpenExportsWorkspace,
onOpenAiWorkspace,
onClose,
}: WorkbenchInspectorProps): JSX.Element {
const [activeTab, setActiveTab] = useState<InspectorTab>('context')
const selectedDetectionRun = useMemo(
() => detectionRuns.find((run) => run.id === selectedDetectionRunId) ?? null,
[detectionRuns, selectedDetectionRunId],
)
const selectedSegmentationRun = useMemo(
() => segmentationRuns.find((run) => run.id === selectedSegmentationRunId) ?? null,
[segmentationRuns, selectedSegmentationRunId],
)
const latestQualityCheck = qualityChecks[0] ?? null
const latestPersistedExport = exports[0] ?? null
const activeTabId = `inspector-tab-${activeTab}`
const activePanelId = `inspector-panel-${activeTab}`
const tabs: Array<{ key: InspectorTab; label: string }> = [
{ key: 'context', label: 'Context' },
{ key: 'dataset', label: 'Databron' },
{ key: 'quality', label: 'Kwaliteit en downloads' },
{ key: 'ai', label: 'Beeldanalyse' },
]
return (
<section className="workbench-inspector-panel" data-testid="workbench-inspector-panel">
<div className="inspector-header">
<div>
<p className="eyebrow">Context</p>
<h2>Details van de selectie</h2>
</div>
<button type="button" className="inspector-close" onClick={onClose} aria-label="Sluit details van de selectie">
Sluiten
</button>
</div>
<div className="inspector-tabs" role="tablist" aria-label="Onderdelen van de selectie">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={activeTab === tab.key ? 'inspector-tab inspector-tab-active' : 'inspector-tab'}
onClick={() => setActiveTab(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`inspector-panel-${tab.key}`}
id={`inspector-tab-${tab.key}`}
data-testid={`inspector-tab-${tab.key}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'context' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-card">
<h3>Werkruimte</h3>
<InspectorField label="Naam" value={selectedProject?.name} />
<InspectorField label="Regio" value={selectedProject?.region} />
<InspectorField label="Status" value={selectedProject?.status} />
<InspectorField label="Gebieden" value={areas.length} />
<InspectorField label="Databronnen" value={datasetsCount} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenDataWorkspace}>
Gegevens beheren
</button>
</div>
</div>
<div className="inspector-card">
<h3>Actief werkgebied</h3>
<InspectorField label="Naam" value={selectedArea?.name} />
<InspectorField label="Oppervlakte m²" value={selectedArea?.area_m2} />
<InspectorField label="Coördinatenstelsel" value={selectedArea?.original_crs} />
<InspectorField label="Geometrietype" value={selectedArea?.geometry?.type} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenMapWorkspace}>
Kaart openen
</button>
</div>
</div>
<div className="inspector-card">
<h3>Geselecteerd kaartobject</h3>
{selectedMapFeature ? (
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
) : (
<p className="muted">Er is geen kaartobject geselecteerd.</p>
)}
</div>
</div>
) : null}
{activeTab === 'dataset' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-action-bar">
<button type="button" className="secondary-action" onClick={onOpenDataWorkspace}>
Gegevenscatalogus
</button>
<button type="button" className="secondary-action" onClick={onOpenMapWorkspace}>
Kaartlaag
</button>
<button type="button" className="secondary-action" onClick={onOpenExportsWorkspace}>
Download
</button>
</div>
<DatasetDetailPanel {...datasetDetailProps} />
</div>
) : null}
{activeTab === 'quality' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-card">
<h3>Laatste kwaliteitscontrole</h3>
<InspectorField label="Type controle" value={latestQualityCheck?.check_type?.replaceAll('_', ' ')} />
<InspectorField label="Status" value={latestQualityCheck?.status} />
<InspectorField label="Score" value={latestQualityCheck?.score} />
<InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics?.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenQualityWorkspace}>
Kwaliteit openen
</button>
</div>
</div>
<div className="inspector-card">
<h3>Laatste download</h3>
<InspectorField label="Zojuist aangemaakt" value={latestExport?.export_type} />
<InspectorField label="Bewaard type" value={latestPersistedExport?.export_type} />
<InspectorField label="Status" value={latestPersistedExport?.status ?? latestExport?.status} />
<InspectorField label="Aantal downloads" value={exports.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenExportsWorkspace}>
Downloads openen
</button>
</div>
</div>
</div>
) : null}
{activeTab === 'ai' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-card">
<h3>Gebouwdetectie</h3>
<InspectorField label="Status" value={selectedDetectionRun?.status} />
<InspectorField label="Model" value={selectedDetectionRun?.model_name} />
<InspectorField label="Gevonden objecten" value={detectionItems.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenAiWorkspace}>
Beeldanalyse openen
</button>
</div>
</div>
<div className="inspector-card">
<h3>Segmentatie</h3>
<InspectorField label="Status" value={selectedSegmentationRun?.status} />
<InspectorField label="Model" value={selectedSegmentationRun?.model_name} />
<InspectorField label="Herkende vlakken" value={segmentationItems.length} />
<details className="technical-inline-details">
<summary>Technische analyseruns</summary>
<div className="entity-meta">
<span>Detectierun-ID: {selectedDetectionRunId || 'n.v.t.'}</span>
<span>Segmentatierun-ID: {selectedSegmentationRunId || 'n.v.t.'}</span>
</div>
</details>
</div>
</div>
) : null}
</section>
)
}
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { LiveAnalysisJourney, resolveLiveAnalysisJourney } from './LiveAnalysisJourney'
const baseState = {
selectionMode: false,
hasSelection: false,
sourceLoading: false,
sourceReady: false,
processing: false,
validating: false,
hasResult: false,
verified: false,
error: null,
}
describe('resolveLiveAnalysisJourney', () => {
it('follows real workflow state without assuming completion', () => {
expect(resolveLiveAnalysisJourney(baseState).currentLabel).toBe('Selecteer')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceLoading: true }).currentLabel).toBe('Bronnen')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, processing: true }).currentLabel).toBe('Verwerk')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true }).currentLabel).toBe('Controleer')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true, verified: true }).status).toBe('complete')
})
it('marks the reached stage as failed', () => {
const view = resolveLiveAnalysisJourney({
...baseState,
hasSelection: true,
sourceReady: true,
processing: true,
error: 'Bron tijdelijk niet beschikbaar.',
})
expect(view.status).toBe('error')
expect(view.steps[2]).toBe('error')
})
})
describe('LiveAnalysisJourney', () => {
it('announces verified evidence only when verification exists', () => {
render(
<LiveAnalysisJourney
{...baseState}
hasSelection
sourceReady
hasResult
verified
selectionLabel="Vrije kaartselectie"
sourceLabel="Gebouwen"
statusMessage="Analyse voltooid"
resultLabel="Kwaliteitsbewijs beschikbaar"
/>,
)
expect(screen.getAllByText('Kwaliteitsbewijs beschikbaar')).toHaveLength(2)
expect(screen.getByTestId('live-analysis-journey').getAttribute('data-stage')).toBe('controleer')
})
})
@@ -0,0 +1,97 @@
import { BadgeCheck, Database, MapPinned, ScanSearch, ShieldAlert } from 'lucide-react'
export type LiveAnalysisStepStatus = 'waiting' | 'active' | 'complete' | 'error'
export interface LiveAnalysisJourneyState {
selectionMode: boolean
hasSelection: boolean
sourceLoading: boolean
sourceReady: boolean
processing: boolean
validating: boolean
hasResult: boolean
verified: boolean
error: string | null
}
export interface LiveAnalysisJourneyView {
currentIndex: number
currentLabel: string
status: 'idle' | 'running' | 'complete' | 'error'
steps: LiveAnalysisStepStatus[]
}
const STEP_LABELS = ['Selecteer', 'Bronnen', 'Verwerk', 'Controleer'] as const
export function resolveLiveAnalysisJourney(state: LiveAnalysisJourneyState): LiveAnalysisJourneyView {
let currentIndex = 0
if (state.validating || state.verified || state.hasResult) currentIndex = 3
else if (state.processing) currentIndex = 2
else if (state.hasSelection) currentIndex = state.sourceLoading || !state.sourceReady ? 1 : 2
const status: LiveAnalysisJourneyView['status'] = state.error
? 'error'
: state.verified
? 'complete'
: state.sourceLoading || state.processing || state.validating
? 'running'
: 'idle'
const steps: LiveAnalysisStepStatus[] = [
state.hasSelection ? 'complete' : 'active',
state.hasSelection && state.sourceReady ? 'complete' : state.hasSelection ? 'active' : 'waiting',
state.hasResult ? 'complete' : state.processing || (state.hasSelection && state.sourceReady) ? 'active' : 'waiting',
state.verified ? 'complete' : state.validating || state.hasResult ? 'active' : 'waiting',
]
if (state.error) steps[currentIndex] = 'error'
return { currentIndex, currentLabel: STEP_LABELS[currentIndex], status, steps }
}
interface LiveAnalysisJourneyProps extends LiveAnalysisJourneyState {
selectionLabel: string
sourceLabel: string
statusMessage: string
resultLabel: string
}
const steps = [
{ label: 'Selecteer', icon: MapPinned },
{ label: 'Bronnen', icon: Database },
{ label: 'Verwerk', icon: ScanSearch },
{ label: 'Controleer', icon: BadgeCheck },
] as const
export function LiveAnalysisJourney({ selectionLabel, sourceLabel, statusMessage, resultLabel, ...state }: LiveAnalysisJourneyProps): JSX.Element {
const view = resolveLiveAnalysisJourney(state)
const detail = view.currentIndex === 0
? state.selectionMode ? 'Teken nu op de kaart' : selectionLabel
: view.currentIndex === 1
? sourceLabel
: view.currentIndex === 3 && state.verified
? resultLabel
: statusMessage
return (
<div className={`live-analysis-journey live-analysis-journey-${view.status}`} data-testid="live-analysis-journey" data-stage={view.currentLabel.toLocaleLowerCase('nl-BE')} aria-live="polite">
<div className="live-analysis-status-card">
<span className="live-analysis-kicker"><i aria-hidden="true" /> Live analyse</span>
<strong>{state.error ? 'Analyse onderbroken' : view.currentLabel}</strong>
<small>{state.error ?? detail}</small>
</div>
<ol className="live-analysis-steps" aria-label="Voortgang van de gebiedsanalyse">
{steps.map(({ label, icon: Icon }, index) => (
<li className={`live-analysis-step live-analysis-step-${view.steps[index]}`} key={label}>
<span><Icon aria-hidden="true" /></span>
<small>{label}</small>
</li>
))}
</ol>
<div className={`live-analysis-proof live-analysis-proof-${state.verified ? 'ready' : 'pending'}`}>
{state.error ? <ShieldAlert aria-hidden="true" /> : <BadgeCheck aria-hidden="true" />}
<span>{state.verified ? resultLabel : state.hasResult ? 'Resultaat wacht op controle' : 'Controleerbaar resultaat'}</span>
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,18 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ResultsPanelDismiss } from './MapExplorerView'
vi.mock('../GeoMap', () => ({ default: () => null }))
afterEach(() => cleanup())
describe('ResultsPanelDismiss', () => {
it('offers an explicit mobile-safe way to close the results drawer', () => {
const onClose = vi.fn()
render(<ResultsPanelDismiss onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Resultatenpaneel sluiten' }))
expect(onClose).toHaveBeenCalledOnce()
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,17 @@
import { MapAdvancedWorkbench } from './MapAdvancedWorkbench'
import { MapExplorerView } from './MapExplorerView'
import type { MapWorkspaceProps } from './mapWorkspaceProps'
import { useMapWorkspaceViewModel } from './useMapWorkspaceViewModel'
/**
* The map workspace has two render paths over one body of derived state: the
* map-first explorer, and the advanced workbench behind it. Both read the same
* view model, so the choice between them is all that is left here.
*/
export function MapWorkspace(props: MapWorkspaceProps): JSX.Element {
const view = useMapWorkspaceViewModel(props)
return view.advancedMode
? <MapAdvancedWorkbench props={props} view={view} />
: <MapExplorerView props={props} view={view} />
}
@@ -0,0 +1,37 @@
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { areasApi } from '../../services/api/areas'
import { MunicipalitySearch } from './MunicipalitySearch'
import type { AreaRead } from '../../types'
vi.mock('../../services/api/areas', () => ({
areasApi: { searchMunicipalities: vi.fn() },
}))
describe('MunicipalitySearch', () => {
beforeEach(() => {
vi.mocked(areasApi.searchMunicipalities).mockResolvedValue({
items: [{ niscode: '13025', name: 'Mol', name_nl: 'Mol', name_fr: 'Mol', name_de: 'Mol' }],
total: 1,
})
})
afterEach(() => cleanup())
it('searches the official municipality catalog and activates a result', async () => {
const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
const onActivate = vi.fn().mockResolvedValue(activated)
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={onActivate} />)
fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
})
it('presents municipality search as optional and keeps free selection visible', () => {
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={vi.fn()} />)
expect(screen.getByText('optioneel')).toBeTruthy()
expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
})
})
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react'
import { MapPin, Search, X } from 'lucide-react'
import { areasApi } from '../../services/api/areas'
import type { AreaRead, MunicipalitySearchItem } from '../../types'
interface MunicipalitySearchProps {
projectId: string | null
activeArea: AreaRead | null
disabled?: boolean
onActivate: (niscode: string) => Promise<AreaRead | null>
}
function areaDisplayName(area: AreaRead | null): string | null {
return area?.name.replace(/^Gemeente\s+/i, '').replace(/\s+-\s+NIS\s+\d+$/i, '') ?? null
}
export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
const [query, setQuery] = useState('')
const [results, setResults] = useState<MunicipalitySearchItem[]>([])
const [searching, setSearching] = useState(false)
const [activatingCode, setActivatingCode] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const searchSequence = useRef(0)
const activeMunicipality = areaDisplayName(activeArea)
useEffect(() => {
const normalized = query.trim()
const requestId = ++searchSequence.current
if (!projectId || normalized.length < 2) {
setResults([])
setSearching(false)
setError(null)
return
}
setSearching(true)
setError(null)
const timeout = window.setTimeout(() => {
void areasApi.searchMunicipalities(projectId, normalized)
.then((response) => {
if (requestId === searchSequence.current) setResults(response.items)
})
.catch((caught: unknown) => {
if (requestId === searchSequence.current) setError(caught instanceof Error ? caught.message : 'Gemeenten zoeken is niet gelukt')
})
.finally(() => {
if (requestId === searchSequence.current) setSearching(false)
})
}, 220)
return () => window.clearTimeout(timeout)
}, [projectId, query])
const activate = async (municipality: MunicipalitySearchItem) => {
setActivatingCode(municipality.niscode)
setError(null)
const area = await onActivate(municipality.niscode)
if (area) {
setQuery('')
setResults([])
} else {
setError('De officiële gemeentegrens kon niet worden geactiveerd.')
}
setActivatingCode(null)
}
return (
<section className="municipality-search" aria-label="Optioneel een gemeente zoeken">
<div className="municipality-search-copy">
<span className="municipality-shortcut"><MapPin aria-hidden="true" /> Snelkeuze</span>
<div>
<strong>Ga naar een gemeente <small>optioneel</small></strong>
<p>Zoek een officiële grens, of teken straks vrij op de kaart.</p>
</div>
</div>
<div className="municipality-search-control">
<label>
<span className="sr-only">Zoek op gemeentenaam of NIS-code</span>
<Search aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Gemeentenaam of NIS-code"
disabled={disabled || !projectId}
autoComplete="off"
data-testid="municipality-search-input"
/>
{query ? <button type="button" className="municipality-search-clear" onClick={() => setQuery('')} aria-label="Zoekterm wissen"><X aria-hidden="true" /></button> : null}
</label>
{query.trim().length >= 2 ? (
<div className="municipality-search-results" aria-label="Gevonden gemeenten">
{searching ? <p role="status">Gemeenten zoeken…</p> : null}
{!searching && results.length === 0 && !error ? <p>Geen officiële gemeente gevonden.</p> : null}
{results.map((municipality) => (
<button key={municipality.niscode} type="button" onClick={() => void activate(municipality)} disabled={activatingCode !== null}>
<span><strong>{municipality.name}</strong><small>NIS {municipality.niscode}</small></span>
<em>{activatingCode === municipality.niscode ? 'Laden…' : 'Gebruik grens'}</em>
</button>
))}
{error ? <p className="error" role="alert">{error}</p> : null}
</div>
) : null}
</div>
<div className="municipality-search-status">
<span>Actief werkgebied</span>
<strong>{activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}</strong>
</div>
</section>
)
}
@@ -0,0 +1,76 @@
import type { TemporalObservation } from '../../types'
interface TemporalTrendChartProps {
timeline: TemporalObservation[]
metricKey: string
}
const WIDTH = 560
const HEIGHT = 150
const PADDING_X = 34
const PADDING_Y = 24
function formatValue(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function TemporalTrendChart({ timeline, metricKey }: TemporalTrendChartProps): JSX.Element | null {
const observations = timeline.flatMap((observation) => {
const metric = observation.metrics.find((item) => item.metric_key === metricKey)
return metric ? [{ observation, metric }] : []
})
if (observations.length < 2) {
return null
}
const values = observations.map((item) => item.metric.value)
const minimum = Math.min(...values)
const maximum = Math.max(...values)
const range = maximum - minimum
const innerWidth = WIDTH - PADDING_X * 2
const innerHeight = HEIGHT - PADDING_Y * 2
const points = observations.map((item, index) => {
const x = PADDING_X + (index / (observations.length - 1)) * innerWidth
const normalized = range === 0 ? 0.5 : (item.metric.value - minimum) / range
const y = HEIGHT - PADDING_Y - normalized * innerHeight
return { ...item, x, y }
})
const pointString = points.map((point) => `${point.x},${point.y}`).join(' ')
const label = `${points[0].metric.label}: ${formatValue(points[0].metric.value, points[0].metric.unit)} tot ${formatValue(
points[points.length - 1].metric.value,
points[points.length - 1].metric.unit,
)}`
return (
<div className="geo-temporal-chart" aria-label={label}>
<div className="geo-temporal-chart-heading">
<div>
<span>Volledige tijdreeks</span>
<strong>{points[0].metric.label}</strong>
</div>
<span>{points.length} meetmomenten</span>
</div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label={label} preserveAspectRatio="none">
<line x1={PADDING_X} x2={WIDTH - PADDING_X} y1={HEIGHT - PADDING_Y} y2={HEIGHT - PADDING_Y} />
<polyline points={pointString} />
{points.map((point) => (
<g key={point.observation.dataset.id}>
<circle cx={point.x} cy={point.y} r="4" />
<text x={point.x} y={HEIGHT - 6} textAnchor="middle">
{new Date(point.observation.dataset.observed_at).getUTCFullYear()}
</text>
</g>
))}
</svg>
<div className="geo-temporal-chart-values">
{points.map((point) => (
<div key={point.observation.dataset.id}>
<span>{new Date(point.observation.dataset.observed_at).getUTCFullYear()}</span>
<strong>{formatValue(point.metric.value, point.metric.unit)}</strong>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,54 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ThemeSearchField } from './ThemeSearchField'
describe('ThemeSearchField', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('toont wat de gebruiker typt zonder daarop te wachten', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<ThemeSearchField waarde="" onChange={onChange} />)
const veld = screen.getByRole('searchbox')
fireEvent.change(veld, { target: { value: 'beb' } })
expect((veld as HTMLInputElement).value).toBe('beb')
// De boven­liggende werkruimte weet er nog niets van; die hertekende
// voorheen bij elke letter.
expect(onChange).not.toHaveBeenCalled()
})
it('geeft de waarde één keer door wanneer het typen stopt', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<ThemeSearchField waarde="" onChange={onChange} />)
const veld = screen.getByRole('searchbox')
for (const tekst of ['b', 'be', 'beb', 'bebo', 'bebou', 'bebouw']) {
fireEvent.change(veld, { target: { value: tekst } })
vi.advanceTimersByTime(40)
}
expect(onChange).not.toHaveBeenCalled()
vi.advanceTimersByTime(200)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('bebouw')
})
it('volgt een waarde die van buitenaf gewist wordt', () => {
const onChange = vi.fn()
const { rerender } = render(<ThemeSearchField waarde="bebouw" onChange={onChange} />)
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('bebouw')
rerender(<ThemeSearchField waarde="" onChange={onChange} />)
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('')
})
it('houdt een toegankelijke naam', () => {
render(<ThemeSearchField waarde="" onChange={vi.fn()} />)
expect(screen.getByRole('searchbox', { name: /zoek een thema/i })).toBeTruthy()
})
})
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react'
import { Search } from 'lucide-react'
interface ThemeSearchFieldProps {
waarde: string
onChange: (waarde: string) => void
}
/**
* Het zoekveld boven de themalijst, met zijn eigen invoertoestand.
*
* De filterwaarde zelf leeft in het viewmodel van de kaartwerkruimte, dat de
* hele werkruimte voedt. Rechtstreeks doorgeven betekende dat elke toetsaanslag
* de volledige boom hertekende, inclusief de kaart: gemeten 33 tot 58 ms per
* letter met lege data, en dat loopt op zodra er echt bronnen in staan.
*
* Wat de gebruiker typt blijft nu hier. Pas als het even stil is gaat de waarde
* naar boven. Typen voelt daardoor direct, terwijl de lijst een fractie later
* bijtrekt — wat bij zoeken ook het gewenste gedrag is, want filteren op elke
* losse letter levert toch geen bruikbaar tussenresultaat.
*/
const STILTE_MS = 160
export function ThemeSearchField({ waarde, onChange }: ThemeSearchFieldProps): JSX.Element {
const [invoer, setInvoer] = useState(waarde)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
// Wordt de waarde van buitenaf gewist (bijvoorbeeld bij een andere
// werkruimte), dan volgt het veld.
useEffect(() => {
setInvoer((huidig) => (huidig === waarde ? huidig : waarde))
}, [waarde])
useEffect(() => {
if (invoer === waarde) return
const teller = window.setTimeout(() => onChangeRef.current(invoer), STILTE_MS)
return () => window.clearTimeout(teller)
// waarde bewust niet in de lijst: die verandert door onze eigen melding.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoer])
return (
<label className="geo-theme-search">
<span className="sr-only">Zoek een thema of gegevensbron</span>
<Search aria-hidden="true" />
<input
type="search"
value={invoer}
onChange={(event) => setInvoer(event.target.value)}
placeholder="Zoek thema’s"
/>
</label>
)
}
@@ -0,0 +1,91 @@
/**
* Kaartsymbologie.
*
* De kleuren van de kaartlagen stonden als losse hexwaarden door GeoMap.tsx
* heen. Het waren framework-standaardkleuren die bij geen enkel token uit het
* designsysteem hoorden, en op de donkere operationele ondergrond vielen de
* donkere varianten volledig weg.
*
* Deze waarden horen bij de donkere werkstand en zijn afgestemd op de tokens
* in geointel-system.css, sectie "Operationeel donker". Ze staan hier als
* letterlijke waarden omdat MapLibre paint-eigenschappen geen CSS-variabelen
* kunnen lezen; wijzigt een token, dan wijzigt zijn tegenhanger hier mee.
*/
export const mapSymbology = {
/** Officiële grenzen. Komt overeen met --gi-brand-500. */
boundary: '#45cfb4',
boundaryStrong: '#38c0a5',
/** Verandering tussen twee meetmomenten. */
added: '#4cc48d',
removed: '#ec7d76',
modified: '#dda45e',
unchanged: '#6fb0dd',
/** Eigen ingeladen data. Oranje blijft de tegenkleur van het teal-merk. */
dataFill: '#fb923c',
dataLine: '#f97316',
/** Actieve selectie. Geel leest op elke ondergrond. */
selectionFill: '#fde047',
selectionLine: '#fbbf24',
/** Water en bathymetrie. */
waterFill: '#38bdf8',
waterLine: '#7dd3fc',
/** Modelresultaten uit beeldanalyse. */
detectionFill: '#a78bfa',
detectionLine: '#c4b5fd',
/** Randen van puntsymbolen, tegen elke vulkleur. */
pointStroke: '#ffffff',
/** Terugvalkleur wanneer een feature geen bekende klasse heeft. */
fallback: '#94a3b8',
} as const
export type KaartWerkstand = 'dark' | 'light'
/**
* De verf van de ondergrond.
*
* De tegels zijn consumentenkaarten: rode snelwegen, groene bossen,
* POI-drukte. In beide werkstanden worden ze ontkleurd zodat alleen de eigen
* data nog kleur draagt; het verschil zit in hoe ver ze gedempt worden.
*
* De featurekleuren hierboven blijven in beide werkstanden gelijk. Het zijn
* middentonen die op een lichte én een donkere ondergrond leesbaar zijn, en
* één set houdt de betekenis van een kleur constant wanneer een gebruiker
* tussen de twee wisselt.
*/
export function basemapPaint(werkstand: KaartWerkstand): Record<string, number> {
if (werkstand === 'light') {
return {
'raster-saturation': -0.62,
'raster-brightness-min': 0.32,
'raster-brightness-max': 1,
'raster-contrast': -0.14,
'raster-hue-rotate': 140,
'raster-opacity': 0.92,
}
}
return {
'raster-saturation': -0.78,
'raster-brightness-min': 0.03,
'raster-brightness-max': 0.44,
'raster-contrast': -0.08,
'raster-hue-rotate': 140,
'raster-opacity': 0.9,
}
}
export function basemapGround(werkstand: KaartWerkstand): string {
return werkstand === 'light' ? '#eef2f0' : '#0a100e'
}
export function huidigeWerkstand(): KaartWerkstand {
if (typeof document === 'undefined') return 'dark'
return document.body.dataset.theme === 'light' ? 'light' : 'dark'
}
@@ -0,0 +1,107 @@
/**
* The map workspace's props.
*
* Its own module so the view-model hook and both render paths can name the
* same shape without importing the component that renders them.
*/
import type { AreaRead, CoverageResolveResponse, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
export interface MapWorkspaceProps {
readOnly?: boolean
managementLocked?: boolean
sourceAcquisitionEnabled?: boolean
derivedDatasetWritesEnabled?: boolean
secondaryResultsContainer?: HTMLElement | null
selectedProjectId: string | null
projects: ProjectRead[]
areas: AreaRead[]
selectedMapAreaId: string
areaFeatureCollection: GeoJSON.FeatureCollection | null
mapFeatureCollection: GeoJSON.FeatureCollection | null
qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null
qualityEvidenceFeatureCount?: number
qualityEvidenceLoading?: boolean
qualityEvidenceError?: string | null
qualityEvidenceWarnings?: string[]
mapLayerLabel: string
mapLayerSourceLabel: string
mapLayerProvenance: string
mapLayerVisible: boolean
mapLayerOpacity: number
areaLayerVisible: boolean
areaLayerOpacity: number
mapFeatureCount: number
areaFeatureCount: number
viewportVectorEnabled: boolean
viewportVectorStatus: string | null
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
fitMapDataOnChange: boolean
mapContentMode: 'dataset' | 'analysis'
analysisLayerAvailable: boolean
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
mapSelectionResult: VectorSelectionResponse | null
mapSelectionLoading: boolean
mapSelectionError: string | null
coverage: CoverageResolveResponse | null
coverageLoading: boolean
coverageError: string | null
coverageDurationMs: number | null
coverageBudgetExceeded: boolean
workspaceLoading: boolean
workspaceError: string | null
selectionExporting: boolean
selectionExportError: string | null
latestSelectionExportPath: string | null
selectionDatasetSaving: boolean
selectionDatasetError: string | null
latestSelectionDataset: DatasetCreateResponse | null
latestSelectionDatasetName: string | null
mapQaReferenceDatasets: DatasetCreateResponse[]
selectedMapQaReferenceDatasetId: string
mapSelectionQaRunning: boolean
mapSelectionQaError: string | null
mapSelectionQaResult: QaComparisonResult | null
latestMapSelectionQualityCheckId: string | null
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
orthophotoAnalysisQuality: DetectionQaResult | null
orthophotoAnalysisDetectionCount: number | null
orthophotoProducts: OrthophotoProductRead[]
selectedOrthophotoProductKey: string
orthophotoResult: OrthophotoAcquisitionResult | null
orthophotoImageUrl: string | null
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onActivateMunicipality: (niscode: string) => Promise<AreaRead | null>
onSetContextSourceLabel: (label: string | null) => void
onSetContextLayerLabel: (label: string | null) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onSetAreaLayerVisible: (visible: boolean) => void
onSetAreaLayerOpacity: (opacity: number) => void
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSetMapContentMode: (mode: 'dataset' | 'analysis') => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onSelectOrthophotoProduct: (productKey: string) => void
onClearQualityEvidence?: () => void
onRefreshProjectData: () => Promise<unknown>
onOpenAssistant: () => void
onOpenExports: () => void
}
@@ -0,0 +1,612 @@
/**
* The map workspace's domain layer: which themes exist, which persisted dataset
* answers a theme, and how a dataset describes itself to an operator.
*
* This was ~590 lines at the top of MapWorkspace.tsx, above a 3.200-line
* component. None of it is React, all of it is testable on its own, and both
* render paths in that component read from it.
*/
import type {
CoverageStatus,
DatasetCreateResponse,
VectorSelectionBBox,
} from '../../types'
import type { MapThemeAcquisition } from '../../hooks/useMapThemeSelectionInsights'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
import {
deduplicateTemporalSnapshots,
isSelectionBoundedDataset,
selectedAreaCoverageZones,
selectionAnalysisScale,
selectionDimensions,
temporalDatasetAreaMatch,
} from './mapWorkspaceUtils'
export const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
export const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
export const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
export type DataThemeId =
| 'administrative'
| 'buildings'
| 'land_cover'
| 'space_occupation'
| 'open_space'
| 'population'
| 'forest'
| 'nature_value'
| 'agriculture'
| 'soil'
| 'water'
| 'bathymetry'
| 'flood_hazard'
| 'elevation'
| 'accessibility'
| 'services'
| 'roads'
| 'parcels'
| 'maritime_planning'
| 'marine_environment'
export interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
export interface TemporalSeriesGroup {
key: string
label: string
items: DatasetCreateResponse[]
}
export interface OnDemandMapProduct extends MapThemeAcquisition {
theme: DataThemeId
availabilityLabel: string
attribution: string
limitationMessage: string
coverageZones: string[]
}
export interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
acquisitionBboxes: VectorSelectionBBox[]
}
export function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return true
const dimensions = selectionDimensions(bbox)
if (product.kind === 'dhmv' || product.kind === 'spw_terrain' || product.kind === 'flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (product.kind === 'thematic_raster' || product.kind === 'walous') {
return dimensions.widthMetres <= 50_000
&& dimensions.heightMetres <= 50_000
&& dimensions.areaSquareMetres <= 2_800_000_000
}
return true
}
export const DATA_THEMES: DataTheme[] = [
{
id: 'administrative',
label: 'Bestuurlijke indeling',
shortLabel: 'Bestuursgebieden',
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
},
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'land_cover',
label: 'Landbedekking',
shortLabel: 'Landbedekking',
description: 'Fysieke en biologische bodembedekking uit een officieel regionaal classificatieraster.',
tokens: ['land_cover', 'land_cover_use', 'landbedekking', 'walous', 'occupation du sol'],
},
{
id: 'space_occupation',
label: 'Ruimtebeslag',
shortLabel: 'Ruimtebeslag',
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
},
{
id: 'open_space',
label: 'Open ruimte',
shortLabel: 'Open ruimte',
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
tokens: ['open_space', 'open ruimte', 'openruimte'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'nature_value',
label: 'Natuurwaarde',
shortLabel: 'BWK-oppervlakte',
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
},
{
id: 'agriculture',
label: 'Landbouw',
shortLabel: 'Landbouwgebruik',
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
},
{
id: 'soil',
label: 'Bodem',
shortLabel: 'Bodemkaart',
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'bathymetry',
label: 'Waterbodem',
shortLabel: 'Dwarsprofielen',
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
},
{
id: 'flood_hazard',
label: 'Overstroming',
shortLabel: 'Overstroomd oppervlak',
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
},
{
id: 'elevation',
label: 'Hoogte & reliëf',
shortLabel: 'Hoogte',
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
},
{
id: 'accessibility',
label: 'Bereikbaarheid',
shortLabel: 'Knooppuntwaarde',
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
},
{
id: 'services',
label: 'Voorzieningen',
shortLabel: 'Voorzieningenniveau',
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
{
id: 'maritime_planning',
label: 'Maritieme planning',
shortLabel: 'Plan- en gebruikszones',
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
},
{
id: 'marine_environment',
label: 'Mariene rapportagezones',
shortLabel: 'Zeegebieden',
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
},
]
export const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
administrative: 'admin',
buildings: 'buildings',
land_cover: 'land_cover_use',
space_occupation: 'land_cover_use',
open_space: 'land_cover_use',
population: 'population',
forest: 'land_cover_use',
nature_value: 'nature',
agriculture: 'land_cover_use',
soil: 'soil',
water: 'surface_water',
bathymetry: 'bathymetry',
flood_hazard: 'flood_climate',
elevation: 'elevation',
accessibility: 'roads',
services: 'population',
roads: 'roads',
parcels: 'parcels',
maritime_planning: 'maritime_planning',
marine_environment: 'marine_environment',
}
export function coverageStatusLabel(status: CoverageStatus): string {
const labels: Record<CoverageStatus, string> = {
operational: 'Beschikbaar',
partial: 'Gedeeltelijk',
not_configured: 'Niet gekoppeld',
unsupported: 'Niet ondersteund',
}
return labels[status]
}
export function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
belgium: 'België',
flanders: 'Vlaanderen',
wallonia: 'Wallonie',
brussels: 'Brussel',
belgian_north_sea: 'Belgische Noordzee',
territorial_sea: 'Territoriale zee',
exclusive_economic_zone: 'EEZ',
continental_shelf: 'Continentaal plat',
}
return labels[zone] ?? zone
}
export const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
administrative: { fill: '#5f6f7f', line: '#344554' },
buildings: { fill: '#d45f3d', line: '#9f3e24' },
land_cover: { fill: '#4f7b4f', line: '#315c39' },
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
open_space: { fill: '#267a46', line: '#175c32' },
population: { fill: '#7559a6', line: '#5b3f88' },
forest: { fill: '#347950', line: '#225f3b' },
nature_value: { fill: '#9a4f64', line: '#74364a' },
agriculture: { fill: '#7b8f32', line: '#53671d' },
soil: { fill: '#9a7040', line: '#6f4c27' },
water: { fill: '#2676a8', line: '#155b85' },
bathymetry: { fill: '#0e7490', line: '#164e63' },
flood_hazard: { fill: '#1597c2', line: '#075985' },
elevation: { fill: '#a57a4b', line: '#315f59' },
accessibility: { fill: '#0f766e', line: '#115e59' },
services: { fill: '#b66d16', line: '#854d0e' },
roads: { fill: '#6b7280', line: '#4b5563' },
parcels: { fill: '#a7792f', line: '#7d571f' },
maritime_planning: { fill: '#2f7f8f', line: '#145d6a' },
marine_environment: { fill: '#3475a3', line: '#1c557d' },
}
export function datasetSearchText(dataset: DatasetCreateResponse): string {
return [
dataset.name,
dataset.original_filename,
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
dataset.source_metadata?.['product_display_name'],
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
// Governed raster products have one unambiguous semantic theme. Matching
// them by generic substrings (for example "water" in "waterdiepte") would
// make a flood scenario replace the permanent surface-water layer.
if (dataset.source_name === 'vmm_flood_hazard') {
return theme.id === 'flood_hazard'
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
return theme.id === 'bathymetry'
}
if (dataset.source_name === 'spw_bathymetry') {
return theme.id === 'bathymetry'
}
if (['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')) {
return theme.id === 'elevation'
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dataset.source_metadata?.['theme'] === theme.id
}
if (dataset.source_name === 'spw_walous_land_cover') {
return theme.id === 'land_cover'
}
if (dataset.source_name === 'dov_soil_map') {
return theme.id === 'soil'
}
if (dataset.source_name === 'ngi_adminvector') {
return theme.id === 'administrative'
}
if (dataset.source_name === 'rbins_msp_2026') {
return theme.id === 'maritime_planning'
}
if (dataset.source_name === 'rbins_marine_reporting_units') {
return theme.id === 'marine_environment'
}
const searchText = datasetSearchText(dataset)
return theme.tokens.some((token) => searchText.includes(token))
}
export function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.dataset_type === 'raster'
&& ['digitaal_vlaanderen_dhmv', 'spw_terrain', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
)
}
export function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
)
}
export function datasetProductKey(dataset: DatasetCreateResponse): string {
return String(dataset.source_metadata?.['product_key'] ?? '')
}
export function datasetCoversSelectedArea(
dataset: DatasetCreateResponse,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): boolean {
if (isSelectionBoundedDataset(dataset.source_metadata)) {
return false
}
const selectedZones = selectedAreaCoverageZones(selectedAreaName)
const configuredZones = dataset.source_metadata?.['coverage_zones']
if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) {
const datasetZones = configuredZones.map((zone) => String(zone))
if (!selectedZones.some((zone) => datasetZones.includes(zone))) {
return false
}
}
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
if (isPartitionedBathymetry(dataset)) {
return regionalScope
? true
: Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
if (coverageScope !== 'municipality' || !dataset.area_id) {
return true
}
if (regionalScope) {
return true
}
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
export function rasterPartitionsForDataset(
datasets: DatasetCreateResponse[],
representative: DatasetCreateResponse | null,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope: boolean,
): DatasetCreateResponse[] {
if (!representative) {
return []
}
if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) {
return [representative]
}
const productKey = datasetProductKey(representative)
const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '')
return datasets
.filter(
(dataset) =>
dataset.source_name === representative.source_name
&& (
isPartitionedBathymetry(representative)
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
: datasetProductKey(dataset) === productKey
)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
)
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
}
export function pickThemeDataset(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): DatasetCreateResponse | null {
const candidates = datasets.filter(
(dataset) =>
datasetMatchesTheme(dataset, theme)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
)
candidates.sort((left, right) => {
const priorityScore = (dataset: DatasetCreateResponse) =>
(dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) +
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
(dataset.source_name === 'grb' ? 100_000 : 0) +
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_walous_land_cover' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_terrain' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0)
const priorityDifference = priorityScore(right) - priorityScore(left)
if (priorityDifference !== 0) return priorityDifference
const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime()
if (observedAtDifference !== 0) return observedAtDifference
const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime()
if (importedAtDifference !== 0) return importedAtDifference
return (right.feature_count ?? right.vector_summary?.feature_count ?? 0)
- (left.feature_count ?? left.vector_summary?.feature_count ?? 0)
})
return candidates[0] ?? null
}
export function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
return dataset
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
: null
}
export function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
?.source_metadata?.['temporal_series_label']
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
return configuredLabel
}
const first = items[0]
const source = first ? getDatasetDisplayName(first) : 'Tijdreeks'
const range = temporalRangeLabel(items)
return range ? `${source} (${range})` : source
}
export function listThemeTemporalSeries(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
): TemporalSeriesGroup[] {
const groups = new Map<string, DatasetCreateResponse[]>()
for (const dataset of datasets) {
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
continue
}
if (temporalDatasetAreaMatch(dataset, selectedAreaId) === 'other') {
continue
}
const items = groups.get(dataset.temporal_series_key) ?? []
items.push(dataset)
groups.set(dataset.temporal_series_key, items)
}
const series = Array.from(groups.entries())
.map(([key, items]) => {
const ordered = deduplicateTemporalSnapshots(items)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.filter((group) => group.items.length >= 2)
const sourcesWithExactAreaSeries = new Set(
series
.filter((group) => group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact'))
.map((group) => group.items[0]?.source_name)
.filter(Boolean),
)
return series
.filter((group) => {
const sourceName = group.items[0]?.source_name
if (!sourceName || !sourcesWithExactAreaSeries.has(sourceName)) return true
return group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact')
})
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
}
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
return latest(right) - latest(left)
})
}
export function formatObservationDate(value: string | null | undefined): string {
if (!value) {
return 'Geen peildatum'
}
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
export function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
const firstValue = items[0]?.observed_at
const lastValue = items[items.length - 1]?.observed_at
if (!firstValue || !lastValue) {
return null
}
const first = new Date(firstValue)
const last = new Date(lastValue)
if (first.getTime() === last.getTime()) {
return formatObservationDate(firstValue)
}
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
}
if (first.getUTCMonth() === last.getUTCMonth()) {
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
}
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
}
export function formatDatasetObservation(dataset: DatasetCreateResponse): string {
if (dataset.source_name === 'vmm_flood_hazard') {
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
return firstMeasurement && lastMeasurement
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
: 'historische profielmetingen'
}
if (dataset.source_name === 'spw_bathymetry') {
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
if (Number.isFinite(observationYear)) {
return `referentiejaar ${observationYear}`
}
}
if (dataset.source_name === 'spw_walous_land_cover') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
return Number.isFinite(observationYear) ? `WALOUS referentiejaar ${observationYear}` : 'WALOUS landbedekking'
}
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
}
return formatObservationDate(dataset.observed_at)
}
export function floodScenarioLabel(dataset: DatasetCreateResponse): string {
const configured = dataset.source_metadata?.['product_display_name']
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
}
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import {
bboxesEqual,
datasetIntersectsSelection,
deduplicateTemporalSnapshots,
isMunicipalityAreaName,
isSelectionBoundedDataset,
normalizeBboxFromCorners,
parseBboxInput,
persistedDatasetSupportsSelection,
productCoversZones,
resultMetricLabel,
selectedAreaCoverageZones,
selectionAnalysisScale,
selectionAreaSquareMetres,
selectionDimensions,
selectionFeatureLimit,
splitSelectionBbox,
temporalDatasetAreaMatch,
} from './mapWorkspaceUtils'
import type { VectorSelectionBBox, VectorSelectionResponse } from '../../types'
describe('map workspace selection guards', () => {
it('normalizes drag corners into an EPSG:4326 bbox', () => {
expect(normalizeBboxFromCorners([5.2, 51.3], [4.8, 50.9])).toEqual({
min_x: 4.8,
min_y: 50.9,
max_x: 5.2,
max_y: 51.3,
crs: 'EPSG:4326',
})
})
it('rejects empty, inverted and degenerate manual selections', () => {
expect(parseBboxInput({ min_x: '', min_y: '', max_x: '', max_y: '' })).toBeNull()
expect(parseBboxInput({ min_x: '5', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
expect(parseBboxInput({ min_x: '4', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
})
it('treats sub-nanodegree bbox drift as the same persisted selection', () => {
const bbox = normalizeBboxFromCorners([4.98, 51.15], [5.02, 51.19])
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-10 })).toBe(true)
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-5 })).toBe(false)
})
it('computes a positive bounded selection area and renders governed metrics', () => {
const bbox = normalizeBboxFromCorners([5.0, 51.0], [5.01, 51.01])
expect(selectionAreaSquareMetres(bbox)).toBeGreaterThan(700_000)
expect(selectionAreaSquareMetres(bbox)).toBeLessThan(900_000)
const result = {
feature_count: 12,
truncated: false,
summary: {
metric_key: 'forest_area',
metric_value: 14.236,
metric_unit: 'ha',
},
} as unknown as VectorSelectionResponse
expect(resultMetricLabel(result)).toBe('14,24 ha')
})
it('maps national work areas to regional provider zones without merging sources', () => {
expect(selectedAreaCoverageZones('Belgium land')).toEqual([
'belgium',
'flanders',
'wallonia',
'brussels',
])
expect(selectedAreaCoverageZones('RC Ardennes inland')).toEqual(['wallonia'])
expect(selectedAreaCoverageZones('Brussels-Capital Region')).toEqual(['brussels'])
expect(selectedAreaCoverageZones('Language boundary')).toEqual(['flanders', 'wallonia'])
expect(productCoversZones(['wallonia'], ['flanders', 'wallonia'])).toBe(true)
expect(productCoversZones(['brussels'], ['wallonia'])).toBe(false)
})
it('recognizes Dutch and release-golden municipality areas as local scope', () => {
expect(isMunicipalityAreaName('Gemeente Mol')).toBe(true)
expect(isMunicipalityAreaName('RC Golden - Mol municipality')).toBe(true)
expect(isMunicipalityAreaName('Flanders')).toBe(false)
expect(isMunicipalityAreaName('RC Golden - Kempen region')).toBe(false)
})
it('distinguishes selection-bounded acquisitions from reusable regional datasets', () => {
expect(isSelectionBoundedDataset({
geometry_clipped_to_selection: true,
bbox_epsg4326: [4.55, 50.58, 4.56, 50.59],
})).toBe(true)
expect(isSelectionBoundedDataset({
coverage_scope: 'bounded_selection',
bbox_epsg4326: [5.1, 51.17, 5.11, 51.18],
})).toBe(true)
expect(isSelectionBoundedDataset({
geometry_clipped_to_selection: false,
bbox_epsg4326: [2.5, 49.5, 6.4, 51.6],
})).toBe(false)
expect(isSelectionBoundedDataset({
geometry_clipped_to_selection: true,
})).toBe(false)
})
it('distinguishes exact, unscoped and foreign temporal work-area snapshots', () => {
expect(temporalDatasetAreaMatch({ area_id: 'wallonia-golden' }, 'wallonia-golden')).toBe('exact')
expect(temporalDatasetAreaMatch({ area_id: 'other-area' }, 'wallonia-golden')).toBe('other')
expect(temporalDatasetAreaMatch({ area_id: null }, 'wallonia-golden')).toBe('unscoped')
})
it('classifies local, regional and overview selections before provider calls', () => {
const bbox = (widthDegrees: number, heightDegrees: number): VectorSelectionBBox => ({
min_x: 5,
min_y: 51,
max_x: 5 + widthDegrees,
max_y: 51 + heightDegrees,
crs: 'EPSG:4326',
})
expect(selectionAnalysisScale(bbox(0.1, 0.1))).toBe('detail')
expect(selectionAnalysisScale(bbox(0.35, 0.25))).toBe('regional')
expect(selectionAnalysisScale(bbox(1, 1))).toBe('overview')
})
it('splits regional selections into provider-safe tiles without changing the outer bounds', () => {
const selection: VectorSelectionBBox = {
min_x: 5,
min_y: 51,
max_x: 5.5,
max_y: 51.35,
crs: 'EPSG:4326',
}
const tiles = splitSelectionBbox(selection)
expect(tiles.length).toBeGreaterThan(1)
expect(Math.min(...tiles.map((tile) => tile.min_x))).toBe(selection.min_x)
expect(Math.min(...tiles.map((tile) => tile.min_y))).toBe(selection.min_y)
expect(Math.max(...tiles.map((tile) => tile.max_x))).toBe(selection.max_x)
expect(Math.max(...tiles.map((tile) => tile.max_y))).toBe(selection.max_y)
for (const tile of tiles) {
const dimensions = selectionDimensions(tile)
expect(dimensions.widthMetres).toBeLessThanOrEqual(18_100)
expect(dimensions.heightMetres).toBeLessThanOrEqual(18_100)
}
})
it('refuses an unbounded detail fan-out', () => {
const selection: VectorSelectionBBox = {
min_x: 4,
min_y: 50,
max_x: 6,
max_y: 52,
crs: 'EPSG:4326',
}
expect(() => splitSelectionBbox(selection)).toThrow('detailpartities')
})
it('keeps persisted high-resolution rasters out of overview selections', () => {
const overview: VectorSelectionBBox = {
min_x: 2.5,
min_y: 49.5,
max_x: 6.4,
max_y: 51.5,
crs: 'EPSG:4326',
}
expect(persistedDatasetSupportsSelection(
{ dataset_type: 'raster', source_name: 'digitaal_vlaanderen_dhmv' },
overview,
)).toBe(false)
expect(persistedDatasetSupportsSelection(
{ dataset_type: 'vector', source_name: 'statbel' },
overview,
)).toBe(true)
expect(selectionFeatureLimit(overview)).toBe(25)
expect(selectionFeatureLimit({ ...overview, min_x: 5, max_x: 5.1, min_y: 51, max_y: 51.1 })).toBe(1000)
})
it('uses persisted raster partitions only where their recorded bounds overlap', () => {
const molPartition = { source_metadata: { bbox_epsg4326: [5.03, 51.15, 5.24, 51.32] } }
expect(datasetIntersectsSelection(molPartition, {
min_x: 5.08,
min_y: 51.17,
max_x: 5.12,
max_y: 51.2,
crs: 'EPSG:4326',
})).toBe(true)
expect(datasetIntersectsSelection(molPartition, {
min_x: 4.3,
min_y: 50.8,
max_x: 4.4,
max_y: 50.9,
crs: 'EPSG:4326',
})).toBe(false)
})
it('counts one canonical temporal snapshot per official observation date', () => {
const snapshots = deduplicateTemporalSnapshots([
{ id: 'old-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-19T00:00:00Z' },
{ id: 'new-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
{ id: 'year-2022', observed_at: '2022-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
])
expect(snapshots.map((dataset) => dataset.id)).toEqual(['year-2022', 'new-2025'])
})
})
@@ -0,0 +1,438 @@
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import type {
DatasetCreateResponse,
ProjectRead,
VectorSelectionBBox,
VectorSelectionMetric,
VectorSelectionResponse,
} from '../../types'
import {
FLANDERS_WORKSPACE_LABEL,
FLANDERS_WORKSPACE_PROJECT_NAME,
} from '../../config/primaryFocus'
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
export function selectedAreaCoverageZones(areaName: string | null | undefined): string[] | null {
const normalized = String(areaName ?? '').toLowerCase()
if (!normalized) return null
if (normalized.includes('coast land-sea')) return ['flanders', 'belgian_north_sea']
if (normalized.includes('language boundary')) return ['flanders', 'wallonia']
if (normalized.includes('north sea')) {
return ['belgian_north_sea', 'territorial_sea', 'exclusive_economic_zone', 'continental_shelf']
}
if (normalized.includes('territorial sea')) return ['territorial_sea']
if (normalized.includes('exclusive economic zone')) return ['exclusive_economic_zone']
if (normalized.includes('continental shelf')) return ['continental_shelf']
if (normalized.includes('brussels')) return ['brussels']
if (normalized.includes('wallonia') || normalized.includes('ardennes')) return ['wallonia']
if (normalized.includes('flanders') || normalized.includes('mol') || normalized.includes('kempen')) return ['flanders']
if (normalized.includes('belgium land')) return ['belgium', 'flanders', 'wallonia', 'brussels']
return null
}
export function isMunicipalityAreaName(areaName: string | null | undefined): boolean {
const normalized = String(areaName ?? '').trim()
return /^Gemeente\s/i.test(normalized) || /\bmunicipality$/i.test(normalized)
}
export function productCoversZones(productZones: string[], selectedZones: string[] | null): boolean {
return selectedZones === null || selectedZones.some((zone) => productZones.includes(zone))
}
export function isSelectionBoundedDataset(
sourceMetadata: Record<string, unknown> | null | undefined,
): boolean {
const boundedScope = sourceMetadata?.['geometry_clipped_to_selection'] === true
|| sourceMetadata?.['coverage_scope'] === 'bounded_selection'
return boundedScope
&& Array.isArray(sourceMetadata?.['bbox_epsg4326'])
&& sourceMetadata['bbox_epsg4326'].length === 4
}
export type TemporalAreaMatch = 'exact' | 'unscoped' | 'other'
export function temporalDatasetAreaMatch(
dataset: Pick<DatasetCreateResponse, 'area_id'>,
selectedAreaId: string | null,
): TemporalAreaMatch {
if (!selectedAreaId || !dataset.area_id) return 'unscoped'
return dataset.area_id === selectedAreaId ? 'exact' : 'other'
}
export function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
}
if (project.name === KEMPEN_PROJECT_NAME) {
return 'Kempen (28 gemeenten)'
}
if (project.name === FLANDERS_WORKSPACE_PROJECT_NAME) {
return FLANDERS_WORKSPACE_LABEL
}
return project.name
}
export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
}
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
return Math.max(0, widthMetres * heightMetres)
}
export interface SelectionDimensions {
widthMetres: number
heightMetres: number
areaSquareMetres: number
}
export type SelectionAnalysisScale = 'detail' | 'regional' | 'overview'
export function selectionDimensions(bbox: VectorSelectionBBox): SelectionDimensions {
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = Math.max(0, (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians))
const heightMetres = Math.max(0, (bbox.max_y - bbox.min_y) * 110_574)
return {
widthMetres,
heightMetres,
areaSquareMetres: widthMetres * heightMetres,
}
}
export function selectionAnalysisScale(bbox: VectorSelectionBBox): SelectionAnalysisScale {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const longestSide = Math.max(widthMetres, heightMetres)
if (longestSide <= 20_000) return 'detail'
if (longestSide <= 50_000) return 'regional'
return 'overview'
}
export function persistedDatasetSupportsSelection(
dataset: { dataset_type: string; source_name?: string | null },
bbox: VectorSelectionBBox,
): boolean {
if (dataset.dataset_type !== 'raster') return true
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return false
const dimensions = selectionDimensions(bbox)
if (dataset.source_name === 'digitaal_vlaanderen_dhmv' || dataset.source_name === 'spw_terrain' || dataset.source_name === 'vmm_flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dimensions.areaSquareMetres <= 2_800_000_000
}
return scale === 'detail'
}
export function datasetIntersectsSelection(
dataset: { source_metadata?: Record<string, unknown> | null },
bbox: VectorSelectionBBox,
): boolean {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
if (!Array.isArray(bounds) || bounds.length !== 4) {
return true
}
const [minX, minY, maxX, maxY] = bounds.map(Number)
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return true
}
return !(
bbox.max_x < minX
|| bbox.min_x > maxX
|| bbox.max_y < minY
|| bbox.min_y > maxY
)
}
export function deduplicateTemporalSnapshots<
T extends {
id: string
observed_at?: string | null
imported_at?: string | null
created_at?: string | null
},
>(datasets: T[]): T[] {
const byObservation = new Map<string, T>()
for (const dataset of datasets) {
if (!dataset.observed_at) continue
const current = byObservation.get(dataset.observed_at)
const recency = new Date(dataset.imported_at ?? dataset.created_at ?? 0).getTime()
const currentRecency = new Date(current?.imported_at ?? current?.created_at ?? 0).getTime()
if (!current || recency > currentRecency || (recency === currentRecency && dataset.id > current.id)) {
byObservation.set(dataset.observed_at, dataset)
}
}
return Array.from(byObservation.values()).sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
}
export function selectionFeatureLimit(bbox: VectorSelectionBBox): number {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return 25
if (scale === 'regional') return 250
return 1000
}
export function splitSelectionBbox(
bbox: VectorSelectionBBox,
maxTileSideMetres = 18_000,
maxTiles = 16,
): VectorSelectionBBox[] {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const columns = Math.max(1, Math.ceil(widthMetres / maxTileSideMetres))
const rows = Math.max(1, Math.ceil(heightMetres / maxTileSideMetres))
if (columns * rows > maxTiles) {
throw new Error(
`De selectie vereist ${columns * rows} detailpartities; maximaal ${maxTiles} zijn toegestaan.`,
)
}
const longitudeStep = (bbox.max_x - bbox.min_x) / columns
const latitudeStep = (bbox.max_y - bbox.min_y) / rows
const tiles: VectorSelectionBBox[] = []
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
tiles.push({
min_x: bbox.min_x + longitudeStep * column,
min_y: bbox.min_y + latitudeStep * row,
max_x: column === columns - 1 ? bbox.max_x : bbox.min_x + longitudeStep * (column + 1),
max_y: row === rows - 1 ? bbox.max_y : bbox.min_y + latitudeStep * (row + 1),
crs: 'EPSG:4326',
})
}
}
return tiles
}
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) {
return false
}
const tolerance = 1e-9
return (
Math.abs(left.min_x - right.min_x) < tolerance
&& Math.abs(left.min_y - right.min_y) < tolerance
&& Math.abs(left.max_x - right.max_x) < tolerance
&& Math.abs(left.max_y - right.max_y) < tolerance
)
}
export function formatArea(areaSquareMetres: number | null): string {
if (areaSquareMetres === null) {
return 'Nog niet geselecteerd'
}
if (areaSquareMetres >= 1_000_000) {
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
}
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
}
export function resultCountLabel(result: VectorSelectionResponse): string {
const total = result.total_feature_count ?? result.feature_count
return result.truncated && result.total_feature_count == null
? `${result.feature_count.toLocaleString('nl-BE')}+`
: total.toLocaleString('nl-BE')
}
export function resultMetricLabel(result: VectorSelectionResponse): string {
if (!result.summary) {
return resultCountLabel(result)
}
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
}
export function selectionMetricLabel(metric: VectorSelectionMetric): string {
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
}
export function formatTemporalMetric(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function readablePropertyName(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
const points: Array<[number, number]> = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
points.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
if ('coordinates' in (geometry ?? {})) {
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
}
return points
}
function formatCoordinate(value: number): string {
return Number.isFinite(value) ? value.toFixed(6) : 'n.v.t.'
}
export function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
const points = collectGeometryPoints(feature?.geometry)
if (!feature?.geometry || points.length === 0) {
return {
bboxLabel: 'n.v.t.',
coordinateCount: 0,
geometryType: feature?.geometry?.type ?? 'geen',
}
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
Math.max(...xs),
)}, ${formatCoordinate(Math.max(...ys))}`
return {
bboxLabel,
coordinateCount: points.length,
geometryType: feature.geometry.type,
}
}
export function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const bounds = featureCollectionBounds(collection)
if (!bounds) {
return null
}
return {
min_x: bounds.minX,
min_y: bounds.minY,
max_x: bounds.maxX,
max_y: bounds.maxY,
crs: 'EPSG:4326',
}
}
export function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
const points = collectGeometryPoints(feature?.geometry)
if (points.length === 0) {
return null
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
return {
min_x: Math.min(...xs),
min_y: Math.min(...ys),
max_x: Math.max(...xs),
max_y: Math.max(...ys),
crs: 'EPSG:4326',
}
}
export function normalizeBboxFromCorners(
first: [number, number],
second: [number, number],
): VectorSelectionBBox {
return {
min_x: Math.min(first[0], second[0]),
min_y: Math.min(first[1], second[1]),
max_x: Math.max(first[0], second[0]),
max_y: Math.max(first[1], second[1]),
crs: 'EPSG:4326',
}
}
export function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
if (!bbox) {
return 'n.v.t.'
}
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
export function formatPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
export function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
min_y: bbox ? String(bbox.min_y) : '',
max_x: bbox ? String(bbox.max_x) : '',
max_y: bbox ? String(bbox.max_y) : '',
}
}
export function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
const min_x = Number(input.min_x)
const min_y = Number(input.min_y)
const max_x = Number(input.max_x)
const max_y = Number(input.max_y)
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
return null
}
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
}
export function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [feature],
}
}
export function safeFileStem(value: unknown): string {
const stem = String(value ?? 'selected-feature')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return stem || 'selected-feature'
}
function fallbackCopyText(text: string): void {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
export function copyText(text: string): void {
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
return
}
fallbackCopyText(text)
}
export function downloadJsonFile(
filename: string,
payload: unknown,
contentType = 'application/json',
): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,60 @@
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelSelector, type ModelSelectionOption } from './ModelSelector'
const options: ModelSelectionOption[] = [
{ id: 'fast', name: 'Snel lokaal model', description: 'Voor korte vragen.', status: 'available', tone: 'fast', technicalName: 'qwen:9b' },
{ id: 'offline', name: 'Niet geconfigureerd', status: 'unavailable', statusLabel: 'Niet beschikbaar' },
]
describe('ModelSelector', () => {
beforeEach(() => {
HTMLDialogElement.prototype.showModal = function showModal() { this.setAttribute('open', '') }
HTMLDialogElement.prototype.close = function close() { this.removeAttribute('open') }
})
afterEach(() => cleanup())
it('opens the selector and returns an available model choice', () => {
const onChange = vi.fn()
render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />)
const trigger = screen.getByRole('button', { name: /Automatisch aanbevolen/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Automatisch aanbevolen/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect(onChange).toHaveBeenCalledWith('fast')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
})
it('keeps unavailable runtime models disabled', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true)
})
it('closes predictably and restores trigger focus after close or cancel', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Modelkeuze sluiten' }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
fireEvent(dialog, new Event('cancel', { bubbles: false, cancelable: true }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(dialog.hasAttribute('open')).toBe(false)
expect(document.activeElement).toBe(trigger)
})
})
@@ -0,0 +1,206 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Bot, Check, ChevronDown, Cpu, Gauge, RefreshCw, ShieldCheck, Sparkles, X } from 'lucide-react'
export type ModelSelectionTone = 'recommended' | 'fast' | 'analytical' | 'specialized'
export interface ModelSelectionOption {
id: string
name: string
description?: string
recommendation?: string
status: 'available' | 'unavailable' | 'loading'
statusLabel?: string
tone?: ModelSelectionTone
provider?: string
environment?: string
speed?: string
capability?: string
context?: string
technicalName?: string
details?: string[]
}
interface ModelSelectorProps {
label: string
value: string
options: ModelSelectionOption[]
onChange: (value: string) => void
loading?: boolean
error?: string | null
disabled?: boolean
onRefresh?: () => void
automaticOption?: ModelSelectionOption
advancedLabel?: string
}
const toneLabels: Record<ModelSelectionTone, string> = {
recommended: 'Aanbevolen',
fast: 'Snel',
analytical: 'Analytisch',
specialized: 'Gespecialiseerd',
}
export function ModelSelector({
label,
value,
options,
onChange,
loading = false,
error = null,
disabled = false,
onRefresh,
automaticOption,
advancedLabel = 'Concrete modellen',
}: ModelSelectorProps): JSX.Element {
const dialogRef = useRef<HTMLDialogElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const dialogId = useId()
const [isOpen, setIsOpen] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const allOptions = useMemo(
() => automaticOption ? [automaticOption, ...options] : options,
[automaticOption, options],
)
const selected = allOptions.find((option) => option.id === value)
?? allOptions.find((option) => option.status === 'available')
?? null
useEffect(() => {
if (!isOpen || !dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]:not(:disabled)')
const firstAvailableButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"]:not(:disabled)')
;(selectedButton ?? firstAvailableButton ?? closeButtonRef.current)?.focus()
}, [isOpen, showAdvanced, value])
const openDialog = () => {
const dialog = dialogRef.current
if (!dialog || dialog.open) return
setShowAdvanced(options.some((option) => option.id === value))
dialog.showModal()
setIsOpen(true)
}
const closeDialog = () => {
if (dialogRef.current?.open) dialogRef.current.close()
setIsOpen(false)
triggerRef.current?.focus()
}
const select = (option: ModelSelectionOption) => {
if (option.status !== 'available') return
onChange(option.id)
closeDialog()
}
return (
<div className="model-selector">
<span className="model-selector-label">{label}</span>
<button
ref={triggerRef}
type="button"
className="model-selector-trigger"
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={dialogId}
disabled={disabled || loading || allOptions.length === 0}
onClick={openDialog}
>
<span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span>
<span>
<strong>{loading ? 'Modellen controleren…' : selected?.name ?? 'Geen model beschikbaar'}</strong>
<small>{selected?.description ?? (error ? 'Modelinformatie kon niet worden geladen' : 'Kies een beschikbaar model')}</small>
</span>
<ChevronDown aria-hidden="true" />
</button>
<dialog
id={dialogId}
ref={dialogRef}
className="model-selector-dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
closeDialog()
}}
onClose={() => {
setIsOpen(false)
triggerRef.current?.focus()
}}
>
<div className="model-selector-dialog-header">
<div>
<span className="section-kicker">Taakgerichte modelkeuze</span>
<h2 id={titleId}>Kies hoe GeoIntel analyseert</h2>
<p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p>
</div>
<button ref={closeButtonRef} type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={closeDialog}>
<X aria-hidden="true" />
</button>
</div>
{error ? <div className="result-state result-state-error" role="alert"><strong>Modellen niet beschikbaar</strong><p>{error}</p></div> : null}
{loading ? <div className="model-selector-loading" role="status"><RefreshCw aria-hidden="true" /><span>Beschikbaarheid wordt gecontroleerd…</span></div> : null}
{!loading && !error && allOptions.length === 0 ? <div className="result-state result-state-empty"><strong>Geen bruikbaar model gevonden</strong><p>Controleer de lokale modelomgeving en probeer opnieuw.</p></div> : null}
<div className="model-selector-options" role="radiogroup" aria-label={label}>
{automaticOption ? <ModelOptionCard option={automaticOption} checked={value === automaticOption.id} onSelect={select} /> : null}
{options.length > 0 ? (
<details className="model-selector-advanced" open={showAdvanced} onToggle={(event) => setShowAdvanced(event.currentTarget.open)}>
<summary><span>{advancedLabel}</span><small>{options.filter((option) => option.status === 'available').length} beschikbaar</small></summary>
<div className="model-selector-advanced-grid">
{options.map((option) => <ModelOptionCard key={option.id} option={option} checked={value === option.id} onSelect={select} />)}
</div>
</details>
) : null}
</div>
<div className="model-selector-dialog-footer">
<span><ShieldCheck aria-hidden="true" /> Beschikbaarheid komt rechtstreeks uit de GeoIntel-runtime.</span>
{onRefresh ? <button type="button" className="secondary-action" onClick={onRefresh} disabled={loading}><RefreshCw aria-hidden="true" /> Opnieuw controleren</button> : null}
</div>
</dialog>
</div>
)
}
function ModelOptionCard({ option, checked, onSelect }: { option: ModelSelectionOption; checked: boolean; onSelect: (option: ModelSelectionOption) => void }): JSX.Element {
const unavailable = option.status !== 'available'
const tone = option.tone ?? 'specialized'
const hasTechnicalDetails = Boolean(option.technicalName || option.provider || option.details?.length)
return (
<div className={`model-option-card model-option-${tone}${checked ? ' model-option-selected' : ''}`}>
<button
type="button"
className="model-option-select"
role="radio"
aria-checked={checked}
disabled={unavailable}
onClick={() => onSelect(option)}
>
<span className="model-option-icon">{tone === 'recommended' ? <Sparkles aria-hidden="true" /> : tone === 'fast' ? <Gauge aria-hidden="true" /> : <Cpu aria-hidden="true" />}</span>
<span className="model-option-copy">
<span className="model-option-heading"><strong>{option.name}</strong><em>{option.statusLabel ?? (unavailable ? 'Niet beschikbaar' : toneLabels[tone])}</em></span>
{option.description ? <span>{option.description}</span> : null}
{option.recommendation ? <small>{option.recommendation}</small> : null}
<span className="model-option-facts">
{option.speed ? <small>Snelheid: {option.speed}</small> : null}
{option.capability ? <small>Analyse: {option.capability}</small> : null}
{option.context ? <small>Context: {option.context}</small> : null}
{option.environment ? <small>{option.environment}</small> : null}
</span>
</span>
<span className="model-option-check">{checked ? <Check aria-hidden="true" /> : null}</span>
</button>
{hasTechnicalDetails ? (
<details className="model-option-technical">
<summary>Technische informatie</summary>
{option.technicalName ? <small>Model: {option.technicalName}</small> : null}
{option.provider ? <small>Runtime: {option.provider}</small> : null}
{option.details?.map((detail) => <small key={detail}>{detail}</small>)}
</details>
) : null}
</div>
)
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { DetectionModelCapability } from '../../types'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from './modelOptions'
function model(overrides: Partial<DetectionModelCapability> = {}): DetectionModelCapability {
return {
model_id: 'segmentation-placeholder',
display_name: 'Segmentation placeholder',
framework: 'none',
task_type: 'segmentation',
supported_classes: [],
configured: false,
status: 'not_configured',
limitation_message: 'Segmentation inference is not configured for this placeholder.',
validated_regions: [],
nationally_validated: false,
operator_review_required: true,
...overrides,
}
}
describe('analysis model availability copy', () => {
it('does not expose raw English backend placeholder copy in the Dutch UI', () => {
const capability = model()
expect(analysisModelAvailabilityMessage(capability)).toContain('nog geen productiegeschikt segmentatiemodel')
expect(analysisModelDisplayName(capability)).toBe('Segmentatiemodel nog niet geconfigureerd')
expect(toAnalysisModelOption(capability).description).not.toContain('Segmentation inference')
})
it('explains an unavailable NVIDIA runtime explicitly', () => {
const capability = model({
model_id: 'yolo-configured',
task_type: 'object_detection',
status: 'accelerator_unavailable',
})
expect(analysisModelAvailabilityMessage(capability)).toContain('NVIDIA CUDA')
})
})
@@ -0,0 +1,77 @@
import type { DetectionModelCapability } from '../../types'
import type { ModelSelectionOption } from './ModelSelector'
export function analysisModelDisplayName(model: DetectionModelCapability): string {
const knownNames: Record<string, string> = {
'yolo-configured': 'Lokaal gebouwmodel',
'manual-fixture-detector': 'Testdetectie (geen productie)',
'yolo-placeholder': 'Gebouwmodel nog niet geconfigureerd',
'segmentation-placeholder': 'Segmentatiemodel nog niet geconfigureerd',
'fixture-segmenter': 'Testsegmentatie (geen productie)',
'yolo-seg-configured': 'Lokaal YOLO-segmentatiemodel',
'sam-configured': 'Lokaal SAM-segmentatiemodel',
'yolo-seg-placeholder': 'YOLO-segmentatie nog niet geconfigureerd',
'sam-placeholder': 'SAM-segmentatie nog niet geconfigureerd',
}
return knownNames[model.model_id] ?? model.display_name
}
function supportedClassLabel(value: string): string {
const labels: Record<string, string> = {
building: 'gebouwen',
vegetation: 'vegetatie',
water: 'water',
landuse: 'landgebruik',
segment: 'algemene vlakken',
}
return labels[value.toLowerCase()] ?? value
}
export function analysisModelAvailabilityMessage(model: DetectionModelCapability): string {
const task = model.task_type === 'segmentation' ? 'segmentatiemodel' : 'detectiemodel'
if (model.model_id === 'manual-fixture-detector' || model.model_id === 'fixture-segmenter') {
return 'Alleen beschikbaar voor expliciete geautomatiseerde tests; dit is geen productie-inferentie.'
}
if (model.configured) {
return `Dit lokale ${task} is op de server geconfigureerd. Resultaten blijven operatorcontrole vereisen.`
}
if (model.status === 'accelerator_unavailable') {
return 'De vereiste NVIDIA CUDA-runtime is momenteel niet beschikbaar op de server.'
}
if (model.status === 'dependency_unavailable') {
return 'De vereiste PyTorch- of modelsoftware is nog niet beschikbaar op de server.'
}
if (model.status === 'contract_incomplete') {
return 'Het modelbestand is aanwezig, maar de versieerbare provenancecontrole is nog niet volledig.'
}
if (model.model_id.includes('placeholder')) {
return `Er is nog geen productiegeschikt ${task} aan deze registratie gekoppeld.`
}
return `Dit ${task} is nog niet volledig geconfigureerd op de server.`
}
export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption {
const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie'
const configured = model.configured && model.status !== 'not_configured'
const supportedClasses = model.supported_classes.map(supportedClassLabel)
return {
id: model.model_id,
name: analysisModelDisplayName(model),
description: configured
? `Beschikbaar voor lokale ${task}${supportedClasses.length ? ` van ${supportedClasses.join(', ')}` : ''}.`
: analysisModelAvailabilityMessage(model),
recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined,
status: configured ? 'available' : 'unavailable',
statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd',
tone: model.nationally_validated ? 'recommended' : model.operator_review_required ? 'specialized' : 'analytical',
provider: model.framework,
environment: 'Servermodel',
capability: model.training_scope ?? task,
technicalName: model.model_id,
details: [
model.version ? `Versie: ${model.version}` : '',
model.validated_regions.length ? `Gevalideerde regio’s: ${model.validated_regions.join(', ')}` : '',
model.operator_review_required ? 'Operatorcontrole vereist' : '',
].filter(Boolean),
}
}
@@ -0,0 +1,250 @@
import type {
AreaRead,
DatasetCreateResponse,
ExportRead,
ProjectRead,
QualityCheckRead,
} from '../../types'
import type { useSourceFreshness } from '../../hooks/useSourceFreshness'
import { SourceFreshnessPanel } from '../status/SourceFreshnessPanel'
import { WorkbenchStatusStrip } from '../WorkbenchStatusStrip'
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
export type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
interface OverviewWorkspaceProps {
selectedProject: ProjectRead | null
selectedProjectId: string | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
qualityChecks: QualityCheckRead[]
exports: ExportRead[]
activeLayerFeatureCount: number
selectedAreaHasGeometry: boolean
hasAnalysisOutput: boolean
sourceFreshness: ReturnType<typeof useSourceFreshness>
onOpenWorkspace: (target: WorkspaceKey) => void
}
interface WorkflowStep {
step: string
title: string
detail: string
status: string
ready: boolean
target: WorkspaceKey
}
export function OverviewWorkspace({
selectedProject,
selectedProjectId,
areas,
datasets,
qualityChecks,
exports,
activeLayerFeatureCount,
selectedAreaHasGeometry,
hasAnalysisOutput,
sourceFreshness,
onOpenWorkspace,
}: OverviewWorkspaceProps): JSX.Element {
const hasMapContext = activeLayerFeatureCount > 0 || selectedAreaHasGeometry
const workflowComplete =
Boolean(selectedProjectId)
&& datasets.length > 0
&& hasMapContext
&& hasAnalysisOutput
&& exports.length > 0
const recommendedTarget: WorkspaceKey = !selectedProjectId || datasets.length === 0
? 'data'
: !hasMapContext
? 'map'
: !hasAnalysisOutput
? 'analysis'
: 'exports'
const steps: WorkflowStep[] = [
{
step: '1',
title: 'Werkruimte en gebied',
detail: selectedProjectId
? `${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'} beschikbaar`
: 'Laad of maak een werkruimte',
status: selectedProjectId ? 'gereed' : 'volgende stap',
ready: Boolean(selectedProjectId),
target: 'data',
},
{
step: '2',
title: 'Databronnen',
detail: datasets.length > 0
? `${datasets.length} ${datasets.length === 1 ? 'databron' : 'databronnen'} ingeladen`
: 'Voeg bron- en referentiegegevens toe',
status: datasets.length > 0 ? 'gereed' : 'wachten',
ready: datasets.length > 0,
target: 'data',
},
{
step: '3',
title: 'Kaart',
detail: hasMapContext
? activeLayerFeatureCount > 0
? `${activeLayerFeatureCount.toLocaleString('nl-BE')} objecten op de kaart`
: 'Werkgebied ingeladen'
: 'Bekijk het werkgebied en kies een kaartlaag',
status: hasMapContext ? 'gereed' : 'wachten',
ready: hasMapContext,
target: 'map',
},
{
step: '4',
title: 'Controle en analyse',
detail: hasAnalysisOutput
? 'Er is een bewaard analyse- of kwaliteitsresultaat'
: 'Voer een controle uit zodra de gegevens klaarstaan',
status: hasAnalysisOutput ? 'gereed' : 'wachten',
ready: hasAnalysisOutput,
target: 'analysis',
},
{
step: '5',
title: 'Downloads',
detail: exports.length > 0
? `${exports.length} ${exports.length === 1 ? 'resultaat' : 'resultaten'} bewaard`
: 'Bewaar een gecontroleerd resultaat',
status: exports.length > 0 ? 'gereed' : 'wachten',
ready: exports.length > 0,
target: 'exports',
},
]
const readyStepCount = steps.filter((step) => step.ready).length
const latestQualityCheck = qualityChecks[0] ?? null
const latestExport = exports[0] ?? null
return (
<div className="workspace-stack">
<ProjectAtlasIllustration
projectName={selectedProject?.name ?? 'de GeoIntel-werkruimte'}
areaCount={areas.length}
datasetCount={datasets.length}
featureCount={activeLayerFeatureCount}
hasAnalysisOutput={hasAnalysisOutput}
exportCount={exports.length}
onOpenWorkspace={onOpenWorkspace}
/>
<section className="overview-command-center" aria-labelledby="overview-command-title">
<div className="overview-command-heading">
<div>
<p className="eyebrow">Projectbriefing</p>
<h2 id="overview-command-title">Wat vraagt nu aandacht?</h2>
</div>
<button type="button" className="secondary-action" onClick={() => onOpenWorkspace(recommendedTarget)}>
Volgende stap openen
</button>
</div>
<div className="overview-command-grid">
<article className="overview-command-card overview-command-card-progress">
<span>Voortgang</span>
<strong>{readyStepCount} / {steps.length}</strong>
<p>{workflowComplete ? 'De volledige keten is afgerond.' : steps.find((step) => !step.ready)?.detail}</p>
<div aria-hidden="true"><i style={{ width: `${(readyStepCount / steps.length) * 100}%` }} /></div>
</article>
<button type="button" className="overview-command-card" onClick={() => onOpenWorkspace('analysis')}>
<span>Laatste kwaliteitsbewijs</span>
<strong>{latestQualityCheck?.score != null ? latestQualityCheck.score.toFixed(3) : 'Nog geen score'}</strong>
<p>{latestQualityCheck ? `${latestQualityCheck.check_type.replace(/_/g, ' ')} · ${latestQualityCheck.status}` : 'Voer een controle uit wanneer kandidaat- en referentiedata klaarstaan.'}</p>
</button>
<button type="button" className="overview-command-card" onClick={() => onOpenWorkspace('data')}>
<span>Brondekking</span>
<strong>{datasets.length} bronnen</strong>
<p>{areas.length > 0 ? `Beschikbaar voor ${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'}.` : 'Definieer eerst een werkgebied.'}</p>
</button>
<button type="button" className="overview-command-card" onClick={() => onOpenWorkspace('exports')}>
<span>Laatste resultaat</span>
<strong>{latestExport ? latestExport.status : 'Nog niet bewaard'}</strong>
<p>{latestExport ? latestExport.export_type.replace(/_/g, ' ') : 'Een gecontroleerde export verschijnt hier als volgende overdraagbare versie.'}</p>
</button>
</div>
</section>
<WorkbenchStatusStrip
selectedProject={selectedProject}
areas={areas}
datasets={datasets}
qualityChecks={qualityChecks}
exports={exports}
activeLayerFeatureCount={activeLayerFeatureCount}
selectedAreaHasGeometry={selectedAreaHasGeometry}
/>
<SourceFreshnessPanel
report={sourceFreshness.report}
loading={sourceFreshness.loading}
error={sourceFreshness.error}
onRefresh={() => { void sourceFreshness.refresh() }}
catalogReport={sourceFreshness.catalogReport}
catalogLoading={sourceFreshness.catalogLoading}
catalogError={sourceFreshness.catalogError}
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
grbRefreshPlan={sourceFreshness.grbRefreshPlan}
grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
<strong>{workflowComplete ? 'voltooid' : 'stappen open'}</strong>
</summary>
<section className="workflow-guidance-panel" aria-label="Voortgang van de werkstroom">
<div className="panel-title-row">
<div>
<p className="eyebrow">Van bron tot resultaat</p>
<h2>Voortgang van de werkstroom</h2>
</div>
<span className="status-badge">
{workflowComplete ? 'Klaar om te delen' : 'Ga verder met de gemarkeerde stap'}
</span>
</div>
<div className="workflow-guidance-steps">
{steps.map((step) => (
<button
key={`${step.step}-${step.title}`}
type="button"
className={
step.target === recommendedTarget && !step.ready
? 'workflow-guidance-step workflow-guidance-step-active'
: step.ready
? 'workflow-guidance-step workflow-guidance-step-ready'
: 'workflow-guidance-step'
}
onClick={() => onOpenWorkspace(step.target)}
aria-label={`Open stap ${step.title}`}
>
<span className="workflow-step-status">{step.status}</span>
<strong>{step.step}. {step.title}</strong>
<small>{step.detail}</small>
</button>
))}
</div>
</section>
<section className="overview-actions">
<div className="overview-action-copy">
<p className="eyebrow">Snel verder</p>
<h2>Kies je volgende actie</h2>
</div>
<div className="quick-action-grid overview-quick-actions" aria-label="Aanbevolen acties">
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('data')}>
Gegevens beheren
</button>
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('map')}>
Kaart openen
</button>
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('analysis')}>
Kwaliteit bekijken
</button>
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('exports')}>
Downloads beheren
</button>
</div>
</section>
</details>
</div>
)
}
@@ -0,0 +1,47 @@
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
afterEach(cleanup)
describe('ProjectAtlasIllustration', () => {
it('derives the active project phase from real readiness and opens its workspace', () => {
const onOpenWorkspace = vi.fn()
render(
<ProjectAtlasIllustration
projectName="Belgium and North Sea Workbench"
areaCount={2}
datasetCount={7}
featureCount={0}
hasAnalysisOutput={false}
exportCount={0}
onOpenWorkspace={onOpenWorkspace}
/>,
)
expect(screen.getByText('2', { selector: '.project-atlas-metrics strong' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Open kaart' }).className).toContain('project-atlas-stage-active')
fireEvent.click(screen.getByRole('button', { name: 'Open kaart' }))
expect(onOpenWorkspace).toHaveBeenCalledWith('map')
})
it('marks the complete chain ready without inventing additional data', () => {
render(
<ProjectAtlasIllustration
projectName="Mol Municipality Workbench"
areaCount={1}
datasetCount={3}
featureCount={14}
hasAnalysisOutput
exportCount={1}
onOpenWorkspace={() => undefined}
/>,
)
expect(document.querySelectorAll('.project-atlas-stage-ready')).toHaveLength(5)
expect(screen.getByText('14', { selector: '.project-atlas-metrics strong' })).toBeTruthy()
})
})
@@ -0,0 +1,121 @@
import { BadgeCheck, Download, Layers3, Map, ScanSearch } from 'lucide-react'
import type { WorkspaceKey } from './OverviewWorkspace'
interface ProjectAtlasIllustrationProps {
projectName: string
areaCount: number
datasetCount: number
featureCount: number
hasAnalysisOutput: boolean
exportCount: number
onOpenWorkspace: (target: WorkspaceKey) => void
}
const stages = [
{ key: 'data', label: 'Gebied', icon: Map },
{ key: 'data', label: 'Bronnen', icon: Layers3 },
{ key: 'map', label: 'Kaart', icon: ScanSearch },
{ key: 'analysis', label: 'Analyse', icon: BadgeCheck },
{ key: 'exports', label: 'Export', icon: Download },
] as const
const signalPoints = [
{ x: 116, y: 222 },
{ x: 275, y: 130 },
{ x: 432, y: 179 },
{ x: 561, y: 87 },
] as const
export function ProjectAtlasIllustration({
projectName,
areaCount,
datasetCount,
featureCount,
hasAnalysisOutput,
exportCount,
onOpenWorkspace,
}: ProjectAtlasIllustrationProps): JSX.Element {
const readiness = [areaCount > 0, datasetCount > 0, featureCount > 0, hasAnalysisOutput, exportCount > 0]
const activeIndex = readiness.findIndex((ready) => !ready)
const currentIndex = activeIndex === -1 ? readiness.length - 1 : activeIndex
return (
<section className="project-atlas" aria-labelledby="project-atlas-title">
<div className="project-atlas-copy">
<p className="eyebrow">Levende projectatlas</p>
<h2 id="project-atlas-title">Van grondgebied naar aantoonbaar resultaat</h2>
<p>
Volg de operationele keten van <strong>{projectName}</strong>. Elke halte opent de bijbehorende werkruimte.
</p>
<div className="project-atlas-metrics" aria-label="Projectkerncijfers">
<span><strong>{areaCount}</strong> gebieden</span>
<span><strong>{datasetCount}</strong> bronnen</span>
<span><strong>{featureCount.toLocaleString('nl-BE')}</strong> objecten</span>
</div>
</div>
<div className="project-atlas-visual" aria-hidden="true">
<svg viewBox="0 0 680 300" role="presentation">
<defs>
<linearGradient id="atlas-sea" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#0b4b45" />
<stop offset="1" stopColor="#082a32" />
</linearGradient>
<linearGradient id="atlas-land" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#80d2ba" />
<stop offset="1" stopColor="#2f8f7d" />
</linearGradient>
<filter id="atlas-glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="7" />
</filter>
</defs>
<rect width="680" height="300" rx="28" fill="url(#atlas-sea)" />
<g className="atlas-grid-lines">
<path d="M0 60H680M0 120H680M0 180H680M0 240H680" />
<path d="M85 0V300M170 0V300M255 0V300M340 0V300M425 0V300M510 0V300M595 0V300" />
</g>
<path className="atlas-coast-halo" d="M260 5C303 32 298 60 334 72c31 10 62-7 87 16 30 27 3 53 28 77 21 20 70 9 78 45 8 38-42 52-47 85H181c5-34 42-53 36-86-7-39-54-52-46-88 6-31 50-29 64-56 12-23 5-42 25-60Z" />
<path className="atlas-land-shape" d="M260 5C303 32 298 60 334 72c31 10 62-7 87 16 30 27 3 53 28 77 21 20 70 9 78 45 8 38-42 52-47 85H181c5-34 42-53 36-86-7-39-54-52-46-88 6-31 50-29 64-56 12-23 5-42 25-60Z" />
<g className="atlas-contours">
<path d="M225 61c46 18 63 2 102 25 38 22 54 15 91 33" />
<path d="M197 126c58-19 95 5 129 28 39 27 83 18 130 39" />
<path d="M210 220c44-23 87-17 126 3 38 19 79 5 132 24" />
</g>
<path className="atlas-route" pathLength="1" d="M116 222C180 188 193 114 275 130c65 13 91 78 157 49 55-24 69-78 129-92" />
{signalPoints.map((point, index) => (
<g className={`atlas-signal atlas-signal-${index + 1}`} key={`${point.x}-${point.y}`}>
<circle className="atlas-signal-glow" cx={point.x} cy={point.y} r="15" filter="url(#atlas-glow)" />
<circle className="atlas-signal-ring" cx={point.x} cy={point.y} r="10" />
<circle className="atlas-signal-core" cx={point.x} cy={point.y} r="3.5" />
</g>
))}
<g className="atlas-scan">
<path d="M60 48H620" />
<rect x="58" y="41" width="564" height="14" rx="7" />
</g>
</svg>
<span className="project-atlas-live"><i /> live projectbeeld</span>
</div>
<div className="project-atlas-stages" aria-label="Open een projectfase">
{stages.map((stage, index) => {
const Icon = stage.icon
const state = readiness[index] ? 'ready' : index === currentIndex ? 'active' : 'waiting'
return (
<button
key={`${stage.label}-${index}`}
type="button"
className={`project-atlas-stage project-atlas-stage-${state}`}
onClick={() => onOpenWorkspace(stage.key)}
aria-label={`Open ${stage.label.toLowerCase()}`}
>
<span><Icon aria-hidden="true" /></span>
<strong>{stage.label}</strong>
<small>{state === 'ready' ? 'gereed' : state === 'active' ? 'volgende' : 'wacht'}</small>
</button>
)
})}
</div>
</section>
)
}
@@ -0,0 +1,195 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import type { AreaRead, ProjectRead } from '../../types'
const AREA_CATALOG_PAGE_SIZE = 12
function projectDisplayName(project: ProjectRead | null): string {
if (project?.name === 'Belgium and North Sea Workbench') {
return 'België en Belgische Noordzee'
}
if (project?.name === 'Kempen Regional Workbench') {
return 'Kempen · volledige regio'
}
if (project?.name === 'Mol Municipality Workbench') {
return 'Mol · lokale historische werkruimte'
}
return project?.name ?? 'Geen werkruimte actief'
}
interface AreaFormState {
name: string
geometry: string
crs: string
}
interface AreaPanelProps {
areas: AreaRead[]
selectedProject: ProjectRead | null
selectedProjectId: string | null
loadingAreas: boolean
areaForm: AreaFormState
selectedMapAreaId: string
onCreateArea: (event: FormEvent<HTMLFormElement>) => void
onUpdateAreaForm: (areaForm: AreaFormState) => void
onSelectMapArea: (areaId: string) => void
}
export function AreaPanel({
areas,
selectedProject,
selectedProjectId,
loadingAreas,
areaForm,
selectedMapAreaId,
onCreateArea,
onUpdateAreaForm,
onSelectMapArea,
}: AreaPanelProps): JSX.Element {
const [catalogOpen, setCatalogOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [page, setPage] = useState(1)
const selectedArea = areas.find((area) => area.id === selectedMapAreaId)
const filteredAreas = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase('nl-BE')
if (!query) return areas
return areas.filter((area) => area.name.toLocaleLowerCase('nl-BE').includes(query))
}, [areas, searchQuery])
const pageCount = Math.max(1, Math.ceil(filteredAreas.length / AREA_CATALOG_PAGE_SIZE))
const visibleAreas = filteredAreas.slice(
(page - 1) * AREA_CATALOG_PAGE_SIZE,
page * AREA_CATALOG_PAGE_SIZE,
)
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
useEffect(() => {
setSearchQuery('')
setPage(1)
setCatalogOpen(false)
}, [selectedProjectId])
return (
<section data-testid="area-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Geografisch bereik</p>
<h2>Gebieden</h2>
</div>
<span className="count-pill">{areas.length}</span>
</div>
<p className="muted">{projectDisplayName(selectedProject)}</p>
<div className="data-selection-summary data-selection-summary-area">
<span>Getoond op de kaart</span>
<strong>{selectedArea?.name ?? 'Geen gebied geselecteerd'}</strong>
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente, gewest, zeezone of teken een eigen selectie.'}</small>
</div>
<details className="data-panel-form-block">
<summary>Eigen gebied toevoegen (GeoJSON)</summary>
<form className="compact-form" onSubmit={onCreateArea}>
<label>
Naam
<input
value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="Naam van het gebied"
/>
</label>
<label>
CRS
<input
value={areaForm.crs}
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
placeholder="EPSG:4326"
/>
</label>
<label>
GeoJSON-geometrie
<textarea
value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
rows={4}
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Gebied toevoegen
</button>
</form>
</details>
{loadingAreas ? <p>Gebieden laden...</p> : null}
{areas.length === 0 ? <p>Nog geen gebieden beschikbaar.</p> : null}
<details
className="data-panel-list-block area-catalog-disclosure"
onToggle={(event) => setCatalogOpen(event.currentTarget.open)}
>
<summary>{areas.length} beschikbare gemeenten en regiogrenzen</summary>
{catalogOpen ? (
<>
<div className="catalog-browser-toolbar">
<label className="catalog-search-field">
Zoek gemeente of regio
<input
type="search"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
setPage(1)
}}
placeholder="Bijvoorbeeld Brussel, Namen of Noordzee"
/>
</label>
<div className="catalog-pagination" aria-label="Paginering van gebieden">
<span>{filteredAreas.length} gevonden</span>
<button
className="secondary-action"
type="button"
disabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Vorige
</button>
<strong>{page} / {pageCount}</strong>
<button
className="secondary-action"
type="button"
disabled={page === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
>
Volgende
</button>
</div>
</div>
{visibleAreas.length > 0 ? (
<ul className="entity-list">
{visibleAreas.map((area) => (
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
<strong>{area.name}</strong>
<div className="entity-meta">
<span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span>
<span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span>
</div>
<button
className="secondary-action"
type="button"
onClick={() => onSelectMapArea(area.id)}
disabled={!area.geometry}
data-testid={`area-show-${area.id}`}
>
{selectedMapAreaId === area.id ? 'Op kaart' : 'Toon op kaart'}
</button>
</li>
))}
</ul>
) : (
<p className="muted">Geen gebied gevonden voor deze zoekterm.</p>
)}
</>
) : null}
</details>
</section>
)
}
@@ -0,0 +1,193 @@
import type { FormEvent } from 'react'
import type { ProjectCreate, ProjectRead } from '../../types'
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
const NATIONAL_PROJECT_NAME = 'Belgium and North Sea Workbench'
const REGIONAL_PROJECT_NAME = 'Kempen Regional Workbench'
const LEGACY_MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const PROTECTED_PROJECT_NAMES = new Set([NATIONAL_PROJECT_NAME, REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
function isTechnicalProject(project: ProjectRead): boolean {
return TECHNICAL_PROJECT_PATTERN.test(project.name)
}
function isAdvancedProject(project: ProjectRead): boolean {
return isTechnicalProject(project) || project.name === LEGACY_MOL_PROJECT_NAME || project.name === REGIONAL_PROJECT_NAME
}
function projectDisplayName(project: ProjectRead): string {
if (project.name === NATIONAL_PROJECT_NAME) {
return 'België en Belgische Noordzee'
}
if (project.name === REGIONAL_PROJECT_NAME) {
return 'Kempen · volledige regionale werkruimte'
}
if (project.name === LEGACY_MOL_PROJECT_NAME) {
return 'Mol · lokale historische werkruimte'
}
return project.name
}
interface ProjectPanelProps {
projects: ProjectRead[]
selectedProjectId: string | null
loadingProjects: boolean
archivingProjectId: string | null
projectForm: ProjectCreate
loadingDemoWorkflow: boolean
demoWorkflowMessage: string | null
onCreateProject: (event: FormEvent<HTMLFormElement>) => void
onUpdateProjectForm: (projectForm: ProjectCreate) => void
onSelectProject: (projectId: string) => void
onArchiveProject: (projectId: string) => Promise<void>
onLoadDemoWorkflow: () => void
}
export function ProjectPanel({
projects,
selectedProjectId,
loadingProjects,
archivingProjectId,
projectForm,
loadingDemoWorkflow,
demoWorkflowMessage,
onCreateProject,
onUpdateProjectForm,
onSelectProject,
onArchiveProject,
onLoadDemoWorkflow,
}: ProjectPanelProps): JSX.Element {
const selectedProject = projects.find((project) => project.id === selectedProjectId)
const regularProjects = projects.filter((project) => !isAdvancedProject(project))
const advancedProjects = projects.filter(isAdvancedProject)
const visibleProjects = selectedProject && isAdvancedProject(selectedProject)
? [selectedProject, ...regularProjects]
: regularProjects
return (
<section className="workspace-panel" data-testid="project-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Gegevenscontext</p>
<h2>Werkruimtes</h2>
</div>
<span className="count-pill">{regularProjects.length} beschikbaar</span>
</div>
{loadingProjects ? <p>Werkruimtes laden...</p> : null}
<div className="data-selection-summary data-selection-summary-project">
<span>Actieve werkruimte</span>
<strong>{selectedProject ? projectDisplayName(selectedProject) : 'Geen werkruimte actief'}</strong>
<small>{selectedProject?.region ?? 'De regionale werkruimte wordt automatisch gekozen.'}</small>
</div>
<div className="data-panel-list-block">
<p className="data-section-label">Beschikbare werkruimtes</p>
<ul className="entity-list">
{visibleProjects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
type="button"
onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId}
data-testid={`project-select-${project.id}`}
>
<strong>{projectDisplayName(project)}</strong>
<span>{project.region || 'Regio niet opgegeven'}</span>
</button>
</li>
))}
{regularProjects.length === 0 ? (
<li className="empty-state">
<strong>Nog geen werkruimte beschikbaar</strong>
<p>Maak een werkruimte aan of laad de demonstratiegegevens.</p>
</li>
) : null}
</ul>
</div>
<details className="data-panel-form-block technical-management-block">
<summary>Geavanceerd projectbeheer</summary>
<div className="technical-management-body">
<form className="compact-form" onSubmit={onCreateProject}>
<label>
Naam
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Naam van de werkruimte"
/>
</label>
<label>
Beschrijving
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Korte beschrijving"
/>
</label>
<label>
Regio
<input
value={projectForm.region ?? 'België en Belgische Noordzee'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Regio"
/>
</label>
<button className="primary-action" type="submit">Nieuwe werkruimte</button>
</form>
<div className="demo-actions">
<button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
{loadingDemoWorkflow ? 'Demo laden...' : 'Demonstratiegegevens laden'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div>
{selectedProject && !PROTECTED_PROJECT_NAMES.has(selectedProject.name) ? (
<div className="project-lifecycle-actions">
<div>
<strong>Actieve werkruimte opruimen</strong>
<p className="muted">
Archiveren verbergt deze werkruimte uit de standaardlijst. Databronnen en analyseresultaten blijven bewaard.
</p>
</div>
<button
className="secondary-action"
type="button"
disabled={archivingProjectId === selectedProject.id}
onClick={() => {
if (window.confirm(`Werkruimte "${projectDisplayName(selectedProject)}" archiveren?`)) {
void onArchiveProject(selectedProject.id)
}
}}
>
{archivingProjectId === selectedProject.id ? 'Archiveren...' : 'Werkruimte archiveren'}
</button>
</div>
) : null}
{advancedProjects.length > 0 ? (
<details className="technical-run-list">
<summary>{advancedProjects.length} alternatieve en technische werkruimtes</summary>
<ul className="entity-list">
{advancedProjects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
type="button"
onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId}
data-testid={`project-select-${project.id}`}
>
<strong>{projectDisplayName(project)}</strong>
<span>{project.region || 'Technische run'}</span>
</button>
</li>
))}
</ul>
</details>
) : null}
</div>
</details>
</section>
)
}
@@ -0,0 +1,41 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProviderPanel } from './ProviderPanel'
import type { ProviderCapability } from '../../types'
function props(overschrijf: Partial<Parameters<typeof ProviderPanel>[0]> = {}) {
return {
selectedProjectId: 'p-1',
providers: [] as ProviderCapability[],
loadingCapabilities: false,
capabilitiesError: null,
onRefresh: vi.fn(),
onOpenSources: vi.fn(),
onOpenStatus: vi.fn(),
onOpenMap: vi.fn(),
...overschrijf,
}
}
describe('ProviderPanel', () => {
afterEach(() => cleanup())
it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => {
// Het type zegt dat providers altijd meekomt. Dit paneel gebruikte die
// lijst op drie plekken en had er op één een guard; nu wordt hij aan de
// kop genormaliseerd. Een type is een belofte van de compiler, niet van
// het netwerk.
const zonder = props({ providers: undefined as unknown as ProviderCapability[] })
expect(() => render(<ProviderPanel {...zonder} />)).not.toThrow()
})
it('toont een lege toestand zonder koppelingen', () => {
render(<ProviderPanel {...props()} />)
expect(screen.getByText(/geen databronnen gemeld/i)).toBeTruthy()
})
it('meldt het wanneer de status niet opgehaald kon worden', () => {
render(<ProviderPanel {...props({ capabilitiesError: 'Bronservice niet bereikbaar' })} />)
expect(screen.getByText(/bronservice niet bereikbaar/i)).toBeTruthy()
})
})
@@ -0,0 +1,219 @@
import { useCallback, useEffect, useState } from 'react'
import type { AoiOperation, ProviderCapability } from '../../types'
import { aoiOperationsApi } from '../../services/api'
interface ProviderPanelProps {
selectedProjectId: string | null
providers: ProviderCapability[]
loadingCapabilities: boolean
capabilitiesError: string | null
onRefresh: () => void
onOpenSources: () => void
onOpenStatus: () => void
onOpenMap: () => void
}
function providerStatusLabel(provider: ProviderCapability): string {
if (provider.configured) {
return 'Beschikbaar'
}
return 'Live koppeling uit'
}
function authorityLabel(value: string): string {
const labels: Record<string, string> = {
authoritative: 'Officieel',
contextual: 'Aanvullend',
manual: 'Eigen upload',
fixture: 'Testgegevens',
}
return labels[value] ?? value
}
function providerLayerLabel(value: string): string {
const labels: Record<string, string> = {
buildings: 'bebouwing',
roads: 'wegen',
parcels: 'percelen',
water: 'water',
landuse: 'landgebruik',
custom: 'eigen laag',
}
return labels[value] ?? value
}
export function ProviderPanel({
selectedProjectId,
providers,
loadingCapabilities,
capabilitiesError,
onRefresh,
onOpenSources,
onOpenStatus,
onOpenMap,
}: ProviderPanelProps): JSX.Element {
// Eén normalisatie aan de kop, in plaats van op elke gebruiksplek een guard.
// Een antwoord zonder deze lijst liet het paneel eerder vallen.
const koppelingen = providers ?? []
const configuredCount = koppelingen.filter((provider) => provider.configured).length
const [operations, setOperations] = useState<AoiOperation[]>([])
const [operationsError, setOperationsError] = useState<string | null>(null)
const [loadingOperations, setLoadingOperations] = useState(false)
const loadOperations = useCallback(async () => {
if (!selectedProjectId) {
setOperations([])
return
}
setLoadingOperations(true)
try {
const response = await aoiOperationsApi.list(selectedProjectId)
setOperations(response.items)
setOperationsError(null)
} catch (error) {
setOperationsError(error instanceof Error ? error.message : 'AOI-verwerking kon niet worden geladen.')
} finally {
setLoadingOperations(false)
}
}, [selectedProjectId])
useEffect(() => {
void loadOperations()
const timer = window.setInterval(() => void loadOperations(), 5000)
return () => window.clearInterval(timer)
}, [loadOperations])
return (
<section className="system-provider-panel">
<div className="system-provider-shell">
<div className="panel-title-row">
<div>
<h2>Systeem & databronnen</h2>
<p className="muted">Operationele beschikbaarheid, bronherkomst en directe routes naar beheeracties.</p>
</div>
<button type="button" className="secondary-action" onClick={onRefresh} disabled={loadingCapabilities}>
Status vernieuwen
</button>
</div>
<div className="system-command-surface" aria-label="Systeemacties">
<div><span>Bronkoppelingen</span><strong>{configuredCount} van {koppelingen.length} actief</strong></div>
<div><span>Werkmodus</span><strong>Begrensde bronopvraging</strong></div>
<div className="system-command-actions">
<button type="button" className="primary-action" onClick={onOpenMap}>Open operationele kaart</button>
<button type="button" className="secondary-action" onClick={onOpenSources}>Beheer gebieden en bronnen</button>
<button type="button" className="secondary-action" onClick={onOpenStatus}>Bekijk gereedheid</button>
</div>
</div>
<div className="system-provider-state-stack">
{loadingCapabilities ? (
<div className="result-state result-state-loading">
<strong>Databronnen laden.</strong>
<p>De lokale en externe bronstatus wordt gecontroleerd.</p>
</div>
) : null}
{capabilitiesError ? (
<div className="result-state result-state-error">
<strong>Bronregister niet bereikbaar.</strong>
<p>{capabilitiesError}</p>
</div>
) : null}
{koppelingen.length === 0 && !loadingCapabilities ? (
<div className="result-state result-state-empty">
<strong>Geen databronnen gemeld.</strong>
<p>Vernieuw de status zodra de backend bereikbaar is.</p>
</div>
) : null}
</div>
<section className="system-provider-capability-surface" aria-label="AOI-verwerkingsvoortgang">
<div className="panel-title-row">
<div>
<strong>Gebiedsverwerking</strong>
<p className="muted">Server-side partities, hervatbare voortgang en bronfouten voor grote selecties.</p>
</div>
<button type="button" className="secondary-action" onClick={() => void loadOperations()} disabled={loadingOperations || !selectedProjectId}>Vernieuwen</button>
</div>
{loadingOperations && operations.length === 0 ? <div className="result-state result-state-loading"><strong>Verwerkingen laden.</strong></div> : null}
{operationsError ? <div className="result-state result-state-error"><strong>Verwerkingsstatus niet bereikbaar.</strong><p>{operationsError}</p></div> : null}
{!loadingOperations && !operationsError && operations.length === 0 ? <div className="result-state result-state-empty"><strong>Nog geen gebiedsverwerking.</strong><p>Grote officiële bronselecties verschijnen hier met één gezamenlijke voortgang.</p></div> : null}
{operations.length > 0 ? (
<ul className="system-provider-list">
{operations.map((operation) => (
<li className="system-provider-card" key={operation.id}>
<div className="system-provider-header"><div><strong>{operation.operation_type}</strong><span>{String(operation.plan_json.provider_key ?? 'bron')} · {String(operation.plan_json.product_key ?? 'product')}</span></div><span className={operation.status === 'success' ? 'status-badge status-badge-ready' : 'status-badge'}>{operation.status}</span></div>
<progress value={operation.progress} max={1} aria-label={`Voortgang ${operation.operation_type}`} />
<div className="entity-meta"><span>{Math.round(operation.progress * 100)}%</span><span>{operation.partitions.length} partities</span><span>{operation.partition_counts.failed ?? 0} mislukt</span></div>
{operation.error_message ? <p className="inline-error">{operation.error_message}</p> : null}
</li>
))}
</ul>
) : null}
</section>
<div className="system-provider-capability-surface" aria-label="Provider capability registry">
<div className="provider-detail-stack">
<strong>Officiële referentiebronnen</strong>
<div>
GRB is beschikbaar voor expliciet begrensde kaartselecties en wordt daarna lokaal bewaard.
OSM blijft uitgeschakeld; geen enkele bron haalt ongemerkt gegevens op.
</div>
</div>
<ul className="system-provider-list">
{koppelingen.map((provider) => (
<li className="system-provider-card" key={provider.provider_name}>
<div className="system-provider-header">
<div>
<strong>{provider.display_name}</strong>
<span>{provider.provider_name}</span>
</div>
<span className={provider.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{providerStatusLabel(provider)}</span>
</div>
<div className="system-provider-grid">
<div>
<span>Herkomst</span>
<strong>{authorityLabel(provider.authority_level)}</strong>
</div>
<div>
<span>Status</span>
<strong>{providerStatusLabel(provider)}</strong>
</div>
</div>
<div className="provider-layer-list" aria-label={`${provider.provider_name} supported layers`}>
{provider.supported_layers.map((layer) => (
<span className="provider-layer-chip" key={layer}>
{providerLayerLabel(layer)}
</span>
))}
</div>
<details className="provider-technical-details">
<summary>Herkomst en technische mogelijkheden</summary>
<div className="provider-detail-stack">
<div>{provider.limitation_message}</div>
{!provider.configured && provider.not_configured_reason ? (
<div>{provider.not_configured_reason}</div>
) : null}
</div>
<div className="provider-provenance-grid">
<div className="provider-provenance-card">
<span>Naamsvermelding</span>
<strong>{provider.attribution}</strong>
</div>
<div className="provider-provenance-card">
<span>Licentie</span>
<strong>{provider.license_note}</strong>
</div>
</div>
<div className="entity-meta">
<span>Geometrie: {provider.supported_geometry_types.join(', ')}</span>
<span>Zoekwijzen: {provider.supported_query_modes.join(', ')}</span>
</div>
</details>
</li>
))}
</ul>
</div>
</div>
</section>
)
}
@@ -0,0 +1,280 @@
import { useEffect, useState } from 'react'
import { qaApi } from '../../services/api'
import type {
DetectionEvidenceRole,
DetectionReviewDecision,
DetectionReviewList,
DetectionReviewRead,
} from '../../types'
import { formatError } from '../../lib/formatError'
interface DetectionReviewPanelProps {
projectId: string
qualityCheckId: string
reviewLocked?: boolean
onOpenEvidenceMap?: (qualityCheckId: string) => void
}
const ROLE_LABELS: Record<DetectionEvidenceRole, string> = {
false_positive: 'Onterecht gevonden',
false_negative: 'Gemist gebouw',
}
const DECISION_LABELS: Record<DetectionReviewDecision, string> = {
confirmed_model_false_positive: 'Bevestigde foutdetectie',
confirmed_model_false_negative: 'Bevestigd gemist gebouw',
reference_gap_or_change: 'Referentie ontbreekt of is verouderd',
qa_alignment_mismatch: 'Vormvergelijking is te streng',
imagery_obscured_or_uncertain: 'Luchtbeeld is onduidelijk',
uncertain: 'Verder onderzoek nodig',
unreviewed: 'Nog niet beoordeeld',
}
const ROLE_DECISIONS: Record<DetectionEvidenceRole, DetectionReviewDecision[]> = {
false_positive: [
'unreviewed',
'confirmed_model_false_positive',
'reference_gap_or_change',
'qa_alignment_mismatch',
'uncertain',
],
false_negative: [
'unreviewed',
'confirmed_model_false_negative',
'reference_gap_or_change',
'qa_alignment_mismatch',
'imagery_obscured_or_uncertain',
'uncertain',
],
}
function shortId(value: string): string {
return value.length > 18 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value
}
function formatScore(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
}
export function DetectionReviewPanel({
projectId,
qualityCheckId,
reviewLocked = false,
onOpenEvidenceMap,
}: DetectionReviewPanelProps): JSX.Element {
const [queue, setQueue] = useState<DetectionReviewList | null>(null)
const [loading, setLoading] = useState(false)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [roleFilter, setRoleFilter] = useState<'all' | DetectionEvidenceRole>('all')
const [statusFilter, setStatusFilter] = useState<'all' | 'reviewed' | 'unreviewed'>('unreviewed')
const [offset, setOffset] = useState(0)
const [draftDecisions, setDraftDecisions] = useState<Record<string, DetectionReviewDecision>>({})
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({})
const load = async () => {
setLoading(true)
setError(null)
try {
const result = await qaApi.listDetectionReviews(projectId, qualityCheckId, {
evidenceRole: roleFilter === 'all' ? undefined : roleFilter,
reviewed: statusFilter === 'all' ? undefined : statusFilter === 'reviewed',
limit: 50,
offset,
})
setQueue(result)
setDraftDecisions(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.decision])))
setDraftNotes(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.notes ?? ''])))
} catch (caught) {
setError(formatError(caught, 'De controlelijst kon niet worden geladen'))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [projectId, qualityCheckId, roleFilter, statusFilter, offset])
const save = async (item: DetectionReviewRead) => {
const key = reviewKey(item)
setSavingKey(key)
setError(null)
try {
await qaApi.upsertDetectionReview(projectId, qualityCheckId, {
evidence_role: item.evidence_role,
evidence_feature_id: item.evidence_feature_id,
decision: draftDecisions[key] ?? item.decision,
notes: draftNotes[key]?.trim() || null,
reviewed_by: 'operator',
})
await load()
} catch (caught) {
setError(formatError(caught, 'De beoordeling kon niet worden bewaard'))
} finally {
setSavingKey(null)
}
}
return (
<section className="detection-review-panel" aria-label="Handmatige controle van beeldanalyse">
<div className="panel-title-row">
<div>
<h3>{reviewLocked ? 'Beoordelingen bekijken' : 'Fouten controleren'}</h3>
<p className="muted">
{reviewLocked
? 'De demo toont het bestaande kwaliteitsbewijs zonder beoordelingen te wijzigen.'
: 'Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.'}
</p>
</div>
<button type="button" className="secondary-action" onClick={() => onOpenEvidenceMap?.(qualityCheckId)}>
Op kaart bekijken
</button>
</div>
{queue ? (
<div className="detection-review-summary">
<div><span>Te beoordelen</span><strong>{queue.summary.total}</strong></div>
<div><span>Afgerond</span><strong>{queue.summary.reviewed}</strong></div>
<div><span>Resterend</span><strong>{queue.summary.remaining}</strong></div>
<div><span>Fout gevonden</span><strong>{queue.summary.false_positive_total}</strong></div>
<div><span>Gemist</span><strong>{queue.summary.false_negative_total}</strong></div>
</div>
) : null}
{queue?.summary.reviewed_metrics ? (
<div className="detection-review-adjudicated" aria-label="Score na beoordeling">
<div className="summary-grid">
<div>
<span>Precisie zoals gemeten</span>
<strong>{formatScore(queue.summary.reviewed_metrics.raw.precision)}</strong>
</div>
<div>
<span>Precisie na uw beoordeling</span>
<strong>{formatScore(queue.summary.reviewed_metrics.adjudicated.precision)}</strong>
</div>
<div>
<span>Herkenningsgraad na beoordeling</span>
<strong>{formatScore(queue.summary.reviewed_metrics.adjudicated.recall)}</strong>
</div>
</div>
<p className="geo-data-notice">
{queue.summary.reviewed_metrics.review_complete
? `Alle bevindingen zijn beoordeeld. ${queue.summary.reviewed_metrics.false_positive_breakdown.exonerated} onterecht gevonden objecten en ${queue.summary.reviewed_metrics.false_negative_breakdown.exonerated} gemiste objecten bleken een hiaat in de referentielaag, niet een modelfout.`
: `Nog ${queue.summary.reviewed_metrics.false_positive_breakdown.unreviewed + queue.summary.reviewed_metrics.false_negative_breakdown.unreviewed} bevindingen onbeoordeeld. De precisie ligt tussen ${formatScore(queue.summary.reviewed_metrics.pessimistic.precision)} en ${formatScore(queue.summary.reviewed_metrics.optimistic.precision)}; de getoonde waarde rekent elke onbeoordeelde bevinding nog als modelfout.`}
</p>
</div>
) : null}
<div className="detection-review-filters">
<label>
Soort
<select value={roleFilter} onChange={(event) => {
setRoleFilter(event.target.value as typeof roleFilter)
setOffset(0)
}}>
<option value="all">Alles</option>
<option value="false_positive">Onterecht gevonden</option>
<option value="false_negative">Gemist gebouw</option>
</select>
</label>
<label>
Status
<select value={statusFilter} onChange={(event) => {
setStatusFilter(event.target.value as typeof statusFilter)
setOffset(0)
}}>
<option value="unreviewed">Nog te beoordelen</option>
<option value="reviewed">Beoordeeld</option>
<option value="all">Alles</option>
</select>
</label>
<button type="button" className="secondary-action" disabled={loading} onClick={() => void load()}>
{loading ? 'Laden...' : 'Vernieuwen'}
</button>
</div>
{error ? <p className="error" role="alert">{error}</p> : null}
{!loading && queue && queue.items.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen objecten in deze selectie</strong>
<p>Pas de filters aan of open de bewijslaag op de kaart.</p>
</div>
) : null}
<ol className="detection-review-list">
{queue?.items?.map((item) => {
const key = reviewKey(item)
const decision = draftDecisions[key] ?? item.decision
return (
<li key={key} className="detection-review-item">
<div className="detection-review-item-heading">
<div>
<span>{ROLE_LABELS[item.evidence_role]}</span>
<strong>{item.class_name ?? 'gebouw'} / {shortId(item.evidence_feature_id)}</strong>
</div>
{typeof item.confidence === 'number' ? <span className="count-pill">{Math.round(item.confidence * 100)}% vertrouwen</span> : null}
</div>
<div className="detection-review-editor">
<label>
Beoordeling
<select
value={decision}
disabled={reviewLocked}
onChange={(event) => setDraftDecisions((current) => ({
...current,
[key]: event.target.value as DetectionReviewDecision,
}))}
>
{ROLE_DECISIONS[item.evidence_role].map((option) => (
<option key={option} value={option}>{DECISION_LABELS[option]}</option>
))}
</select>
</label>
<label>
Notitie
<input
type="text"
maxLength={2000}
placeholder="Waarom is dit correct, fout of onzeker?"
value={draftNotes[key] ?? ''}
readOnly={reviewLocked}
onChange={(event) => setDraftNotes((current) => ({ ...current, [key]: event.target.value }))}
/>
</label>
<button type="button" className="primary-action" disabled={reviewLocked || savingKey === key} onClick={() => void save(item)}>
{savingKey === key ? 'Bewaren...' : 'Beoordeling bewaren'}
</button>
</div>
</li>
)
})}
</ol>
{queue && queue.total > queue.limit ? (
<div className="detection-review-pagination" aria-label="Pagina's van de controlelijst">
<button
type="button"
className="secondary-action"
disabled={offset === 0 || loading}
onClick={() => setOffset((current) => Math.max(current - queue.limit, 0))}
>
Vorige
</button>
<span>{offset + 1}-{Math.min(offset + queue.limit, queue.total)} van {queue.total}</span>
<button
type="button"
className="secondary-action"
disabled={offset + queue.limit >= queue.total || loading}
onClick={() => setOffset((current) => current + queue.limit)}
>
Volgende
</button>
</div>
) : null}
</section>
)
}
function reviewKey(item: Pick<DetectionReviewRead, 'evidence_role' | 'evidence_feature_id'>): string {
return `${item.evidence_role}:${item.evidence_feature_id}`
}
@@ -0,0 +1,643 @@
import { useMemo, useState } from 'react'
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
import { DetectionReviewPanel } from './DetectionReviewPanel'
const CORE_METRIC_ORDER = [
'precision',
'recall',
'f1',
'mean_iou',
'false_positive_count',
'false_negative_count',
]
interface QualityResultsPanelProps {
reviewLocked?: boolean
selectedProjectId: string | null
qualityChecks: QualityCheckRead[]
qualityChecksError: string | null
candidateDatasets: DatasetCreateResponse[]
referenceDatasets: DatasetCreateResponse[]
onRefresh: () => void
onOpenEvidenceMap?: (qualityCheckId: string) => void
evidenceLoading?: boolean
evidenceError?: string | null
onOpenMapWorkspace: () => void
onOpenAnalysisWorkspace: () => void
}
function qualityMetricLabel(metricKey: string): string {
const labels: Record<string, string> = {
precision: 'Precisie',
recall: 'Teruggevonden aandeel',
f1: 'F1',
mean_iou: 'Gemiddelde overlap',
false_positive_count: 'Onterecht gevonden',
false_negative_count: 'Gemiste objecten',
}
return labels[metricKey] ?? metricKey.replaceAll('_', ' ')
}
function qualityMetricValue(metric: MetricRead | undefined): string {
if (!metric || metric.metric_value === null || metric.metric_value === undefined) {
return 'n.v.t.'
}
const value = Number(metric.metric_value)
if (!Number.isFinite(value)) {
return String(metric.metric_value)
}
return Number.isInteger(value) ? String(value) : value.toFixed(3)
}
function qualityScoreValue(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
}
function qualityScoreInterpretation(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Nog geen score'
if (value >= 0.85) return 'Sterk resultaat'
if (value >= 0.70) return 'Bruikbaar na controle'
if (value >= 0.50) return 'Verkennend, controle vereist'
return 'Onvoldoende betrouwbaar'
}
function qualityStatusLabel(status: string | null | undefined): string {
if (!status || status === 'waiting') return 'nog niet uitgevoerd'
if (status === 'completed' || status === 'ok') return 'afgerond'
if (status === 'failed') return 'mislukt'
return status
}
function qualityCheckTypeLabel(checkType: string | null | undefined): string {
const labels: Record<string, string> = {
vector_vs_reference: 'Kaartlaag tegenover referentie',
detections_vs_reference: 'Gebouwdetectie tegenover referentie',
segmentations_vs_reference: 'Segmentatie tegenover referentie',
detection_vs_reference: 'Gebouwdetectie tegenover referentie',
segmentation_vs_reference: 'Segmentatie tegenover referentie',
}
if (!checkType) return 'Kwaliteitscontrole'
return labels[checkType] ?? checkType.replaceAll('_', ' ')
}
function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined {
return check?.metrics?.find((metric) => metric.metric_key === metricKey)
}
function findingEvidenceList(check: QualityCheckRead | null, key: string): Record<string, unknown>[] {
const value = check?.findings_json?.[key]
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : []
}
function evidenceLabel(item: Record<string, unknown>): string {
const candidate = item['candidate_feature_id']
const reference = item['reference_feature_id']
const iou = item['iou']
const pairLabel = [candidate ? `Te controleren ${candidate}` : null, reference ? `Referentie ${reference}` : null].filter(Boolean).join(' / ')
const iouValue = Number(iou)
return iou === null || iou === undefined || !Number.isFinite(iouValue) ? pairLabel || JSON.stringify(item) : `${pairLabel || 'Match'} / IoU ${iouValue.toFixed(3)}`
}
function formatQualityTimestamp(value?: string | null): string {
return value || 'n.v.t.'
}
function qualityMatchesSearch(check: QualityCheckRead, query: string, datasetNameById: Map<string, string>): boolean {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
return true
}
const candidateName = check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) : null
const referenceName = datasetNameById.get(check.reference_dataset_id)
const searchable = [
check.id,
check.check_type,
check.status,
check.candidate_dataset_id,
check.reference_dataset_id,
check.analysis_run_id,
check.job_id,
candidateName,
referenceName,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchable.includes(normalizedQuery)
}
export function QualityResultsPanel({
reviewLocked = false,
selectedProjectId,
qualityChecks,
qualityChecksError,
candidateDatasets,
referenceDatasets,
onRefresh,
onOpenEvidenceMap,
evidenceLoading = false,
evidenceError = null,
onOpenMapWorkspace,
onOpenAnalysisWorkspace,
}: QualityResultsPanelProps): JSX.Element {
const [showAllQualityChecks, setShowAllQualityChecks] = useState(false)
const [qualityStatusFilter, setQualityStatusFilter] = useState('all')
const [qualityTypeFilter, setQualityTypeFilter] = useState('all')
const [qualitySearchQuery, setQualitySearchQuery] = useState('')
const [selectedQualityCheckId, setSelectedQualityCheckId] = useState<string | null>(null)
const completedChecks = qualityChecks.filter((check) => check.status === 'ok' || check.status === 'completed').length
const latestCheck = qualityChecks[0] ?? null
const datasetNameById = useMemo(() => {
const lookup = new Map<string, string>()
for (const dataset of [...candidateDatasets, ...referenceDatasets]) {
lookup.set(dataset.id, dataset.name)
}
return lookup
}, [candidateDatasets, referenceDatasets])
const latestCandidateName = latestCheck?.candidate_dataset_id ? datasetNameById.get(latestCheck.candidate_dataset_id) : null
const latestReferenceName = latestCheck?.reference_dataset_id ? datasetNameById.get(latestCheck.reference_dataset_id) : null
const selectedQualityCheck = useMemo(
() => qualityChecks.find((check) => check.id === selectedQualityCheckId) ?? latestCheck,
[latestCheck, qualityChecks, selectedQualityCheckId],
)
const selectedCandidateName = selectedQualityCheck?.candidate_dataset_id
? datasetNameById.get(selectedQualityCheck.candidate_dataset_id) ?? 'Laag niet meer in de gegevenslijst'
: 'Niet bewaard'
const selectedReferenceName = selectedQualityCheck
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? 'Referentielaag niet meer in de gegevenslijst'
: 'Niet beschikbaar'
const selectedMatchEvidence = findingEvidenceList(selectedQualityCheck, 'match_evidence')
const selectedFalsePositiveEvidence = findingEvidenceList(selectedQualityCheck, 'false_positive_evidence')
const selectedFalseNegativeEvidence = findingEvidenceList(selectedQualityCheck, 'false_negative_evidence')
const qualityStatuses = useMemo(() => Array.from(new Set(qualityChecks.map((check) => check.status))).sort(), [qualityChecks])
const qualityTypes = useMemo(() => Array.from(new Set(qualityChecks.map((check) => check.check_type))).sort(), [qualityChecks])
const filteredQualityChecks = useMemo(
() =>
qualityChecks.filter((check) => {
const statusMatches = qualityStatusFilter === 'all' || check.status === qualityStatusFilter
const typeMatches = qualityTypeFilter === 'all' || check.check_type === qualityTypeFilter
return statusMatches && typeMatches && qualityMatchesSearch(check, qualitySearchQuery, datasetNameById)
}),
[qualityChecks, qualityStatusFilter, qualityTypeFilter, qualitySearchQuery, datasetNameById],
)
const visibleQualityChecks = showAllQualityChecks ? filteredQualityChecks : filteredQualityChecks.slice(0, 8)
const hiddenQualityCheckCount = Math.max(filteredQualityChecks.length - visibleQualityChecks.length, 0)
const hasActiveQualityFilters = qualityStatusFilter !== 'all' || qualityTypeFilter !== 'all' || qualitySearchQuery.trim().length > 0
return (
<section className={qualityChecks.length === 0 ? 'quality-results-panel quality-results-panel-empty' : 'quality-results-panel'} data-testid="quality-results-panel">
<div className="quality-results-shell">
<div className="panel-title-row">
<div>
<h2>Kwaliteitsresultaten</h2>
<p className="muted">Bewaarde controles van kaartlagen en beeldanalyses.</p>
</div>
<span className="count-pill">{qualityChecks.length} controles</span>
</div>
<div className="quality-summary-surface" aria-label="Samenvatting kwaliteitsresultaten">
<div className="quality-summary-grid">
<div>
<span>Afgerond</span>
<strong>{completedChecks}</strong>
</div>
<div>
<span>Laatste score (0-1)</span>
<strong>{qualityScoreValue(latestCheck?.score)}</strong>
<small>{qualityScoreInterpretation(latestCheck?.score)}</small>
</div>
<div>
<span>Laatste status</span>
<strong>{qualityStatusLabel(latestCheck?.status)}</strong>
</div>
</div>
</div>
{qualityChecks.length === 0 ? (
<div className="quality-empty-workbench" aria-label="Lege kwaliteitswerkruimte">
<aside className="quality-empty-context">
<span className="eyebrow">Controles</span>
<strong>Nog geen resultaten</strong>
<p>Kwaliteitscontroles verschijnen hier nadat een resultaat met een officiële referentielaag is vergeleken.</p>
<div className="quality-empty-context-stat">
<span>Te controleren lagen</span>
<strong>{candidateDatasets.length}</strong>
</div>
<div className="quality-empty-context-stat">
<span>Referentielagen</span>
<strong>{referenceDatasets.length}</strong>
</div>
<button type="button" className="secondary-action" onClick={onRefresh} disabled={!selectedProjectId}>
Resultaten vernieuwen
</button>
</aside>
<div className="quality-empty-results">
<div className="quality-empty-column-heading">
<strong>Resultaten</strong>
<span>0 controles</span>
</div>
<div className="result-state result-state-empty quality-user-empty-state">
<strong>Nog geen kwaliteitscontrole uitgevoerd</strong>
<p>Start op de kaart met een gemeente en maak daar een begrensd resultaat. Vergelijk beeldanalyse vervolgens met een officiële referentielaag.</p>
<div className="button-row">
<button type="button" className="primary-action" onClick={onOpenMapWorkspace}>Open kaart en kies gemeente</button>
<button type="button" className="secondary-action" onClick={onOpenAnalysisWorkspace}>Open beeldanalyse</button>
</div>
</div>
</div>
<aside className="quality-empty-inspector">
<div className="quality-empty-column-heading">
<strong>Controle-inspector</strong>
<span>Geen selectie</span>
</div>
<div className="result-state result-state-empty">
<strong>Geen bewijs geselecteerd</strong>
<p>Scores, databronnen en kaartbewijs worden hier samen getoond zodra een controle beschikbaar is.</p>
</div>
</aside>
</div>
) : null}
{qualityChecks.length > 0 ? (
<details className="quality-advanced-disclosure" open>
<summary>
<span>Details, kaartbewijs en historiek</span>
<strong>{qualityChecks.length} bewaarde controles</strong>
</summary>
<div className="quality-evidence-surface" aria-label="Databronnen van de kwaliteitscontrole">
<div className="quality-handoff-grid" aria-label="Context van de vergeleken databronnen">
<div>
<span>Te controleren lagen</span>
<strong>{candidateDatasets.length}</strong>
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'Nog geen te controleren laag gekozen'}</p>
</div>
<div>
<span>Referentielagen</span>
<strong>{referenceDatasets.length}</strong>
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'Nog geen referentielaag beschikbaar'}</p>
</div>
<div>
<span>Laatste vergelijking</span>
<strong>{qualityStatusLabel(latestCheck?.status)}</strong>
<p className="quality-dataset-name">
Te controleren: {latestCandidateName ?? 'laag niet meer beschikbaar'} / referentie: {latestReferenceName ?? 'laag niet meer beschikbaar'}
</p>
</div>
</div>
</div>
<div className="quality-drilldown-surface" aria-label="Details van het kaartbewijs">
<div className="panel-title-row">
<div>
<h3>Bewijs van de kwaliteitscontrole</h3>
<p className="muted">Bekijk de bewaarde vergelijking, scores en kaartlagen voordat je resultaten deelt.</p>
</div>
<button
type="button"
className="secondary-action"
onClick={() => setSelectedQualityCheckId(latestCheck?.id ?? null)}
disabled={!latestCheck}
>
Laatste controle bekijken
</button>
<button
type="button"
className="secondary-action"
onClick={() => latestCheck?.id && onOpenEvidenceMap?.(latestCheck.id)}
disabled={!latestCheck || evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Kaartbewijs laden...' : 'Laatste op kaart tonen'}
</button>
</div>
{evidenceError ? (
<div className="result-state result-state-error">
<strong>Het bewijs kon niet op de kaart worden geladen.</strong>
<p>{evidenceError}</p>
</div>
) : null}
{selectedQualityCheck ? (
<>
<div className="quality-drilldown-grid">
<div>
<span>Geselecteerde controle</span>
<strong>{qualityCheckTypeLabel(selectedQualityCheck.check_type)}</strong>
<p>{qualityScoreInterpretation(selectedQualityCheck.score)}</p>
</div>
<div>
<span>Te controleren laag</span>
<strong>{selectedCandidateName}</strong>
<p>De laag waarvan de kwaliteit wordt beoordeeld.</p>
</div>
<div>
<span>Referentielaag</span>
<strong>{selectedReferenceName}</strong>
<p>De bewaarde bron waarmee wordt vergeleken.</p>
</div>
<div>
<span>Beoordeling</span>
<strong>{qualityScoreInterpretation(selectedQualityCheck.score)}</strong>
<p>Score: {qualityScoreValue(selectedQualityCheck.score)} op een schaal van 0 tot 1</p>
</div>
<div>
<span>Status</span>
<strong>{qualityStatusLabel(selectedQualityCheck.status)}</strong>
<p>Resultaat is bewaard in de werkruimte.</p>
</div>
<div>
<span>Afgerond</span>
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
<p>Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<details className="technical-inline-details">
<summary>Technische identificatie en verwerking</summary>
<div className="entity-meta">
<span>Controle-ID: {selectedQualityCheck.id}</span>
<span>Type: {selectedQualityCheck.check_type}</span>
<span>Te controleren dataset-ID: {selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</span>
<span>Referentie-dataset-ID: {selectedQualityCheck.reference_dataset_id}</span>
<span>Analyserun-ID: {selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</span>
<span>Taak-ID: {selectedQualityCheck.job_id ?? 'n.v.t.'}</span>
</div>
</details>
<div className="quality-evidence-token-grid">
<div>
<span>Onterecht gevonden</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_positive_count'))}</strong>
<p>Gevonden vormen zonder overeenkomst in de referentielaag.</p>
</div>
<div>
<span>Gemiste objecten</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_negative_count'))}</strong>
<p>Objecten uit de referentielaag die niet werden gevonden.</p>
</div>
<div className="quality-evidence-map-handoff">
<span>Controle op de kaart</span>
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
<p>Toon beide lagen samen op de kaart voor een ruimtelijke controle.</p>
<button
type="button"
className="secondary-action"
onClick={() => onOpenEvidenceMap?.(selectedQualityCheck.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Lagen laden...' : 'Bewijs op kaart tonen'}
</button>
</div>
</div>
{selectedQualityCheck.check_type === 'detections_vs_reference' && selectedProjectId ? (
<DetectionReviewPanel
projectId={selectedProjectId}
qualityCheckId={selectedQualityCheck.id}
reviewLocked={reviewLocked}
onOpenEvidenceMap={onOpenEvidenceMap}
/>
) : null}
<div className="quality-feature-evidence-grid" aria-label="Kaartbewijs per object">
<div>
<span>Overeenkomende object-ID's</span>
{selectedMatchEvidence.length > 0 ? (
<ul>
{selectedMatchEvidence.slice(0, 6).map((item, index) => (
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
))}
</ul>
) : (
<p>Voor deze controle zijn geen overeenkomende object-ID's bewaard.</p>
)}
</div>
<div>
<span>ID's van onterecht gevonden objecten</span>
{selectedFalsePositiveEvidence.length > 0 ? (
<ul>
{selectedFalsePositiveEvidence.slice(0, 6).map((item, index) => (
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
))}
</ul>
) : (
<p>Er zijn geen ID's van onterecht gevonden objecten bewaard.</p>
)}
</div>
<div>
<span>ID's van gemiste objecten</span>
{selectedFalseNegativeEvidence.length > 0 ? (
<ul>
{selectedFalseNegativeEvidence.slice(0, 6).map((item, index) => (
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
))}
</ul>
) : (
<p>Er zijn geen ID's van gemiste objecten bewaard.</p>
)}
</div>
</div>
<div className="quality-provenance-grid">
<div>
<span>Instellingen</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.parameters_json ?? {}, null, 2)}
</pre>
</div>
<div>
<span>Bevindingen</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.findings_json ?? {}, null, 2)}
</pre>
</div>
</div>
</>
) : (
<div className="result-state result-state-empty">
<strong>Geen kwaliteitscontrole geselecteerd.</strong>
<p>Kies een bewaarde controle om scores en kaartbewijs te bekijken.</p>
</div>
)}
</div>
<div className="quality-control-surface" aria-label="Kwaliteitsresultaten vernieuwen en filteren">
<div className="button-row">
<button
type="button"
className="secondary-action"
onClick={onRefresh}
disabled={!selectedProjectId}
data-testid="refresh-quality-results"
>
Kwaliteitsresultaten vernieuwen
</button>
</div>
<div className="quality-result-state-stack">
{qualityChecksError ? (
<div className="result-state result-state-error">
<strong>De kwaliteitsresultaten konden niet worden geladen.</strong>
<p>{qualityChecksError}</p>
</div>
) : null}
{qualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Nog geen bewaarde kwaliteitsresultaten</strong>
<p>Vergelijk een kaartlaag of beeldanalyse met een referentielaag om deze werkruimte te vullen.</p>
</div>
) : null}
</div>
{qualityChecks.length > 0 ? (
<div className="quality-history-controls" aria-label="Filters voor kwaliteitsresultaten">
<label>
Kwaliteitsresultaten zoeken
<input
type="search"
placeholder="Type of naam van een kaartlaag"
value={qualitySearchQuery}
onChange={(event) => setQualitySearchQuery(event.target.value)}
/>
</label>
<label>
Status
<select value={qualityStatusFilter} onChange={(event) => setQualityStatusFilter(event.target.value)}>
<option value="all">Alle statussen</option>
{qualityStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<label>
Type controle
<select value={qualityTypeFilter} onChange={(event) => setQualityTypeFilter(event.target.value)}>
<option value="all">Alle types</option>
{qualityTypes.map((checkType) => (
<option key={checkType} value={checkType}>
{checkType}
</option>
))}
</select>
</label>
<button
type="button"
className="secondary-action"
onClick={() => {
setQualitySearchQuery('')
setQualityStatusFilter('all')
setQualityTypeFilter('all')
setShowAllQualityChecks(false)
}}
disabled={!hasActiveQualityFilters && !showAllQualityChecks}
>
Filters wissen
</button>
</div>
) : null}
{filteredQualityChecks.length > 8 ? (
<div className="list-limit-banner">
<span className="quality-list-count">
{visibleQualityChecks.length} van {filteredQualityChecks.length} kwaliteitsresultaten getoond.
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} oudere controles verborgen.` : ' Alle resultaten zijn zichtbaar.'}
</span>
<button type="button" className="secondary-action" onClick={() => setShowAllQualityChecks((value) => !value)}>
{showAllQualityChecks ? 'Alleen laatste 8 tonen' : 'Alle kwaliteitsresultaten tonen'}
</button>
</div>
) : null}
{qualityChecks.length > 0 && filteredQualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen kwaliteitsresultaten voldoen aan de filters.</strong>
<p>Wis de zoek-, status- of typefilter om de laatste resultaten opnieuw te tonen.</p>
</div>
) : null}
</div>
<div className="quality-history-surface" aria-label="Geschiedenis van kwaliteitsresultaten">
<div className="panel-title-row">
<h3>Historiek</h3>
<span className="count-pill">{visibleQualityChecks.length} getoond</span>
</div>
<ul className="quality-check-list">
{visibleQualityChecks.map((check) => (
<li className="quality-check-card" key={check.id}>
<div className="quality-check-header">
<div>
<strong>{qualityCheckTypeLabel(check.check_type)}</strong>
<div className="entity-meta">
<span className="quality-check-dataset-link">
Te controleren: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? 'laag niet meer beschikbaar' : 'n.v.t.'}
</span>
<span className="quality-check-dataset-link">
Referentie: {datasetNameById.get(check.reference_dataset_id) ?? 'laag niet meer beschikbaar'}
</span>
</div>
</div>
<span className={check.status === 'ok' || check.status === 'completed' ? 'status-badge status-badge-ready' : 'status-badge'}>
{qualityStatusLabel(check.status)}
</span>
</div>
<div className="quality-check-actions">
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
Controle bekijken
</button>
<button
type="button"
className="secondary-action"
onClick={() => onOpenEvidenceMap?.(check.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
Bewijs op kaart tonen
</button>
</div>
<div className="quality-score-row">
<div>
<span>Score</span>
<strong>{qualityScoreValue(check.score)}</strong>
</div>
<div>
<span>Meetwaarden</span>
<strong>{check.metrics.length}</strong>
</div>
<div>
<span>Beoordeling</span>
<strong>{qualityScoreInterpretation(check.score)}</strong>
</div>
</div>
<details className="technical-inline-details">
<summary>Technische referentie</summary>
<div className="entity-meta">
<span>Controle-ID: {check.id}</span>
<span>Type: {check.check_type}</span>
</div>
</details>
<div className="quality-metric-section">
<span>Gemeten kwaliteit</span>
<div className="quality-metric-grid">
{CORE_METRIC_ORDER.map((metricKey) => {
const metric = check.metrics.find((item) => item.metric_key === metricKey)
const isCriticalCount = metricKey === 'false_positive_count' || metricKey === 'false_negative_count'
return (
<div className={isCriticalCount ? 'quality-metric-card quality-metric-card-critical' : 'quality-metric-card'} key={metricKey}>
<span>{qualityMetricLabel(metricKey)}</span>
<strong>{qualityMetricValue(metric)}</strong>
</div>
)
})}
</div>
</div>
<span className="metric-list-label">Alle meetwaarden</span>
<ul className="metric-list">
{check.metrics.map((metric) => (
<li key={metric.id}>
<span>{qualityMetricLabel(metric.metric_key)}</span>
<strong>{qualityMetricValue(metric)}</strong>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
</details>
) : null}
</div>
</section>
)
}
@@ -0,0 +1,529 @@
import type {
DatasetCreateResponse,
JobRead,
SegmentationModelCapability,
SegmentationQaResult,
SegmentationRead,
SegmentationRunRead,
SegmentationRunResponse,
} from '../../types'
import { ModelSelector } from '../models/ModelSelector'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from '../models/modelOptions'
import { getDatasetSelectionLabel } from '../../lib/datasetDisplay'
interface SegmentationLabProps {
managementLocked?: boolean
blockedInferenceDatasetCount?: number
segmentationModels: SegmentationModelCapability[]
loadingSegmentationModels: boolean
segmentationModelError: string | null
selectedSegmentationDatasetId: string
selectedSegmentationModelId: string
segmentationTileManifestPath: string
segmentationConfidenceThreshold: number
runningSegmentation: boolean
segmentationJob: JobRead | null
segmentationRunResult: SegmentationRunResponse | null
segmentationRunError: string | null
segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string
segmentationItems: SegmentationRead[]
segmentationTotal: number
segmentationTruncated: boolean
segmentationClassFilter: string
segmentationMinConfidenceFilter: number
loadingSegmentationResults: boolean
segmentationReferenceDatasetId: string
segmentationQaResult: SegmentationQaResult | null
segmentationQaError: string | null
runningSegmentationQa: boolean
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
referenceDatasets: DatasetCreateResponse[]
selectedSegmentationModelConfigured: boolean
selectedSegmentationModelLimitation: string | null
onLoadModels: () => void
onSelectDataset: (datasetId: string) => void
onSelectModel: (modelId: string) => void
onSetTileManifestPath: (value: string) => void
onSetConfidenceThreshold: (value: number) => void
onRunSegmentation: () => void
onLoadRuns: () => void
onSelectRun: (runId: string) => void
onSetClassFilter: (value: string) => void
onSetMinConfidenceFilter: (value: number) => void
onLoadResults: () => void
onSelectReferenceDataset: (datasetId: string) => void
onRunQa: () => void
}
function segmentationRunLabel(run: SegmentationRunRead): string {
const timestamp = run.finished_at ?? run.created_at
const dateLabel = timestamp
? new Intl.DateTimeFormat('nl-BE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(timestamp))
: 'datum onbekend'
return `${persistedSegmentationModelLabel(run.model_name)} · ${analysisStatusLabel(run.status)} · ${dateLabel}`
}
function analysisStatusLabel(status: string): string {
if (status === 'completed') return 'afgerond'
if (status === 'success') return 'geslaagd'
if (status === 'failed') return 'mislukt'
if (status === 'running') return 'bezig'
if (status === 'queued') return 'in wachtrij'
return status.replace(/_/g, ' ')
}
function persistedSegmentationModelLabel(modelName: string | null | undefined): string {
if (!modelName) return 'Segmentatie'
if (modelName === 'fixture-segmenter') return 'Testsegmentatie'
if (modelName === 'sam-placeholder') return 'SAM-model niet geconfigureerd'
if (modelName === 'yolo-seg-placeholder') return 'YOLO-segmentatie niet geconfigureerd'
if (modelName === 'segmentation-placeholder') return 'Segmentatiemodel niet geconfigureerd'
if (modelName === 'yolo-seg-configured') return 'Lokaal YOLO-segmentatiemodel'
if (modelName === 'sam-configured') return 'Lokaal SAM-model'
return modelName
}
function segmentationClassLabel(className: string): string {
if (className.toLowerCase() === 'building') return 'Gebouw'
return className
}
export function SegmentationLab({
managementLocked = false,
blockedInferenceDatasetCount = 0,
segmentationModels,
loadingSegmentationModels,
segmentationModelError,
selectedSegmentationDatasetId,
selectedSegmentationModelId,
segmentationTileManifestPath,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationJob,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationClassFilter,
segmentationMinConfidenceFilter,
loadingSegmentationResults,
segmentationReferenceDatasetId,
segmentationQaResult,
segmentationQaError,
runningSegmentationQa,
selectedProjectId,
rasterDatasets,
referenceDatasets,
selectedSegmentationModelConfigured,
selectedSegmentationModelLimitation,
onLoadModels,
onSelectDataset,
onSelectModel,
onSetTileManifestPath,
onSetConfidenceThreshold,
onRunSegmentation,
onLoadRuns,
onSelectRun,
onSetClassFilter,
onSetMinConfidenceFilter,
onLoadResults,
onSelectReferenceDataset,
onRunQa,
}: SegmentationLabProps): JSX.Element {
const segmentationHasDataset = selectedSegmentationDatasetId.length > 0
const segmentationHasTileManifest = segmentationTileManifestPath.trim().length > 0
const segmentationModelUiRunnable =
selectedSegmentationModelConfigured && selectedSegmentationModelId !== 'fixture-segmenter'
const selectedSegmentationModel = segmentationModels.find(
(model) => model.model_id === selectedSegmentationModelId,
) ?? null
const selectedSegmentationModelAvailability = selectedSegmentationModel
? analysisModelAvailabilityMessage(selectedSegmentationModel)
: selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
const segmentationRunReady =
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
const segmentationJobActive = segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'
const segmentationRunBlockedReason = !selectedProjectId
? 'Kies eerst een werkruimte'
: !segmentationHasDataset
? 'Kies een rasterbestand'
: selectedSegmentationModelId === 'fixture-segmenter'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo’s'
: !selectedSegmentationModelConfigured
? selectedSegmentationModelAvailability
: null
return (
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
<div className="panel-title-row">
<div>
<p className="eyebrow">Vlakken herkennen in beeld</p>
<h2>Segmentatie</h2>
</div>
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
Status vernieuwen
</button>
</div>
<details className="ai-lab-model-surface" aria-label="Technische informatie over segmentatiemodellen">
<summary>
<span>Technische modelinformatie</span>
<strong>{segmentationModels.length} modellen</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
{loadingSegmentationModels ? (
<div className="result-state result-state-loading">
<strong>Segmentatiemodellen worden gecontroleerd.</strong>
<p>GeoIntel leest de modelregistratie van de backend.</p>
</div>
) : null}
{segmentationModelError ? (
<div className="result-state result-state-error">
<strong>De modelregistratie voor segmentatie is niet bereikbaar.</strong>
<p>{segmentationModelError}</p>
</div>
) : null}
{segmentationModels.length === 0 && !loadingSegmentationModels ? (
<div className="result-state result-state-empty">
<strong>De backend meldt geen segmentatiemodellen.</strong>
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</p>
</div>
) : null}
</div>
<ul className="model-list">
{segmentationModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{analysisModelDisplayName(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
<span>Model-ID: {model.model_id}</span>
<span>Framework: {model.framework}</span>
<span>Taaktype: {model.task_type}</span>
</div>
</details>
</li>
))}
</ul>
</div>
</details>
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Segmentatie starten">
<h3>Nieuwe segmentatie</h3>
<div
className={segmentationRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
aria-label="Startklaar voor segmentatie"
>
<div className="ai-lab-section-header">
<div>
<h3>Wat is nog nodig?</h3>
<p>GeoIntel controleert het raster en model voordat de verwerking start.</p>
</div>
<span className={segmentationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{segmentationRunReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={segmentationHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Rasterbestand</span>
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
</div>
<div className={segmentationModelUiRunnable ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Analysemodel</span>
<strong>
{selectedSegmentationModelId === 'fixture-segmenter'
? 'Alleen beschikbaar voor geautomatiseerde tests'
: selectedSegmentationModelConfigured
? 'Het gekozen model is beschikbaar'
: selectedSegmentationModelAvailability}
</strong>
</div>
<div className={segmentationHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : segmentationHasDataset ? 'Worden automatisch voorbereid' : 'Wachten op een rasterbestand'}</strong>
</div>
</div>
</div>
<div className={segmentationRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
<span>Analyse</span>
<strong>{segmentationRunReady ? 'Klaar om segmentatie te starten' : segmentationRunBlockedReason}</strong>
</div>
{rasterDatasets.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen rasterbestand beschikbaar voor segmentatie.</strong>
{blockedInferenceDatasetCount > 0 ? (
<p>{blockedInferenceDatasetCount} rasterbestand(en) zijn vooraf uitgesloten door fixture-, quarantaine-, validatie- of provenancegegevens. De volledige servergate blijft beslissend.</p>
) : null}
<p>{managementLocked
? 'Ga naar Kaart en maak een begrensde officiële bronselectie. De synthetische demo-context blijft bewust uitgesloten van geconfigureerde modelinference.'
: 'Voeg eerst een gevalideerd rasterbestand toe onder Bronnen of haal via Kaart een begrensde officiële bron op.'}</p>
</div>
) : null}
<div className="lab-form-grid">
<label>
Rasterbestand
<select
value={selectedSegmentationDatasetId}
onChange={(event) => {
onSelectDataset(event.target.value)
onSetTileManifestPath('')
}}
>
<option value="">Kies een rasterbestand</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
</label>
<ModelSelector
label="Analysemodel"
value={selectedSegmentationModelId}
options={segmentationModels.map(toAnalysisModelOption)}
onChange={onSelectModel}
loading={loadingSegmentationModels}
error={segmentationModelError}
onRefresh={onLoadModels}
/>
<label>
Minimale zekerheid
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/>
</label>
</div>
{!managementLocked ? <details className="technical-inline-details">
<summary>Technische beeldtegelinstelling</summary>
<label>
Beeldtegelmanifest
<input
type="text"
placeholder="Pad naar het beeldtegelmanifest"
value={segmentationTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
</label>
</details> : null}
<button
className="primary-action"
type="button"
onClick={onRunSegmentation}
disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}
>
{segmentationJob?.status === 'queued'
? 'Wachten op NVIDIA GPU…'
: runningSegmentation
? segmentationHasTileManifest ? 'GPU-segmentatie wordt verwerkt…' : 'Beeldtegels voorbereiden…'
: 'Segmentatie starten'}
</button>
</div>
</div>
<div className="ai-lab-state-stack">
{segmentationJobActive ? (
<div className="result-state result-state-loading" role="status" aria-live="polite">
<strong>{segmentationJob?.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-segmentatie wordt uitgevoerd.'}</strong>
<p>
{segmentationJob?.status === 'queued'
? 'De server start de taak zodra de NVIDIA-worker beschikbaar is.'
: 'GeoIntel volgt de servertaak en toont na voltooiing alleen de werkelijk bewaarde polygonen.'}
</p>
<span className="muted">Taak-ID: {segmentationJob?.id}</span>
</div>
) : null}
{!selectedSegmentationModelConfigured ? (
<div className="result-state result-state-empty">
<strong>Het segmentatiemodel is nog niet gereed.</strong>
<p>{selectedSegmentationModelAvailability}</p>
</div>
) : null}
{segmentationRunError ? (
<div className="result-state result-state-error" role="alert">
<strong>De segmentatie is mislukt.</strong>
<p>{segmentationRunError}</p>
</div>
) : null}
{segmentationRunResult ? (
<div className={segmentationRunResult.segmentation_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {analysisStatusLabel(segmentationRunResult.status)}</p>
<p>{segmentationRunResult.message}</p>
<p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p>
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
<details className="technical-inline-details">
<summary>Technische verwerking</summary>
<div className="entity-meta">
<span>Analyserun-ID: {segmentationRunResult.analysis_run_id}</span>
<span>Taak-ID: {segmentationRunResult.job_id}</span>
</div>
</details>
</div>
) : null}
</div>
<div className="ai-lab-results-surface" aria-label="Segmentatieresultaten">
<div className="panel-title-row">
<div>
<h3>Herkende vlakken</h3>
<p className="muted">Bekijk bewaarde resultaten en filter op klasse of zekerheid.</p>
</div>
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Analyses vernieuwen
</button>
</div>
<div className="lab-form-grid">
<label>
Analyse
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Kies een bewaarde analyse</option>
{segmentationRuns.map((run) => (
<option key={run.id} value={run.id}>
{segmentationRunLabel(run)}
</option>
))}
</select>
</label>
<label>
Klasse
<input
type="text"
placeholder="Filter op klasse"
value={segmentationClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
</label>
<label>
Minimale zekerheid
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
</label>
</div>
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}>
Resultaten laden
</button>
{loadingSegmentationResults ? (
<div className="result-state result-state-loading">
<strong>Segmentatieresultaten worden geladen.</strong>
<p>GeoIntel leest de bewaarde polygonen van deze analyse.</p>
</div>
) : null}
<div className="ai-lab-state-stack">
{loadingSegmentationResults || segmentationRunError ? null : !selectedSegmentationRunId ? (
<div className="result-state result-state-empty">
<strong>Kies eerst een bewaarde analyse.</strong>
<p>Daarna toont GeoIntel uitsluitend de polygonen van die analyserun.</p>
</div>
) : segmentationTotal === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen bewaarde vlakken binnen deze filters.</strong>
<p>Dit bewijst niet dat het gebied geen relevante objecten bevat.</p>
</div>
) : (
<div className={segmentationTruncated ? 'result-state result-state-warning' : 'result-state result-state-ready'}>
<strong>
{segmentationTruncated
? `${segmentationItems.length} van ${segmentationTotal} vlakken geladen`
: `${segmentationTotal} vlakken geladen`}
</strong>
<p>
{segmentationTruncated
? 'De kaart en tabel tonen een begrensde pagina. Gebruik filters om het resultaat gericht te verfijnen.'
: 'Deze resultaten zijn bewaard in de database.'}
</p>
</div>
)}
</div>
{segmentationItems.length > 0 ? (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Klasse</th>
<th>Zekerheid</th>
<th>Oppervlakte m²</th>
<th>Model</th>
<th>Herkomst</th>
</tr>
</thead>
<tbody>
{segmentationItems.map((segmentation) => (
<tr key={segmentation.id}>
<td>{segmentationClassLabel(segmentation.class_name)}</td>
<td>{segmentation.confidence?.toFixed(2) ?? 'n.v.t.'}</td>
<td>{segmentation.area_m2?.toFixed(2) ?? 'n.v.t.'}</td>
<td>{persistedSegmentationModelLabel(segmentation.model_name)}</td>
<td>{segmentation.tile_index == null ? 'n.v.t.' : 'Luchtbeeldtegel'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
<div className="ai-lab-qa-surface" aria-label="Kwaliteitscontrole voor segmentatie">
<h3>Kwaliteitscontrole segmentatie</h3>
<label>
Referentielaag
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
</label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
Vergelijk met referentielaag
</button>
{segmentationQaError ? (
<div className="result-state result-state-error">
<strong>De kwaliteitscontrole is mislukt.</strong>
<p>{segmentationQaError}</p>
</div>
) : null}
{segmentationQaResult ? (
<div className="result-summary-card">
<p>Status: {segmentationQaResult.status === 'completed' ? 'afgerond' : segmentationQaResult.status}</p>
<p>Precisie: {segmentationQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Herkenningsgraad: {segmentationQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
<p>F1: {segmentationQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Gemiddelde overlap: {segmentationQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Onterecht gevonden: {segmentationQaResult.false_positives}</p>
<p>Gemist: {segmentationQaResult.false_negatives}</p>
<details className="technical-inline-details">
<summary>Technische referentie</summary>
<span>Kwaliteitscontrole-ID: {segmentationQaResult.quality_check_id}</span>
</details>
</div>
) : null}
</div>
</section>
)
}
@@ -0,0 +1,77 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSecondaryDisplay } from './SecondaryDisplay'
import { parseSecondaryDisplayGeometry, secondaryDisplayFeatures } from './secondaryDisplayGeometry'
afterEach(() => {
vi.restoreAllMocks()
delete document.body.dataset.theme
document.body.replaceChildren()
})
describe('secondary display geometry', () => {
it('restores a valid remembered window position', () => {
expect(parseSecondaryDisplayGeometry('{"left":1920,"top":0,"width":720,"height":1040}')).toEqual({
left: 1920,
top: 0,
width: 720,
height: 1040,
})
})
it('rejects malformed state and bounds unsafe dimensions', () => {
expect(parseSecondaryDisplayGeometry('not-json')).toBeNull()
expect(parseSecondaryDisplayGeometry('{"left":0,"top":0,"width":2,"height":99999}')).toEqual({
left: 0,
top: 0,
width: 420,
height: 2160,
})
})
it('builds a resizable popup feature string', () => {
expect(secondaryDisplayFeatures({ left: 1920, top: 10, width: 700, height: 900 })).toContain(
'left=1920,top=10,width=700,height=900,resizable=yes,scrollbars=yes',
)
})
})
describe('secondary display document', () => {
it('preserves metadata, semantics, focus and the active theme', async () => {
const iframe = document.createElement('iframe')
document.body.append(iframe)
const popup = iframe.contentWindow!
let closed = false
Object.defineProperty(popup, 'closed', { configurable: true, get: () => closed })
Object.defineProperty(popup, 'close', { configurable: true, value: vi.fn(() => { closed = true }) })
Object.defineProperty(popup, 'focus', { configurable: true, value: vi.fn() })
vi.spyOn(window, 'open').mockReturnValue(popup)
document.body.dataset.theme = 'dark'
const launcher = document.createElement('button')
launcher.textContent = 'Open tweede scherm'
document.body.append(launcher)
launcher.focus()
const view = renderHook(() => useSecondaryDisplay())
act(() => view.result.current.open())
expect(popup.document.title).toBe('GeoIntel · Analyseconsole')
expect(popup.document.documentElement.lang).toBe(document.documentElement.lang || 'nl')
expect(popup.document.querySelector('meta[charset="utf-8"]')).not.toBeNull()
expect(popup.document.querySelector('base')?.href).toBe(document.baseURI)
expect(popup.document.querySelector('meta[name="viewport"]')).not.toBeNull()
expect(popup.document.querySelector('h1')?.textContent).toBe('Analyseconsole')
expect(popup.document.querySelector('main > h2')?.textContent).toBe('Live analyse en resultaten')
expect(popup.document.activeElement).toBe(popup.document.querySelector('h1'))
expect(popup.document.body.dataset.theme).toBe('dark')
act(() => {
document.body.dataset.theme = 'light'
})
await waitFor(() => expect(popup.document.body.dataset.theme).toBe('light'))
act(() => view.result.current.close())
expect(document.activeElement).toBe(launcher)
})
})
@@ -0,0 +1,318 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import {
parseSecondaryDisplayGeometry,
secondaryDisplayFeatures,
type SecondaryDisplayGeometry,
} from './secondaryDisplayGeometry'
const SECONDARY_DISPLAY_NAME = 'geointel-analysis-console'
const SECONDARY_DISPLAY_GEOMETRY_KEY = 'geointel.secondary-display.geometry.v1'
function defaultGeometry(): SecondaryDisplayGeometry {
return {
left: window.screenX + Math.max(80, window.outerWidth - 32),
top: window.screenY,
width: 620,
height: Math.max(640, Math.min(1_080, window.outerHeight)),
}
}
/**
* Neemt de opmaak van het hoofdvenster mee naar de console.
*
* De vorige versie kloonde de <link rel="stylesheet"> naar het nieuwe venster.
* Dat venster wordt geopend met window.open('') en is dus about:blank; daar
* werd de link wel in de head gezet maar nooit opgehaald — nagemeten leverde
* link.sheet === null op. Gevolg: de console stond volledig onopgemaakt, in
* Times New Roman op wit, terwijl de werkbank ernaast donker was. Het verklaart
* ook waarom de paneelregels voor dit venster met !important stonden: die
* probeerden iets te overschrijven dat er nooit aankwam. Sinds de opmaak hier
* wel aankomt zijn ze overbodig gebleken en verwijderd.
*
* Nu worden de regels zelf ingeschreven. Dat is dezelfde oorsprong, dus
* cssRules is leesbaar, en er komt geen netwerkverzoek aan te pas. Lukt het
* lezen toch niet, dan valt hij terug op de gekloonde link.
*/
function copyDocumentStyles(target: Document): void {
const regels: string[] = []
let alleenGelezen = true
// Verwijzingen in de regels — lettertypen, iconen, achtergronden — staan
// relatief. Het nieuwe venster is about:blank en heeft dus geen basis om ze
// tegen op te lossen; zonder deze stap blijft het wachten op lettertypen die
// nooit aankomen. Ze worden hier absoluut gemaakt tegen de bron van het blad.
const maakAbsoluut = (tekst: string, basis: string): string =>
tekst.replace(/url\((['"]?)([^'")]+)\1\)/g, (heel, quote, verwijzing) => {
if (/^(data:|blob:|https?:|\/\/)/i.test(verwijzing)) return heel
try {
return `url("${new URL(verwijzing, basis).href}")`
} catch {
return heel
}
})
// @font-face gaat bewust niet mee. De bestanden komen in dit venster wel
// binnen met status 200, maar de FontFace springt nooit naar 'loaded': in een
// document dat op about:blank staat voltooit het lettertypeladen niet. Het
// gevolg was dat document.fonts.status eeuwig op 'loading' bleef en de console
// kort in terugvalletters opende. De gezichten worden in plaats daarvan
// overgenomen uit het hoofdvenster, waar ze al geladen zijn — zie
// copyLoadedFonts hieronder.
const isFontFace = (regel: CSSRule): boolean =>
typeof CSSFontFaceRule !== 'undefined' && regel instanceof CSSFontFaceRule
for (const sheet of Array.from(document.styleSheets)) {
try {
const basis = sheet.href ?? document.baseURI
const tekst = Array.from(sheet.cssRules)
.filter((regel) => !isFontFace(regel))
.map((regel) => maakAbsoluut(regel.cssText, basis))
.join('\n')
if (tekst) regels.push(tekst)
} catch {
alleenGelezen = false
}
}
if (regels.length > 0) {
const stijl = target.createElement('style')
stijl.setAttribute('data-herkomst', 'hoofdvenster')
stijl.textContent = regels.join('\n')
target.head.append(stijl)
}
if (alleenGelezen && regels.length > 0) return
// Terugval voor bladen die niet te lezen zijn, bijvoorbeeld van een ander domein.
document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel="stylesheet"], style').forEach((node) => {
const clone = node.cloneNode(true) as HTMLLinkElement | HTMLStyleElement
if (clone instanceof HTMLLinkElement && node instanceof HTMLLinkElement) clone.href = node.href
target.head.append(clone)
})
}
/**
* Neemt de al geladen lettertypegezichten van het hoofdvenster over.
*
* Een FontFace hangt niet aan een document, dus een gezicht dat hier al
* ingeladen is kan rechtstreeks aan de FontFaceSet van het nieuwe venster
* worden toegevoegd. Dat scheelt niet alleen een tweede download, het omzeilt
* ook dat het laden in een about:blank-document nooit voltooit.
*/
function copyLoadedFonts(target: Window): void {
const doel = target.document.fonts
if (!doel || typeof document.fonts === 'undefined') return
document.fonts.forEach((gezicht) => {
try {
doel.add(gezicht)
} catch {
// Al aanwezig, of dit gezicht laat zich niet overdragen.
}
})
}
function syncSecondaryTheme(target: Window): void {
const theme = document.body.dataset.theme
if (theme === 'light' || theme === 'dark') target.document.body.dataset.theme = theme
else delete target.document.body.dataset.theme
}
export function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement {
const targetDocument = target.document
targetDocument.documentElement.lang = document.documentElement.lang || 'nl'
targetDocument.head.replaceChildren()
const charset = targetDocument.createElement('meta')
charset.setAttribute('charset', 'utf-8')
const title = targetDocument.createElement('title')
title.textContent = 'GeoIntel · Analyseconsole'
const base = targetDocument.createElement('base')
base.href = document.baseURI
const viewport = targetDocument.createElement('meta')
viewport.name = 'viewport'
viewport.content = 'width=device-width, initial-scale=1'
targetDocument.head.append(charset, title, base, viewport)
copyDocumentStyles(targetDocument)
copyLoadedFonts(target)
const shell = targetDocument.createElement('div')
shell.className = 'secondary-display-shell'
const header = targetDocument.createElement('header')
header.className = 'secondary-display-header'
header.innerHTML = `
<div class="secondary-display-brand">
<img src="/geointel-icon.svg" alt="" aria-hidden="true" />
<span><small>LIVE GEKOPPELD</small><h1 tabindex="-1">Analyseconsole</h1></span>
</div>
<div class="secondary-display-status"><i aria-hidden="true"></i><span>Gesynchroniseerd met de kaart</span></div>
`
const closeButton = targetDocument.createElement('button')
closeButton.type = 'button'
closeButton.className = 'secondary-display-close'
closeButton.textContent = 'Sluit console'
closeButton.addEventListener('click', onClose)
header.append(closeButton)
const content = targetDocument.createElement('main')
content.className = 'secondary-display-content'
content.id = 'secondary-display-content'
content.setAttribute('aria-label', 'Live analyse en resultaten')
const contentHeading = targetDocument.createElement('h2')
contentHeading.className = 'sr-only'
contentHeading.textContent = 'Live analyse en resultaten'
content.append(contentHeading)
shell.append(header, content)
targetDocument.body.replaceChildren(shell)
targetDocument.body.className = 'secondary-display-body'
syncSecondaryTheme(target)
header.querySelector<HTMLElement>('h1')?.focus({ preventScroll: true })
return content
}
export function useSecondaryDisplay() {
const popupRef = useRef<Window | null>(null)
const monitorTimerRef = useRef<number | null>(null)
const returnFocusRef = useRef<HTMLElement | null>(null)
const [container, setContainer] = useState<HTMLElement | null>(null)
const [error, setError] = useState<string | null>(null)
const stopMonitoring = useCallback(() => {
if (monitorTimerRef.current !== null) {
window.clearInterval(monitorTimerRef.current)
monitorTimerRef.current = null
}
}, [])
const rememberGeometry = useCallback((popup: Window) => {
const geometry: SecondaryDisplayGeometry = {
left: popup.screenX,
top: popup.screenY,
width: popup.outerWidth,
height: popup.outerHeight,
}
try {
window.localStorage.setItem(SECONDARY_DISPLAY_GEOMETRY_KEY, JSON.stringify(geometry))
} catch {
// Window positioning is a convenience; storage restrictions must never
// break the live analysis console.
}
}, [])
const restoreFocus = useCallback(() => {
const target = returnFocusRef.current
returnFocusRef.current = null
if (target?.isConnected) target.focus({ preventScroll: true })
}, [])
const close = useCallback(() => {
const popup = popupRef.current
if (popup && !popup.closed) {
try {
rememberGeometry(popup)
} catch {
// A navigated popup can refuse geometry access; closing still wins.
}
try {
popup.close()
} catch {
// State is cleared below even when the browser has already detached it.
}
}
popupRef.current = null
setContainer(null)
stopMonitoring()
restoreFocus()
}, [rememberGeometry, restoreFocus, stopMonitoring])
const open = useCallback(() => {
const existing = popupRef.current
if (existing && !existing.closed) {
try {
const existingContent = existing.document.getElementById('secondary-display-content')
const content = existingContent?.nodeType === Node.ELEMENT_NODE
? existingContent as HTMLElement
: initialiseSecondaryDocument(existing, close)
syncSecondaryTheme(existing)
setContainer(content)
setError(null)
existing.focus()
} catch {
try {
existing.close()
} finally {
popupRef.current = null
setContainer(null)
stopMonitoring()
}
setError('Het tweede venster kon niet opnieuw worden gekoppeld. Sluit het venster en probeer opnieuw.')
}
return
}
let saved: SecondaryDisplayGeometry | null = null
try {
saved = parseSecondaryDisplayGeometry(window.localStorage.getItem(SECONDARY_DISPLAY_GEOMETRY_KEY))
} catch {
saved = null
}
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
const popup = window.open('', SECONDARY_DISPLAY_NAME, secondaryDisplayFeatures(saved ?? defaultGeometry()))
if (!popup) {
setError('De browser blokkeerde het tweede venster. Sta pop-ups voor GeoIntel toe en probeer opnieuw.')
return
}
setError(null)
popupRef.current = popup
const content = initialiseSecondaryDocument(popup, close)
setContainer(content)
popup.focus()
stopMonitoring()
monitorTimerRef.current = window.setInterval(() => {
const current = popupRef.current
if (!current || current.closed) {
popupRef.current = null
setContainer(null)
stopMonitoring()
restoreFocus()
return
}
rememberGeometry(current)
}, 1_000)
}, [close, rememberGeometry, restoreFocus, stopMonitoring])
useEffect(() => {
if (!container) return
const sync = () => {
const popup = popupRef.current
if (popup && !popup.closed) syncSecondaryTheme(popup)
}
sync()
const observer = new MutationObserver(sync)
observer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [container])
useEffect(() => close, [close])
return {
container,
error,
isOpen: container !== null,
open,
close,
}
}
interface SecondaryDisplayTargetProps {
container: HTMLElement | null
children: ReactNode
}
export function SecondaryDisplayTarget({ container, children }: SecondaryDisplayTargetProps) {
return container ? createPortal(children, container) : children
}
@@ -0,0 +1,91 @@
import {
Activity,
Bot,
Database,
Download,
Map,
ScanSearch,
Settings,
ShieldCheck,
type LucideIcon,
} from 'lucide-react'
import type { WorkspaceKey } from '../overview/OverviewWorkspace'
import { GeoIntelMark } from '../brand/GeoIntelBrand'
export interface WorkspaceNavigationItem {
key: WorkspaceKey
label: string
navigationLabel?: string
description: string
}
export interface WorkspaceNavigationGroup {
label: string
keys: WorkspaceKey[]
}
interface WorkbenchNavigationProps {
activeWorkspace: WorkspaceKey
groups: WorkspaceNavigationGroup[]
items: WorkspaceNavigationItem[]
onSelect: (workspace: WorkspaceKey) => void
}
const workspaceIcons: Record<WorkspaceKey, LucideIcon> = {
map: Map,
data: Database,
assistant: Bot,
analysis: ShieldCheck,
ai: ScanSearch,
exports: Download,
overview: Activity,
system: Settings,
}
export function WorkbenchNavigation({
activeWorkspace,
groups,
items,
onSelect,
}: WorkbenchNavigationProps): JSX.Element {
return (
<aside className="workbench-sidebar" aria-label="Navigatie van de werkruimte">
<div className="sidebar-brand" aria-label="GeoIntel">
<GeoIntelMark className="brand-mark" />
<span className="sidebar-brand-copy">
<strong>GeoIntel</strong>
</span>
</div>
<nav aria-label="Hoofdonderdelen">
{groups.map((group) => (
<div className="nav-group" key={group.label}>
<p className="nav-section-label">{group.label}</p>
{group.keys.map((key) => {
const item = items.find((candidate) => candidate.key === key)
if (!item) {
return null
}
const Icon = workspaceIcons[item.key]
return (
<button
key={item.key}
type="button"
className={item.key === activeWorkspace ? 'nav-item nav-item-active' : 'nav-item'}
onClick={() => onSelect(item.key)}
aria-current={item.key === activeWorkspace ? 'page' : undefined}
aria-label={`Open ${item.label}: ${item.description}`}
title={item.description}
data-testid={`workspace-nav-${item.key}`}
>
<Icon className="nav-item-icon" aria-hidden="true" strokeWidth={1.8} />
<span>{item.navigationLabel ?? item.label}</span>
</button>
)
})}
</div>
))}
</nav>
</aside>
)
}
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorkspaceErrorBoundary } from './WorkspaceErrorBoundary'
function Struikelt({ gooi }: { gooi: boolean }): JSX.Element {
if (gooi) {
throw new Error('checks is undefined')
}
return <p>werkblad staat</p>
}
describe('WorkspaceErrorBoundary', () => {
afterEach(() => cleanup())
it('houdt de schil overeind en biedt een nieuwe poging aan', () => {
// React logt de gevangen fout zelf ook; die ruis hoort niet in de uitvoer.
const stil = vi.spyOn(console, 'error').mockImplementation(() => {})
function Proef(): JSX.Element {
const [gooi, setGooi] = useState(true)
return (
<div>
<button type="button" onClick={() => setGooi(false)}>herstel de bron</button>
<WorkspaceErrorBoundary resetKey="ai" label="Beeldanalyse">
<Struikelt gooi={gooi} />
</WorkspaceErrorBoundary>
</div>
)
}
render(<Proef />)
// De fout is opgevangen: de melding staat er, de schil eromheen ook.
expect(screen.getByTestId('workspace-error')).toBeTruthy()
expect(screen.getByText('Beeldanalyse kon niet worden getoond')).toBeTruthy()
expect(screen.getByText('checks is undefined')).toBeTruthy()
expect(screen.getByRole('button', { name: /herstel de bron/i })).toBeTruthy()
// Na herstel van de oorzaak brengt "Opnieuw proberen" het werkblad terug.
fireEvent.click(screen.getByRole('button', { name: /herstel de bron/i }))
fireEvent.click(screen.getByRole('button', { name: /opnieuw proberen/i }))
expect(screen.getByText('werkblad staat')).toBeTruthy()
stil.mockRestore()
})
it('wist de fout zodra de gebruiker naar een ander werkblad gaat', () => {
const stil = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rerender } = render(
<WorkspaceErrorBoundary resetKey="ai" label="Beeldanalyse">
<Struikelt gooi />
</WorkspaceErrorBoundary>,
)
expect(screen.getByTestId('workspace-error')).toBeTruthy()
rerender(
<WorkspaceErrorBoundary resetKey="map" label="Kaart">
<Struikelt gooi={false} />
</WorkspaceErrorBoundary>,
)
expect(screen.getByText('werkblad staat')).toBeTruthy()
expect(screen.queryByTestId('workspace-error')).toBeNull()
stil.mockRestore()
})
})
@@ -0,0 +1,83 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { CircleAlert, RotateCcw } from 'lucide-react'
interface WorkspaceErrorBoundaryProps {
/** Verandert deze sleutel, dan probeert de grens het opnieuw. Zet hier de
actieve werkruimte in, zodat wegnavigeren de fout wist. */
resetKey: string
/** Naam van het werkblad, voor de melding. */
label: string
children: ReactNode
}
interface WorkspaceErrorBoundaryState {
error: Error | null
resetKey: string
}
/**
* Vangt een fout in één werkblad op.
*
* Zonder deze grens nam een enkele component die gooit de hele werkbank mee:
* React ontkoppelt dan de volledige boom en de operator houdt een leeg scherm
* over, inclusief de kaart en de navigatie. Dat is voor een operationeel
* gereedschap de verkeerde verhouding tussen oorzaak en gevolg.
*
* Nu blijft de schil staan. De gebruiker ziet welk werkblad het liet afweten,
* kan naar een ander werkblad, en kan dit werkblad opnieuw proberen.
*/
export class WorkspaceErrorBoundary extends Component<WorkspaceErrorBoundaryProps, WorkspaceErrorBoundaryState> {
constructor(props: WorkspaceErrorBoundaryProps) {
super(props)
this.state = { error: null, resetKey: props.resetKey }
}
static getDerivedStateFromError(error: Error): Partial<WorkspaceErrorBoundaryState> {
return { error }
}
static getDerivedStateFromProps(
props: WorkspaceErrorBoundaryProps,
state: WorkspaceErrorBoundaryState,
): Partial<WorkspaceErrorBoundaryState> | null {
if (props.resetKey !== state.resetKey) {
return { error: null, resetKey: props.resetKey }
}
return null
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// De stack blijft in de console beschikbaar voor wie meekijkt; er gaat
// niets naar buiten.
console.error(`Werkblad "${this.props.label}" is gestopt:`, error, info.componentStack)
}
private retry = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
const { error } = this.state
if (!error) {
return this.props.children
}
return (
<section className="workspace-error" role="alert" data-testid="workspace-error">
<CircleAlert aria-hidden="true" />
<div>
<strong>{this.props.label} kon niet worden getoond</strong>
<p>
De rest van de werkbank blijft bruikbaar. Ga naar een ander werkblad, of probeer dit werkblad
opnieuw te openen.
</p>
<p className="workspace-error-detail">{error.message}</p>
</div>
<button type="button" className="secondary-action" onClick={this.retry}>
<RotateCcw aria-hidden="true" />
<span>Opnieuw proberen</span>
</button>
</section>
)
}
}
@@ -0,0 +1,40 @@
import {
Bot,
Database,
Download,
Gauge,
MapPinned,
ScanLine,
Settings2,
ShieldCheck,
} from 'lucide-react'
import type { WorkspaceKey } from '../overview/OverviewWorkspace'
interface WorkspaceSignalProps {
workspace: WorkspaceKey
}
const workspaceIcons = {
overview: Gauge,
data: Database,
map: MapPinned,
assistant: Bot,
analysis: ShieldCheck,
ai: ScanLine,
exports: Download,
system: Settings2,
} as const
export function WorkspaceSignal({ workspace }: WorkspaceSignalProps): JSX.Element {
const Icon = workspaceIcons[workspace]
return (
<div className={`workspace-signal workspace-signal-${workspace}`} aria-hidden="true">
<span className="workspace-signal-grid" />
<span className="workspace-signal-orbit workspace-signal-orbit-a" />
<span className="workspace-signal-orbit workspace-signal-orbit-b" />
<span className="workspace-signal-beam" />
<span className="workspace-signal-icon"><Icon /></span>
</div>
)
}
@@ -0,0 +1,40 @@
export interface SecondaryDisplayGeometry {
left: number
top: number
width: number
height: number
}
function boundedInteger(value: unknown, minimum: number, maximum: number): number | null {
const parsed = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(parsed)) return null
return Math.min(maximum, Math.max(minimum, Math.round(parsed)))
}
export function parseSecondaryDisplayGeometry(value: string | null): SecondaryDisplayGeometry | null {
if (!value) return null
try {
const parsed = JSON.parse(value) as Partial<SecondaryDisplayGeometry>
const left = boundedInteger(parsed.left, -20_000, 20_000)
const top = boundedInteger(parsed.top, -20_000, 20_000)
const width = boundedInteger(parsed.width, 420, 3_840)
const height = boundedInteger(parsed.height, 520, 2_160)
return left === null || top === null || width === null || height === null
? null
: { left, top, width, height }
} catch {
return null
}
}
export function secondaryDisplayFeatures(geometry: SecondaryDisplayGeometry): string {
return [
'popup=yes',
`left=${geometry.left}`,
`top=${geometry.top}`,
`width=${geometry.width}`,
`height=${geometry.height}`,
'resizable=yes',
'scrollbars=yes',
].join(',')
}
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SourceFreshnessPanel } from './SourceFreshnessPanel'
import type { SourceFreshnessReport } from '../../types'
function props(overschrijf: Partial<Parameters<typeof SourceFreshnessPanel>[0]> = {}) {
return {
report: null,
loading: false,
error: null,
onRefresh: vi.fn(),
catalogReport: null,
catalogLoading: false,
catalogError: null,
onProbeCatalogs: vi.fn(),
grbRefreshPlan: null,
grbRefreshPlanLoading: false,
grbRefreshPlanError: null,
...overschrijf,
}
}
const leegRapport: SourceFreshnessReport = {
project_id: 'p-1',
generated_at: '2026-08-23T09:00:00Z',
summary: {
source_count: 0,
dataset_count: 0,
current_count: 0,
due_count: 0,
review_required_count: 0,
local_count: 0,
sources_with_integrity_issues: 0,
integrity_issue_count: 0,
},
items: [],
limitations: [],
}
describe('SourceFreshnessPanel', () => {
afterEach(() => cleanup())
it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => {
// Het type zegt dat items altijd meekomt, maar een type is een belofte van
// de compiler en geen garantie van het netwerk. Vandaar de optionele keten
// in de component; deze test legt vast dat die er blijft.
const zonderItems = { ...leegRapport, items: undefined } as unknown as SourceFreshnessReport
expect(() => render(<SourceFreshnessPanel {...props({ report: zonderItems })} />)).not.toThrow()
})
it('toont een leeg rapport zonder te struikelen', () => {
expect(() => render(<SourceFreshnessPanel {...props({ report: leegRapport })} />)).not.toThrow()
})
it('meldt een fout in plaats van hem te verzwijgen', () => {
render(<SourceFreshnessPanel {...props({ error: 'Kon de brondekking niet ophalen' })} />)
expect(screen.getByText(/kon de brondekking niet ophalen/i)).toBeTruthy()
})
})
@@ -0,0 +1,251 @@
import type {
GrbRefreshLayerPlan,
GrbRefreshLayerStatus,
GrbRefreshPlan,
SourceCatalogComparisonStatus,
SourceCatalogProbeItem,
SourceCatalogProbeReport,
SourceCatalogProbeStatus,
SourceFreshnessItem,
SourceFreshnessReport,
SourceFreshnessStatus,
} from '../../types'
interface SourceFreshnessPanelProps {
report: SourceFreshnessReport | null
loading: boolean
error: string | null
onRefresh: () => void
catalogReport: SourceCatalogProbeReport | null
catalogLoading: boolean
catalogError: string | null
onProbeCatalogs: (force: boolean) => void
grbRefreshPlan: GrbRefreshPlan | null
grbRefreshPlanLoading: boolean
grbRefreshPlanError: string | null
}
function statusLabel(status: SourceFreshnessStatus): string {
if (status === 'current') return 'actueel'
if (status === 'due') return 'controle nodig'
if (status === 'review_required') return 'nakijken'
return 'lokaal'
}
function catalogStatusLabel(status: SourceCatalogProbeStatus): string {
if (status === 'available') return 'bereikbaar'
if (status === 'degraded') return 'beperkt'
if (status === 'unavailable') return 'niet bereikbaar'
return 'uitgeschakeld'
}
function comparisonLabel(status: SourceCatalogComparisonStatus): string {
if (status === 'same') return 'zelfde editie'
if (status === 'different') return 'verschil controleren'
if (status === 'no_local_data') return 'nog niet lokaal ingeladen'
if (status === 'not_comparable') return 'andere versienotatie'
return 'niet vergelijkbaar'
}
function formatDate(value?: string | null): string {
if (!value) return 'geen datum'
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
}
function formatCount(value?: number | null): string {
return value == null ? 'nog niet bekend' : value.toLocaleString('nl-BE')
}
function grbLayerStatusLabel(status: GrbRefreshLayerStatus): string {
if (status === 'current') return 'actueel'
if (status === 'update_available') return 'update beschikbaar'
if (status === 'not_loaded') return 'nog niet ingeladen'
if (status === 'remote_unavailable') return 'bron niet bereikbaar'
return 'beoordeling nodig'
}
function GrbRefreshLayerRow({ item, remoteEdition }: { item: GrbRefreshLayerPlan; remoteEdition?: string | null }): JSX.Element {
return (
<div className={`grb-refresh-layer grb-refresh-layer-${item.status}`}>
<div>
<strong>{item.display_name}</strong>
<span>{grbLayerStatusLabel(item.status)}</span>
</div>
<p>{item.action_message}</p>
<div className="source-freshness-meta">
<span>Lokaal: {item.local_source_version ?? 'niet aanwezig'}</span>
<span>Officieel: {remoteEdition ?? 'onbekend'}</span>
<span>{formatCount(item.local_feature_count)} objecten lokaal</span>
</div>
</div>
)
}
function integrityIssueCount(item: SourceFreshnessItem): number {
return Object.values(item.integrity).reduce((total, value) => total + value, 0)
}
function CatalogRow({ item }: { item: SourceCatalogProbeItem }): JSX.Element {
const evidenceLabel = item.service_type === 'HTML' || item.service_type === 'DCAT' ? 'Publicaties' : 'Lagen'
return (
<div className={`source-catalog-row source-catalog-row-${item.status}`}>
<div className="source-freshness-main">
<div>
<strong>{item.display_name}</strong>
<span>{catalogStatusLabel(item.status)} / {comparisonLabel(item.comparison_status)}</span>
</div>
<span className="source-freshness-status">{item.remote_version ?? 'geen editie'}</span>
</div>
<p>{item.message}</p>
<div className="source-freshness-meta">
<span>Lokaal: {item.local_source_version ?? 'niet aanwezig'}</span>
<span>{evidenceLabel}: {item.matched_layers.length}/{item.expected_layers.length} bevestigd</span>
{item.remote_modified_at ? <span>Metadata: {formatDate(item.remote_modified_at)}</span> : null}
{item.cached ? <span>cache gebruikt</span> : null}
</div>
</div>
)
}
function SourceRow({ item }: { item: SourceFreshnessItem }): JSX.Element {
const issueCount = integrityIssueCount(item)
return (
<div className={`source-freshness-row source-freshness-row-${item.status}`}>
<div className="source-freshness-main">
<div>
<strong>{item.display_name}</strong>
<span>{item.dataset_count} datasets / {item.version_count} versies</span>
</div>
<span className="source-freshness-status">{statusLabel(item.status)}</span>
</div>
<p>{item.reason}</p>
<div className="source-freshness-meta">
<span>Laatste import: {formatDate(item.latest_imported_at)}</span>
{item.latest_source_version ? <span>Editie: {item.latest_source_version}</span> : null}
{item.historical_series ? <span>Historische reeks</span> : null}
{issueCount ? <span>{issueCount} integriteitsafwijking{issueCount === 1 ? '' : 'en'}</span> : null}
</div>
</div>
)
}
export function SourceFreshnessPanel({
report,
loading,
error,
onRefresh,
catalogReport,
catalogLoading,
catalogError,
onProbeCatalogs,
grbRefreshPlan,
grbRefreshPlanLoading,
grbRefreshPlanError,
}: SourceFreshnessPanelProps): JSX.Element {
// Eén normalisatie aan de kop in plaats van een guard per gebruiksplek; de
// vorige versie had er één op de filter en geen op de map.
const bronnen = report?.items ?? []
const attentionItems = bronnen.filter((item) => item.status === 'due' || item.status === 'review_required')
const summary = report?.summary
return (
<section className="source-freshness-panel" aria-label="Bronactualiteit en versie-integriteit">
<div className="source-freshness-header">
<div>
<p className="eyebrow">Bronbeheer</p>
<h2>Actualiteit en versiecontrole</h2>
<p>Controleert lokale publicaties en kan op aanvraag officiële basiskaart-, luchtfoto-, bevolkings- en landbouwedities uitlezen.</p>
</div>
<div className="source-freshness-actions">
<button className="secondary-action" type="button" onClick={onRefresh} disabled={loading}>
{loading ? 'Controleren...' : 'Lokale status vernieuwen'}
</button>
<button
className="primary-action"
type="button"
onClick={() => onProbeCatalogs(Boolean(catalogReport))}
disabled={catalogLoading}
>
{catalogLoading ? 'Officiële bronnen lezen...' : catalogReport ? 'Opnieuw bij bron controleren' : 'Officiële edities controleren'}
</button>
</div>
</div>
{error ? <p className="inline-error">{error}</p> : null}
{!report && !error ? <p className="empty-state">{loading ? 'Bronstatus wordt gecontroleerd...' : 'Geen projectbronstatus beschikbaar.'}</p> : null}
{report && summary ? (
<>
<div className="source-freshness-summary" aria-label="Samenvatting broncontrole">
<span><strong>{summary.current_count}</strong> actueel</span>
<span><strong>{summary.due_count}</strong> controle nodig</span>
<span><strong>{summary.review_required_count}</strong> nakijken</span>
<span><strong>{summary.integrity_issue_count}</strong> integriteitsfouten</span>
</div>
{attentionItems.length ? (
<div className="source-freshness-attention" aria-label="Bronnen die aandacht vragen">
{attentionItems.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
) : (
<p className="source-freshness-ok">Alle externe bronpublicaties hebben geldige lokale versie-evidentie.</p>
)}
<details className="source-freshness-details">
<summary>
<span>Alle {summary.source_count} bronnen bekijken</span>
<strong>gecontroleerd {formatDate(report.generated_at)}</strong>
</summary>
<div className="source-freshness-list">
{bronnen.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
<p className="source-freshness-limitation">{report.limitations.join(' ')}</p>
</details>
<div className="source-catalog-section" aria-live="polite">
<div className="source-catalog-heading">
<div>
<strong>Officiële catalogusedities</strong>
<span>Alleen na jouw expliciete controle; er wordt niets ingeladen of vervangen.</span>
</div>
{catalogReport ? (
<span>{catalogReport.summary.available_count}/{catalogReport.summary.provider_count} bereikbaar</span>
) : null}
</div>
{catalogError ? <p className="inline-error">{catalogError}</p> : null}
{!catalogReport && !catalogError ? (
<p className="source-catalog-empty">Vergelijk lokale GRB-, orthofoto-, Statbel- en landbouwversies met hun officiële publicatie-evidentie.</p>
) : null}
{catalogReport ? (
<>
<div className="source-catalog-list">
{catalogReport.items.map((item) => <CatalogRow item={item} key={item.source_name} />)}
</div>
<p className="source-freshness-limitation">
Gecontroleerd {formatDate(catalogReport.generated_at)}. Een verschil vraagt menselijke beoordeling en start nooit automatisch een import.
</p>
</>
) : null}
{grbRefreshPlanLoading ? <p className="source-catalog-empty">Veilig vernieuwingsplan wordt opgebouwd...</p> : null}
{grbRefreshPlanError ? <p className="inline-error">{grbRefreshPlanError}</p> : null}
{grbRefreshPlan ? (
<div className="grb-refresh-plan">
<div className="grb-refresh-plan-heading">
<div>
<strong>Veilige GRB-vernieuwing</strong>
<span>{grbRefreshPlan.message}</span>
</div>
<span>{grbRefreshPlan.summary.update_available_count} updates</span>
</div>
<div className="grb-refresh-layer-list">
{grbRefreshPlan.layers.map((item) => (
<GrbRefreshLayerRow item={item} remoteEdition={grbRefreshPlan.remote_edition_date} key={item.theme} />
))}
</div>
<p className="source-freshness-limitation">
Een serveroperator controleert eerst exacte aantallen en checksums. Bestaande snapshots blijven altijd behouden.
</p>
</div>
) : null}
</div>
</>
) : null}
</section>
)
}
+61
View File
@@ -0,0 +1,61 @@
import type { DatasetCreateResponse, ProjectRead } from '../types'
export const PRIMARY_FOCUS_LABEL = 'Mol'
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
export const NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'
export const NATIONAL_WORKSPACE_LABEL = 'België en Belgische Noordzee'
export const NATIONAL_WORKSPACE_REGION = 'België en Belgische Noordzee'
export const NATIONAL_MAP_CENTER: [number, number] = [4.62, 50.72]
export const NATIONAL_MAP_ZOOM = 7.25
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
export const FLANDERS_WORKSPACE_PROJECT_NAME = 'Flanders Regional Workbench'
export const FLANDERS_WORKSPACE_LABEL = 'Vlaanderen (285 gemeenten)'
export const PRIMARY_FOCUS_CENTER: [number, number] = [5.1167, 51.1919]
export const PRIMARY_FOCUS_AREA_NAME = 'Mol primary AOI'
export const PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'
export const PRIMARY_FOCUS_AREA_GEOJSON =
'{"type":"MultiPolygon","coordinates":[[[[5.109476746222376,51.18745107550715],[5.123924622861509,51.18745107550715],[5.123924622861509,51.19634848029239],[5.109476746222376,51.19634848029239],[5.109476746222376,51.18745107550715]]]]}'
const PRIMARY_FOCUS_TOKEN = /(^|[^a-z0-9])mol([^a-z0-9]|$)/i
function containsPrimaryFocus(values: Array<string | null | undefined>): boolean {
return values.some((value) => Boolean(value && PRIMARY_FOCUS_TOKEN.test(value)))
}
export function isPrimaryFocusProject(project: ProjectRead): boolean {
return containsPrimaryFocus([project.name, project.description, project.region])
}
export function isPrimaryFocusMunicipalityProject(project: ProjectRead): boolean {
return project.name === PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME
}
export function isPrimaryFocusMunicipalityBoundaryDataset(dataset: DatasetCreateResponse): boolean {
return (
dataset.status === 'ready' &&
(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson') &&
dataset.source_name === 'vrbg' &&
dataset.source_metadata?.coverage_scope === 'municipality' &&
dataset.source_metadata?.nis_code === '13025'
)
}
export function isPrimaryFocusProjectData(
project: ProjectRead,
datasets: DatasetCreateResponse[],
): boolean {
if (isPrimaryFocusProject(project)) {
return true
}
return datasets.some((dataset) =>
containsPrimaryFocus([
dataset.name,
dataset.original_filename,
dataset.stored_filename,
dataset.storage_path,
dataset.source_name,
dataset.reference_layer_name,
]),
)
}
+4
View File
@@ -0,0 +1,4 @@
export const VECTOR_VIEWPORT_FEATURE_THRESHOLD = 5_000
export const VECTOR_VIEWPORT_MIN_ZOOM = 14
export const VECTOR_VIEWPORT_FEATURE_LIMIT = 1_000
export const VECTOR_VIEWPORT_DEBOUNCE_MS = 250
View File
Whitespace-only changes.
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { analysisApi } from '../services/api'
import type { ChangeDetectionSummary, DatasetCreateResponse, VectorSelectionBBox } from '../types'
import { formatError } from '../lib/formatError'
interface ChangeDetectionWorkflowOptions {
selectedProjectId: string | null
availableVectorDatasets: DatasetCreateResponse[]
loadDatasetJobs: (projectId: string, datasetId: string) => Promise<void>
/**
* Reads the active map selection when the comparison is started. Without a
* selection the comparison covers both datasets entire, which is rarely the
* question being asked and returns a payload no map can draw.
*
* A getter rather than a value because this hook runs before the selection
* state is declared in the component; it is only ever called from a user
* event, long after that binding is initialised.
*/
getSelection?: () => { bbox: VectorSelectionBBox | null; areaId: string | null }
}
export function useChangeDetectionWorkflow({
selectedProjectId,
availableVectorDatasets,
loadDatasetJobs,
getSelection,
}: ChangeDetectionWorkflowOptions) {
const [changeSourceDatasetId, setChangeSourceDatasetId] = useState('')
const [changeTargetDatasetId, setChangeTargetDatasetId] = useState('')
const [changeIouThreshold, setChangeIouThreshold] = useState(0.8)
const [changeIncludeUnchanged, setChangeIncludeUnchanged] = useState(true)
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | null>(null)
const [changeDetectionError, setChangeDetectionError] = useState<string | null>(null)
const runChangeDetection = async () => {
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
const targetDatasetId =
changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id
if (!sourceDatasetId || !targetDatasetId) {
setChangeDetectionError('Kies twee vectorbronnen')
return
}
if (sourceDatasetId === targetDatasetId) {
setChangeDetectionError('Source and target datasets must differ')
return
}
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
setChangeDetectionError('De IoU-drempel moet tussen 0 en 1 liggen.')
return
}
setChangeDetectionError(null)
setChangeDetectionResult(null)
setRunningChangeDetection(true)
const selection = getSelection?.() ?? { bbox: null, areaId: null }
try {
const job = await analysisApi.runChangeDetection({
source_dataset_id: sourceDatasetId,
target_dataset_id: targetDatasetId,
iou_threshold: changeIouThreshold,
include_unchanged: changeIncludeUnchanged,
...(selection.bbox ? { bbox: selection.bbox } : {}),
...(selection.areaId ? { area_id: selection.areaId } : {}),
})
if (job.status !== 'success') {
throw new Error(job.error_message || 'De wijzigingsanalyse is mislukt.')
}
if (!job.result_json) {
throw new Error('De wijzigingsanalyse is afgerond zonder resultaat.')
}
setChangeSourceDatasetId(sourceDatasetId)
setChangeTargetDatasetId(targetDatasetId)
setChangeDetectionResult(job.result_json)
if (selectedProjectId) {
await loadDatasetJobs(selectedProjectId, sourceDatasetId)
}
} catch (error) {
setChangeDetectionError(formatError(error, 'Change detection failed'))
} finally {
setRunningChangeDetection(false)
}
}
return {
changeSourceDatasetId,
changeTargetDatasetId,
changeIouThreshold,
changeIncludeUnchanged,
runningChangeDetection,
changeDetectionResult,
changeDetectionError,
runChangeDetection,
setChangeSourceDatasetId,
setChangeTargetDatasetId,
setChangeIouThreshold,
setChangeIncludeUnchanged,
}
}
@@ -0,0 +1,145 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
const mocks = vi.hoisted(() => ({
resolveCoverage: vi.fn(),
}))
vi.mock('../services/api', () => ({
externalApi: {
resolveCoverage: mocks.resolveCoverage,
},
}))
import { useCoverageResolver } from './useCoverageResolver'
const bbox: VectorSelectionBBox = {
min_x: 4.98,
min_y: 51.15,
max_x: 5.02,
max_y: 51.19,
crs: 'EPSG:4326',
}
const coverageResult = {
intersected_zones: ['flanders'],
outside_supported_scope: false,
items: [],
warnings: [],
} as unknown as CoverageResolveResponse
describe('useCoverageResolver', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.resolveCoverage.mockResolvedValue(coverageResult)
})
afterEach(() => {
vi.useRealTimers()
})
it('does not query until both project and bbox exist', async () => {
const { result, rerender } = renderHook(
({ projectId, selection }) => useCoverageResolver({ projectId, bbox: selection }),
{ initialProps: { projectId: 'project-1' as string | null, selection: null as VectorSelectionBBox | null } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(mocks.resolveCoverage).not.toHaveBeenCalled()
expect(result.current.loadingCoverage).toBe(false)
rerender({ projectId: 'project-1', selection: bbox })
await act(async () => {
await vi.advanceTimersByTimeAsync(249)
})
expect(mocks.resolveCoverage).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(result.current.coverage).toEqual(coverageResult)
expect(mocks.resolveCoverage).toHaveBeenCalledWith({
projectId: 'project-1',
bbox: { minx: 4.98, miny: 51.15, maxx: 5.02, maxy: 51.19 },
})
})
it('clears stale coverage when the selection is removed', async () => {
const { result, rerender } = renderHook(
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
{ initialProps: { selection: bbox as VectorSelectionBBox | null } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverage).toEqual(coverageResult)
rerender({ selection: null })
expect(result.current.coverage).toBeNull()
expect(result.current.coverageError).toBeNull()
expect(result.current.loadingCoverage).toBe(false)
expect(result.current.coverageDurationMs).toBeNull()
})
it('clears stale coverage as soon as a different selection starts resolving', async () => {
const nextBbox = { ...bbox, min_x: 5.1, max_x: 5.2 }
const { result, rerender } = renderHook(
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
{ initialProps: { selection: bbox } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverage).toEqual(coverageResult)
rerender({ selection: nextBbox })
expect(result.current.coverage).toBeNull()
expect(result.current.loadingCoverage).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(249)
})
expect(mocks.resolveCoverage).toHaveBeenCalledTimes(1)
})
it('exposes provider failures without retaining stale results', async () => {
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverageError).toBe('provider unavailable')
expect(result.current.coverage).toBeNull()
expect(result.current.loadingCoverage).toBe(false)
})
it('records duration and exposes a slow coverage response', async () => {
let resolveRequest: ((value: CoverageResolveResponse) => void) | null = null
mocks.resolveCoverage.mockImplementationOnce(
() => new Promise<CoverageResolveResponse>((resolve) => {
resolveRequest = resolve
}),
)
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.loadingCoverage).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(4_001)
resolveRequest?.(coverageResult)
await Promise.resolve()
})
expect(result.current.coverageDurationMs).toBe(4_001)
expect(result.current.coverageBudgetExceeded).toBe(true)
})
})
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { externalApi } from '../services/api'
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
import {
COVERAGE_RESPONSE_BUDGET_MS,
exceedsPerformanceBudget,
} from '../lib/performanceBudget'
interface CoverageResolverOptions {
projectId: string | null
bbox: VectorSelectionBBox | null
}
export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions) {
const [coverage, setCoverage] = useState<CoverageResolveResponse | null>(null)
const [loadingCoverage, setLoadingCoverage] = useState(false)
const [coverageError, setCoverageError] = useState<string | null>(null)
const [coverageDurationMs, setCoverageDurationMs] = useState<number | null>(null)
useEffect(() => {
if (!projectId || !bbox) {
setCoverage(null)
setCoverageError(null)
setLoadingCoverage(false)
setCoverageDurationMs(null)
return
}
let cancelled = false
// A new AOI must never temporarily display the previous AOI's coverage.
// Clear immediately; the debounce only postpones the network request.
setCoverage(null)
setCoverageError(null)
setLoadingCoverage(true)
setCoverageDurationMs(null)
const timer = window.setTimeout(() => {
const startedAt = Date.now()
externalApi.resolveCoverage({
projectId,
bbox: {
minx: bbox.min_x,
miny: bbox.min_y,
maxx: bbox.max_x,
maxy: bbox.max_y,
},
})
.then((result) => {
if (!cancelled) {
setCoverage(result)
}
})
.catch((error) => {
if (!cancelled) {
setCoverage(null)
setCoverageError(error instanceof Error ? error.message : 'Dekking kon niet worden bepaald')
}
})
.finally(() => {
if (!cancelled) {
setCoverageDurationMs(Date.now() - startedAt)
setLoadingCoverage(false)
}
})
}, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [projectId, bbox?.min_x, bbox?.min_y, bbox?.max_x, bbox?.max_y])
return {
coverage,
loadingCoverage,
coverageError,
coverageDurationMs,
coverageBudgetExceeded: exceedsPerformanceBudget(
coverageDurationMs,
COVERAGE_RESPONSE_BUDGET_MS,
),
}
}
+615
View File
@@ -0,0 +1,615 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { datasetsApi, jobsApi } from '../services/api'
import type {
AreaRead,
DatasetCreateResponse,
JobRead,
RasterTileHandoff,
RasterMetadataResponse,
RasterPreviewResponse,
RasterStatsResponse,
VectorSummary,
} from '../types'
import { formatError } from '../lib/formatError'
import { isPrimaryFocusMunicipalityBoundaryDataset } from '../config/primaryFocus'
import { VECTOR_VIEWPORT_FEATURE_THRESHOLD } from '../config/vectorDelivery'
interface DatasetWorkflowOptions {
selectedProjectId: string | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
setErrorMessage: (message: string | null) => void
isVectorDatasetType: (datasetType: string) => boolean
}
function toRasterMetadata(metadata: Record<string, unknown> | null | undefined): RasterMetadataResponse | null {
if (!metadata) {
return null
}
return metadata as unknown as RasterMetadataResponse
}
function isOperationalScopeBoundaryDataset(dataset: DatasetCreateResponse): boolean {
return (
dataset.status === 'ready' &&
(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson') &&
dataset.source_metadata?.layer_type === 'regional_boundary'
)
}
function extractRasterTileManifestPath(job: JobRead | null | undefined): string {
const manifest = toRasterTileHandoff(job)
return manifest?.manifest_path ?? ''
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function optionalNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function toRasterTileHandoff(job: JobRead | null | undefined): RasterTileHandoff | null {
const result = job?.result_json
if (!result) {
return null
}
const manifestPath = result.manifest_path
if (typeof manifestPath !== 'string' || manifestPath.trim().length === 0) {
return null
}
const manifest = typeof result.manifest === 'object' && result.manifest !== null
? result.manifest as Record<string, unknown>
: {}
return {
manifest_path: manifestPath,
tile_set_id: optionalString(result.tile_set_id) ?? optionalString(manifest.tile_set_id),
tile_size: optionalNumber(result.tile_size) ?? optionalNumber(manifest.tile_size),
overlap: optionalNumber(result.overlap) ?? optionalNumber(manifest.overlap),
count: optionalNumber(result.count) ?? optionalNumber(manifest.count),
source_dataset_id: optionalString(result.source_dataset_id) ?? optionalString(manifest.source_dataset_id),
created_at: optionalString(manifest.created_at),
}
}
export function useDatasetWorkflow({
selectedProjectId,
areas,
datasets,
loadProjectData,
setErrorMessage,
isVectorDatasetType,
}: DatasetWorkflowOptions) {
// De detailgegevens van een bron werden bij een fout stilzwijgend
// overgeslagen; het paneel bleef dan leeg zonder dat iemand wist waarom.
const meldDetailFout = (fout: unknown) => {
setErrorMessage(fout instanceof Error ? fout.message : 'De brondetails konden niet worden geladen')
return null
}
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null)
const [selectedDataset, setSelectedDataset] = useState<DatasetCreateResponse | null>(null)
const [selectedDatasetSummary, setSelectedDatasetSummary] = useState<VectorSummary | null>(null)
const [selectedRasterMetadata, setSelectedRasterMetadata] = useState<RasterMetadataResponse | null>(null)
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
const [jobs, setJobs] = useState<JobRead[]>([])
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
const [latestRasterTileManifestPath, setLatestRasterTileManifestPath] = useState('')
const [latestRasterTileManifest, setLatestRasterTileManifest] = useState<RasterTileHandoff | null>(null)
const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('')
const [selectedClipAreaId, setSelectedClipAreaId] = useState('')
const [rasterTileSize, setRasterTileSize] = useState(512)
const [rasterTileOverlap, setRasterTileOverlap] = useState(64)
const [rasterTileOutputName, setRasterTileOutputName] = useState('')
const [rasterReprojectCrs, setRasterReprojectCrs] = useState('EPSG:31370')
const [rasterReprojectResampling, setRasterReprojectResampling] = useState('nearest')
const [ndviNirBand, setNdviNirBand] = useState(4)
const [ndviRedBand, setNdviRedBand] = useState(3)
const [ndwiGreenBand, setNdwiGreenBand] = useState(2)
const [ndwiNirBand, setNdwiNirBand] = useState(4)
const [ndbiSwirBand, setNdbiSwirBand] = useState(5)
const [ndbiNirBand, setNdbiNirBand] = useState(4)
const [loadingDatasetDetails, setLoadingDatasetDetails] = useState(false)
const [datasetDetailError, setDatasetDetailError] = useState<string | null>(null)
const datasetDetailRequestSequence = useRef(0)
const [datasetForm, setDatasetForm] = useState({
datasetType: 'vector',
source: 'user_upload',
datasetRole: 'source',
sourceName: '',
referenceLayerName: '',
sourceMetadataJson: '',
provenanceMetadataJson: '',
areaId: '',
file: null as File | null,
})
const isRasterTileInputValid = useMemo(
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
[rasterTileSize, rasterTileOverlap],
)
useEffect(() => {
if (areas.length === 0) {
setSelectedClipAreaId('')
} else if (!selectedClipAreaId || !areas.some((area) => area.id === selectedClipAreaId)) {
setSelectedClipAreaId(areas[0].id)
}
}, [areas, selectedClipAreaId])
useEffect(() => {
if (!selectedProjectId || selectedDatasetId || datasets.length === 0) {
return
}
// Auto-open the first usable dataset so Data, Map and Exports start with real context.
const defaultDataset =
datasets.find(isOperationalScopeBoundaryDataset) ??
datasets.find(isPrimaryFocusMunicipalityBoundaryDataset) ??
datasets.find((dataset) => isVectorDatasetType(dataset.dataset_type) && dataset.status === 'ready') ??
datasets.find((dataset) => dataset.status === 'ready') ??
datasets[0]
if (defaultDataset) {
loadDatasetDetails(selectedProjectId, defaultDataset).catch(meldDetailFout)
}
}, [datasets, isVectorDatasetType, selectedDatasetId, selectedProjectId])
const loadDatasetJobs = async (projectId: string, datasetId: string, detailRequestId?: number) => {
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
if (detailRequestId != null && detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setJobs(response.items)
const latestRasterTileJob = response.items.find(
(job) => job.job_type === 'raster.tile' && extractRasterTileManifestPath(job),
)
const manifest = toRasterTileHandoff(latestRasterTileJob)
const manifestPath = manifest ? manifest.manifest_path : ''
setLatestRasterTileManifest(manifest)
setLatestRasterTileManifestPath(manifestPath)
}
const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => {
const detailRequestId = ++datasetDetailRequestSequence.current
setLoadingDatasetDetails(true)
setDatasetDetailError(null)
setSelectedDataset(dataset)
setSelectedDatasetSummary(null)
setSelectedRasterMetadata(null)
setSelectedRasterStats(null)
setDatasetContent(null)
setRasterPreview(null)
setLatestRasterTileManifestPath('')
setLatestRasterTileManifest(null)
setSelectedDatasetId(dataset.id)
setJobs([])
try {
if (isVectorDatasetType(dataset.dataset_type)) {
const summary = await datasetsApi.vectorSummary(projectId, dataset.id)
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setSelectedDatasetSummary(summary)
const featureCount = summary.feature_count ?? dataset.feature_count
if (featureCount == null || featureCount <= VECTOR_VIEWPORT_FEATURE_THRESHOLD) {
const content = await datasetsApi.getContent(projectId, dataset.id)
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setDatasetContent(content)
}
} else if (dataset.dataset_type === 'raster') {
try {
const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id)
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setSelectedRasterMetadata(toRasterMetadata(rasterInspection.metadata))
} catch (error) {
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setSelectedRasterMetadata(null)
setDatasetDetailError(formatError(error, 'Raster metadata unavailable'))
}
}
await loadDatasetJobs(projectId, dataset.id, detailRequestId)
} catch (error) {
if (detailRequestId === datasetDetailRequestSequence.current) {
setDatasetDetailError(formatError(error, 'De datasetdetails konden niet worden geladen.'))
}
} finally {
if (detailRequestId === datasetDetailRequestSequence.current) {
setLoadingDatasetDetails(false)
}
}
}
const uploadDataset = async (event: FormEvent) => {
event.preventDefault()
if (!selectedProjectId || !datasetForm.file) {
setErrorMessage('Kies een werkruimte en voeg een bestand toe')
return
}
if (!datasetForm.datasetRole) {
setErrorMessage('Kies de rol van deze bron')
return
}
if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') {
setErrorMessage('Een referentierol vraagt om een vectorbestand')
return
}
if (datasetForm.sourceMetadataJson) {
try {
const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson)
if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') {
setErrorMessage('Bronmetadata moet een JSON-object zijn.')
return
}
} catch {
setErrorMessage('Bronmetadata moet geldige JSON zijn.')
return
}
}
if (datasetForm.provenanceMetadataJson) {
try {
const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson)
if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') {
setErrorMessage('Provenancemetadata moet een JSON-object zijn.')
return
}
} catch {
setErrorMessage('Provenancemetadata moet geldige JSON zijn.')
return
}
}
try {
await datasetsApi.upload(selectedProjectId, {
file: datasetForm.file,
datasetType: datasetForm.datasetType,
source: datasetForm.source,
datasetRole: datasetForm.datasetRole,
sourceName: datasetForm.sourceName || undefined,
referenceLayerName: datasetForm.referenceLayerName || undefined,
sourceMetadataJson: datasetForm.sourceMetadataJson || undefined,
provenanceMetadataJson: datasetForm.provenanceMetadataJson || undefined,
areaId: datasetForm.areaId || undefined,
})
setDatasetForm((previous) => ({ ...previous, file: null }))
await loadProjectData(selectedProjectId)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De bron kon niet worden ingeladen')
}
}
const refreshSelectedDatasetAfterJob = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
if (refreshed) {
loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout)
}
}
const runVectorClip = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
if (!selectedClipAreaId) {
setDatasetDetailError('Kies eerst een gebied om op bij te snijden')
return
}
setDatasetDetailError(null)
try {
await datasetsApi.vectorClip(selectedProjectId, selectedDatasetId, {
area_id: selectedClipAreaId,
output_name: `${selectedDataset?.name.replace(/\.geojson$/, '')}-clipped`,
})
await refreshSelectedDatasetAfterJob()
} catch (error) {
setDatasetDetailError(formatError(error, 'Vector clip failed'))
}
}
const runVectorBuffer = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
setDatasetDetailError(null)
try {
await datasetsApi.vectorBuffer(selectedProjectId, selectedDatasetId, {
distance_m: 25,
dissolve: false,
})
await refreshSelectedDatasetAfterJob()
} catch (error) {
setDatasetDetailError(formatError(error, 'Vector buffer failed'))
}
}
const runVectorIntersect = async (availableVectorTargets: DatasetCreateResponse[]) => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id
if (!targetId) {
setDatasetDetailError('Kies eerst een bron om mee te doorsnijden')
return
}
setDatasetDetailError(null)
try {
await datasetsApi.vectorIntersect(selectedProjectId, selectedDatasetId, {
other_dataset_id: targetId,
})
await refreshSelectedDatasetAfterJob()
} catch (error) {
setDatasetDetailError(formatError(error, 'Vector intersect failed'))
}
}
const runRasterInspect = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
const response = await datasetsApi.inspectRaster(selectedProjectId, selectedDatasetId)
setSelectedRasterMetadata(toRasterMetadata(response.metadata))
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster inspect unavailable'))
}
}
const runRasterPreview = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
const response = await datasetsApi.rasterPreview(selectedProjectId, selectedDatasetId)
setRasterPreview(response)
if (response.metadata) {
setSelectedRasterMetadata(toRasterMetadata(response.metadata))
}
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster preview unavailable'))
setRasterPreview(null)
}
}
const runRasterStats = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
const response = await datasetsApi.rasterStats(selectedProjectId, selectedDatasetId)
setSelectedRasterStats(response)
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster stats unavailable'))
setSelectedRasterStats(null)
}
}
const runRasterReproject = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
const targetCrs = rasterReprojectCrs.trim()
if (!targetCrs) {
setDatasetDetailError('Voor rasterherprojectie is een doel-CRS vereist.')
return
}
try {
await datasetsApi.rasterReproject(selectedProjectId, selectedDatasetId, {
target_crs: targetCrs,
resampling: rasterReprojectResampling,
})
await refreshSelectedDatasetAfterJob()
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster reproject failed'))
}
}
const runRasterClip = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
if (!selectedClipAreaId) {
setDatasetDetailError('Kies eerst een gebied om het raster op bij te snijden')
return
}
try {
await datasetsApi.rasterClip(selectedProjectId, selectedDatasetId, {
area_id: selectedClipAreaId,
output_name: `raster-clipped-${selectedDatasetId}`,
})
await refreshSelectedDatasetAfterJob()
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster clip failed'))
}
}
const runRasterTile = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
const job = await datasetsApi.rasterTile(selectedProjectId, selectedDatasetId, {
tile_size: rasterTileSize,
overlap: rasterTileOverlap,
output_name: rasterTileOutputName || undefined,
})
const manifest = toRasterTileHandoff(job)
const manifestPath = manifest?.manifest_path ?? ''
setLatestRasterTileManifest(manifest)
setLatestRasterTileManifestPath(manifestPath)
await refreshSelectedDatasetAfterJob()
if (manifestPath) {
setLatestRasterTileManifest(manifest)
setLatestRasterTileManifestPath(manifestPath)
}
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster tile failed'))
}
}
const runRasterNdvi = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
await datasetsApi.rasterNdvi(selectedProjectId, selectedDatasetId, {
nir_band: ndviNirBand,
red_band: ndviRedBand,
})
await refreshSelectedDatasetAfterJob()
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster NDVI failed'))
}
}
const runRasterNdwi = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
await datasetsApi.rasterNdwi(selectedProjectId, selectedDatasetId, {
green_band: ndwiGreenBand,
nir_band: ndwiNirBand,
})
await refreshSelectedDatasetAfterJob()
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster NDWI failed'))
}
}
const runRasterNdbi = async () => {
if (!selectedProjectId || !selectedDatasetId) {
return
}
try {
await datasetsApi.rasterNdbi(selectedProjectId, selectedDatasetId, {
swir_band: ndbiSwirBand,
nir_band: ndbiNirBand,
})
await refreshSelectedDatasetAfterJob()
setDatasetDetailError(null)
} catch (error) {
setDatasetDetailError(formatError(error, 'Raster NDBI failed'))
}
}
const pickDerivedDataset = async (datasetId: string) => {
if (!selectedProjectId) {
return
}
const target = datasets.find((item) => item.id === datasetId)
if (target) {
await loadDatasetDetails(selectedProjectId, target)
}
}
const refreshMetadata = async (datasetId: string) => {
if (!selectedProjectId) {
return
}
const refreshed = await datasetsApi.refreshMetadata(selectedProjectId, datasetId)
await loadProjectData(selectedProjectId)
if (selectedDataset?.id === datasetId) {
setSelectedDataset(refreshed)
if (isVectorDatasetType(refreshed.dataset_type)) {
loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout)
}
}
}
const resetDatasetForProject = () => {
datasetDetailRequestSequence.current += 1
setSelectedDatasetId(null)
setSelectedDataset(null)
setSelectedDatasetSummary(null)
setSelectedRasterMetadata(null)
setSelectedRasterStats(null)
setDatasetContent(null)
setRasterPreview(null)
setLatestRasterTileManifestPath('')
setLatestRasterTileManifest(null)
setJobs([])
setLoadingDatasetDetails(false)
setDatasetDetailError(null)
}
return {
selectedDatasetId,
selectedDataset,
selectedDatasetSummary,
selectedRasterMetadata,
selectedRasterStats,
datasetContent,
jobs,
rasterPreview,
latestRasterTileManifestPath,
latestRasterTileManifest,
selectedIntersectTargetId,
selectedClipAreaId,
rasterTileSize,
rasterTileOverlap,
rasterTileOutputName,
rasterReprojectCrs,
rasterReprojectResampling,
ndviNirBand,
ndviRedBand,
ndwiGreenBand,
ndwiNirBand,
ndbiSwirBand,
ndbiNirBand,
loadingDatasetDetails,
datasetDetailError,
datasetForm,
isRasterTileInputValid,
loadDatasetJobs,
loadDatasetDetails,
uploadDataset,
runVectorClip,
runVectorBuffer,
runVectorIntersect,
runRasterInspect,
runRasterPreview,
runRasterStats,
runRasterReproject,
runRasterClip,
runRasterTile,
runRasterNdvi,
runRasterNdwi,
runRasterNdbi,
pickDerivedDataset,
refreshMetadata,
resetDatasetForProject,
setSelectedDatasetId,
setSelectedDataset,
setSelectedIntersectTargetId,
setSelectedClipAreaId,
setRasterTileSize,
setRasterTileOverlap,
setRasterTileOutputName,
setRasterReprojectCrs,
setRasterReprojectResampling,
setNdviNirBand,
setNdviRedBand,
setNdwiGreenBand,
setNdwiNirBand,
setNdbiSwirBand,
setNdbiNirBand,
setDatasetForm,
}
}
@@ -0,0 +1,51 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useDeferredBackground } from './useDeferredBackground'
function Blok(): JSX.Element {
const ref = useDeferredBackground<HTMLDivElement>()
return <div data-testid="blok" ref={ref} />
}
describe('useDeferredBackground', () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
it('zet de achtergrond pas wanneer het blok in beeld komt', () => {
type Terugroep = (invoeren: { isIntersecting: boolean }[]) => void
const waarnemers: { melden: Terugroep }[] = []
const disconnect = vi.fn()
vi.stubGlobal('IntersectionObserver', class {
melden: Terugroep
constructor(terugroep: Terugroep) {
this.melden = terugroep
waarnemers.push(this)
}
observe() {}
disconnect = disconnect
})
const meldIntersectie = (invoeren: { isIntersecting: boolean }[]) => {
for (const w of waarnemers) w.melden(invoeren)
}
const { getByTestId } = render(<Blok />)
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBeNull()
meldIntersectie([{ isIntersecting: false }])
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBeNull()
meldIntersectie([{ isIntersecting: true }])
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBe('geladen')
// Eenmaal geladen hoeft er niet verder gekeken te worden.
expect(disconnect).toHaveBeenCalled()
})
it('zet de achtergrond meteen wanneer de browser niet kan waarnemen', () => {
vi.stubGlobal('IntersectionObserver', undefined)
const { getByTestId } = render(<Blok />)
// Liever een afbeelding te vroeg dan een leeg vlak dat nooit vult.
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBe('geladen')
})
})
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'
/**
* Stelt een achtergrondafbeelding uit tot het element in beeld komt.
*
* Achtergronden in CSS kennen geen loading="lazy": zodra de regel matcht, gaat
* het verzoek eruit. Op de landingspagina betekende dat drie afbeeldingen van
* samen 413 kB bij het openen, terwijl twee ervan pas ver onder de vouw staan.
*
* Het element krijgt hier pas het attribuut data-achtergrond="geladen" wanneer
* het in de buurt van het scherm komt; de CSS hangt de url aan dat attribuut.
* Zonder IntersectionObserver — of met beperkte beweging — wordt de afbeelding
* meteen gezet, zodat er nooit een leeg vlak achterblijft.
*/
export function useDeferredBackground<T extends HTMLElement>(): React.RefObject<T> {
const ref = useRef<T>(null)
useEffect(() => {
const element = ref.current
if (!element) return
const toon = () => element.setAttribute('data-achtergrond', 'geladen')
if (typeof IntersectionObserver === 'undefined') {
toon()
return
}
const waarnemer = new IntersectionObserver(
(invoeren) => {
if (invoeren.some((invoer) => invoer.isIntersecting)) {
toon()
waarnemer.disconnect()
}
},
// Ruim voor de vouw beginnen, zodat de afbeelding er staat voordat de
// gebruiker hem bereikt.
{ rootMargin: '600px' },
)
waarnemer.observe(element)
return () => waarnemer.disconnect()
}, [])
return ref
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react'
import { demoApi } from '../services/api'
import type { DatasetCreateResponse, QualityCheckRead } from '../types'
import { formatError } from '../lib/formatError'
interface DemoWorkflowOptions {
restrictedMode?: boolean
loadProjects: (preferredProjectId?: string | null) => Promise<void>
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
loadDetectionRuns: (projectId?: string | null) => Promise<void>
loadSegmentationRuns: (projectId?: string | null) => Promise<void>
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
loadExports: (projectId?: string | null) => Promise<void>
setSelectedProjectId: (projectId: string) => void
setSelectedDatasetId: (datasetId: string | null) => void
setSelectedMapAreaId: (areaId: string) => void
setQaCandidateDatasetId: (datasetId: string) => void
setQaReferenceDatasetId: (datasetId: string) => void
setQaAreaId: (areaId: string) => void
setSelectedDetectionDatasetId: (datasetId: string) => void
setDetectionReferenceDatasetId: (datasetId: string) => void
setSelectedSegmentationDatasetId: (datasetId: string) => void
setSegmentationReferenceDatasetId: (datasetId: string) => void
setErrorMessage: (message: string | null) => void
}
export function useDemoWorkflow({
restrictedMode = false,
loadProjects,
loadProjectData,
loadDatasetDetails,
loadDetectionRuns,
loadSegmentationRuns,
loadQualityChecks,
loadExports,
setSelectedProjectId,
setSelectedDatasetId,
setSelectedMapAreaId,
setQaCandidateDatasetId,
setQaReferenceDatasetId,
setQaAreaId,
setSelectedDetectionDatasetId,
setDetectionReferenceDatasetId,
setSelectedSegmentationDatasetId,
setSegmentationReferenceDatasetId,
setErrorMessage,
}: DemoWorkflowOptions) {
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
const loadDemoWorkflow = async (): Promise<boolean> => {
setLoadingDemoWorkflow(true)
setDemoWorkflowMessage(null)
setErrorMessage(null)
try {
const result = await demoApi.seedWorkflow()
setSelectedProjectId(result.project_id)
setSelectedDatasetId(result.candidate_dataset_id)
setSelectedMapAreaId(result.area_id)
setQaCandidateDatasetId(result.candidate_dataset_id)
setQaReferenceDatasetId(result.reference_dataset_id)
setQaAreaId(result.area_id)
setSelectedDetectionDatasetId(result.raster_dataset_id ?? '')
setDetectionReferenceDatasetId(result.reference_dataset_id)
setSelectedSegmentationDatasetId(result.raster_dataset_id ?? '')
setSegmentationReferenceDatasetId(result.reference_dataset_id)
setDemoWorkflowMessage(result.message)
await loadProjects(result.project_id)
const analysisLoads = Promise.all([
loadDetectionRuns(result.project_id),
loadSegmentationRuns(result.project_id),
loadExports(result.project_id),
]).then(() => undefined)
const [projectData] = await Promise.all([
loadProjectData(result.project_id),
loadQualityChecks(result.project_id),
analysisLoads,
])
const candidateDataset = projectData?.datasets?.find((dataset) => dataset.id === result.candidate_dataset_id)
const rasterDataset = projectData?.datasets?.find((dataset) => dataset.id === result.raster_dataset_id)
if (candidateDataset) {
await loadDatasetDetails(result.project_id, candidateDataset)
} else if (rasterDataset) {
await loadDatasetDetails(result.project_id, rasterDataset)
}
return true
} catch (error) {
setErrorMessage(formatError(error, 'De demowerkruimte kon niet worden geladen.'))
return false
} finally {
setLoadingDemoWorkflow(false)
}
}
return {
loadingDemoWorkflow,
demoWorkflowMessage,
loadDemoWorkflow,
}
}
@@ -0,0 +1,301 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DetectionRunRead, JobRead, YoloPreflightResponse } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
listModelAssets: vi.fn(),
getYoloPreflight: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
listDetections: vi.fn(),
getRunGeoJson: vi.fn(),
getRun: vi.fn(),
compareWithReference: vi.fn(),
rasterInspect: vi.fn(),
rasterTile: vi.fn(),
upload: vi.fn(),
}))
vi.mock('../services/api', () => ({
detectionApi: {
listModels: mocks.listModels,
listModelAssets: mocks.listModelAssets,
getYoloPreflight: mocks.getYoloPreflight,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
listDetections: mocks.listDetections,
getRunGeoJson: mocks.getRunGeoJson,
getRun: mocks.getRun,
compareWithReference: mocks.compareWithReference,
},
datasetsApi: {
rasterInspect: mocks.rasterInspect,
rasterTile: mocks.rasterTile,
upload: mocks.upload,
},
}))
import { useDetectionWorkflow } from './useDetectionWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'detection.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { detection_count: 1 },
}
const persistedRun: DetectionRunRead = {
id: analysisRunId,
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'detection',
status: 'success',
model_name: 'yolo-configured',
parameters_json: {},
result_json: { detection_count: 1 },
}
function preflight(acceleratorReady: boolean): YoloPreflightResponse {
return {
model_id: 'yolo-configured',
status: acceleratorReady ? 'ready' : 'accelerator_unavailable',
message: acceleratorReady ? 'Gereed' : 'NVIDIA CUDA is niet beschikbaar',
checks: {
enabled: true,
dependencies_available: true,
accelerator_ready: acceleratorReady,
model_path_set: true,
model_file_exists: true,
model_load_requested: false,
manifest_path_set: true,
manifest_valid: true,
tile_paths_exist: true,
tile_limit_ok: true,
},
runtime: { dependencies_assumed: false, cuda_available: acceleratorReady },
tile_count: 1,
max_tiles: 256,
will_download_models: false,
will_run_inference: acceleratorReady,
}
}
function renderWorkflow() {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useDetectionWorkflow({
selectedProjectId: projectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useDetectionWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.listDetections.mockResolvedValue({ items: [], total: 1, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-configured',
display_name: 'YOLO',
framework: 'ultralytics/pytorch',
task_type: 'object_detection',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.listModelAssets.mockResolvedValue({ items: [], total: 0, model_directory: '/models' })
})
it('queues, follows and loads a persisted result without a synchronous inference fallback', async () => {
mocks.runAsync.mockResolvedValue(completedJob)
const { result, loadProjectData } = renderWorkflow()
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.runDetection()
})
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(result.current.detectionJob?.status).toBe('success')
expect(result.current.detectionRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
detection_count: 1,
status: 'success',
})
expect(result.current.detectionWorkflowStage).toBe('complete')
expect(result.current.detectionRunError).toBeNull()
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('blocks the queue when preflight says the NVIDIA accelerator is unavailable', async () => {
mocks.getYoloPreflight.mockResolvedValue(preflight(false))
const { result } = renderWorkflow()
await act(async () => {
await result.current.loadDetectionModels()
})
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.prepareAndRunDetection()
})
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.detectionWorkflowStage).toBe('failed')
expect(result.current.detectionRunError).toContain('NVIDIA CUDA')
})
it('does not let a late run list from another project overwrite the active project', async () => {
let resolveOlder!: (value: { items: DetectionRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: DetectionRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.detectionRuns).toEqual([projectTwoRun])
expect(result.current.selectedDetectionRunId).toBe('run-2')
})
it('does not let late detection results from another project overwrite the active project', async () => {
type DetectionList = { items: Array<{ id: string }>; total: number; truncated: boolean }
type DetectionGeoJson = { type: 'FeatureCollection'; features: Array<{ id: string }> }
let resolveOlderList!: (value: DetectionList) => void
let resolveNewerList!: (value: DetectionList) => void
let resolveOlderGeoJson!: (value: DetectionGeoJson) => void
let resolveNewerGeoJson!: (value: DetectionGeoJson) => void
mocks.listDetections
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeoJson = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeoJson = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionResults('run-2') })
await act(async () => {
resolveNewerList({ items: [{ id: 'result-2' }], total: 1, truncated: false })
resolveNewerGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-2' }] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [{ id: 'result-1' }], total: 1, truncated: false })
resolveOlderGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-1' }] })
await olderRequest
})
expect(result.current.detectionItems).toEqual([{ id: 'result-2' }])
expect(result.current.detectionGeoJson).toEqual({
type: 'FeatureCollection',
features: [{ id: 'feature-2' }],
})
expect(result.current.loadingDetectionResults).toBe(false)
})
it('drops a late queue response when the user has already changed project', async () => {
let resolveQueuedJob!: (value: JobRead) => void
mocks.runAsync.mockReturnValue(new Promise((resolve) => { resolveQueuedJob = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
let request!: Promise<void>
act(() => { request = result.current.runDetection() })
rerender({ selectedProjectId: 'project-2' })
await act(async () => {
resolveQueuedJob(completedJob)
await request
})
expect(result.current.detectionJob).toBeNull()
expect(result.current.detectionRunResult).toBeNull()
expect(result.current.runningDetection).toBe(false)
expect(mocks.getRun).not.toHaveBeenCalled()
})
})
+859
View File
@@ -0,0 +1,859 @@
import { useEffect, useRef, useState } from 'react'
import { datasetsApi, detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
DetectionModelCapability,
DetectionQaResult,
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
JobRead,
ModelAssetRead,
QualityCheckRead,
YoloPreflightResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import { rasterTileCount } from '../lib/rasterTiling'
import {
analysisRunIdFromJob,
completedDetectionResponse,
DetectionJobError,
waitForDetectionJob,
} from '../services/detectionJob'
interface DetectionWorkflowOptions {
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
qaIouThreshold: number
loadProjectData: (projectId: string) => Promise<unknown>
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
}
interface DetectionOperatorProfileSelection {
modelAssetId: string
confidenceThreshold: number
}
export type DetectionWorkflowStage =
| 'idle'
| 'uploading'
| 'ready'
| 'tiling'
| 'validating'
| 'detecting'
| 'loading'
| 'complete'
| 'failed'
export interface DetectionCalibrationRunRow {
threshold: number
status: 'queued' | 'running' | 'success' | 'failed'
analysis_run_id?: string | null
job_id?: string | null
quality_check_id?: string | null
detection_count?: number | null
precision?: number | null
recall?: number | null
f1_score?: number | null
false_positives?: number | null
false_negatives?: number | null
/** The F1-optimal cut among the requested thresholds. */
best_f1?: boolean
message?: string | null
}
function parseCalibrationThresholds(value: string): number[] {
const tokens = value
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter(Boolean)
const thresholds: number[] = []
for (const token of tokens) {
const threshold = Number(token)
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
return []
}
if (!thresholds.includes(threshold)) {
thresholds.push(threshold)
}
}
return thresholds
}
function tileManifestPathFromJob(job: JobRead): string | null {
const manifestPath = job.result_json?.manifest_path
return typeof manifestPath === 'string' && manifestPath.trim().length > 0 ? manifestPath.trim() : null
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de detectietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useDetectionWorkflow({
selectedProjectId,
rasterDatasets,
qaIouThreshold,
loadProjectData,
loadQualityChecks,
}: DetectionWorkflowOptions) {
const [detectionModels, setDetectionModels] = useState<DetectionModelCapability[]>([])
const [modelAssets, setModelAssets] = useState<ModelAssetRead[]>([])
const [loadingDetectionModels, setLoadingDetectionModels] = useState(false)
const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
const [modelAssetError, setModelAssetError] = useState<string | null>(null)
const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-configured')
const [selectedModelAssetId, setSelectedModelAssetId] = useState('')
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false)
const [detectionJob, setDetectionJob] = useState<JobRead | null>(null)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
const [selectedDetectionRunId, setSelectedDetectionRunId] = useState('')
const [detectionItems, setDetectionItems] = useState<DetectionRead[]>([])
// The server returns one page; a regional run holds far more than it draws.
const [detectionTotal, setDetectionTotal] = useState(0)
const [detectionTruncated, setDetectionTruncated] = useState(false)
const [detectionGeoJson, setDetectionGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
const [detectionClassFilter, setDetectionClassFilter] = useState('')
const [detectionMinConfidenceFilter, setDetectionMinConfidenceFilter] = useState(0)
const [loadingDetectionResults, setLoadingDetectionResults] = useState(false)
const [detectionReferenceDatasetId, setDetectionReferenceDatasetId] = useState('')
const [detectionQaResult, setDetectionQaResult] = useState<DetectionQaResult | null>(null)
const [detectionQaError, setDetectionQaError] = useState<string | null>(null)
const [runningDetectionQa, setRunningDetectionQa] = useState(false)
const [yoloPreflight, setYoloPreflight] = useState<YoloPreflightResponse | null>(null)
const [loadingYoloPreflight, setLoadingYoloPreflight] = useState(false)
const [yoloPreflightError, setYoloPreflightError] = useState<string | null>(null)
const [calibrationThresholdText, setCalibrationThresholdText] = useState('0.50 0.25 0.15')
const [runningDetectionCalibration, setRunningDetectionCalibration] = useState(false)
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
const activeDetectionControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const detectionExecutionSequence = useRef(0)
const detectionRunsRequestSequence = useRef(0)
const detectionResultsRequestSequence = useRef(0)
const detectionQaRequestSequence = useRef(0)
const detectionCalibrationSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
detectionExecutionSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
setDetectionJob(null)
setRunningDetection(false)
setDetectionRunResult(null)
setDetectionRunError(null)
setDetectionWorkflowStage('idle')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
return () => {
activeDetectionControllerRef.current?.abort()
}
}, [selectedProjectId])
const loadDetectionModels = async () => {
setLoadingDetectionModels(true)
setDetectionModelError(null)
setModelAssetError(null)
try {
const response = await detectionApi.listModels()
setDetectionModels(response.models)
const selectedModelStillAvailable = response.models.some((model) => model.model_id === selectedDetectionModelId)
if (!selectedModelStillAvailable && response.models.length > 0) {
const configuredModel = response.models.find(
(model) => model.configured && model.model_id !== 'manual-fixture-detector',
)
setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id)
}
} catch (error) {
setDetectionModelError(formatError(error, 'De detectiemodellen konden niet worden geladen'))
}
try {
const assetResponse = await detectionApi.listModelAssets()
setModelAssets(assetResponse.items)
const activeAsset = assetResponse.items.find((asset) => asset.active) ?? null
const selectedAssetStillAvailable = assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)
const nextAssetId = selectedAssetStillAvailable ? selectedModelAssetId : activeAsset?.model_asset_id ?? ''
setSelectedModelAssetId(nextAssetId)
try {
const preflight = await detectionApi.getYoloPreflight({ model_asset_id: nextAssetId || null })
setYoloPreflight(preflight)
setYoloPreflightError(null)
} catch (error) {
setYoloPreflightError(formatError(error, 'De modelcontrole kon niet worden opgehaald'))
}
} catch (error) {
setModelAssets([])
setModelAssetError(formatError(error, 'De lokale modelbestanden konden niet worden geladen'))
} finally {
setLoadingDetectionModels(false)
}
}
const loadYoloPreflight = async (tileManifestPath = detectionTileManifestPath) => {
setLoadingYoloPreflight(true)
setYoloPreflightError(null)
try {
const response = await detectionApi.getYoloPreflight({
tile_manifest_path: tileManifestPath.trim() || null,
model_asset_id: selectedModelAssetId || null,
})
setYoloPreflight(response)
} catch (error) {
setYoloPreflightError(formatError(error, 'De modelcontrole kon niet worden opgehaald'))
} finally {
setLoadingYoloPreflight(false)
}
}
const loadDetectionRuns = async (projectId = selectedProjectId) => {
const sequence = detectionRunsRequestSequence.current + 1
detectionRunsRequestSequence.current = sequence
if (!projectId) {
setDetectionRuns([])
return
}
try {
const response = await detectionApi.listRuns({ project_id: projectId })
if (
detectionRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setDetectionRuns(response.items)
setSelectedDetectionRunId((current) => current || response.items[0]?.id || '')
} catch (error) {
if (
detectionRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
}
}
}
const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => {
const sequence = detectionResultsRequestSequence.current + 1
detectionResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null)
return
}
setLoadingDetectionResults(true)
setDetectionRunError(null)
try {
const params = {
project_id: requestProjectId,
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
}
const [detectionsResponse, geoJsonResponse] = await Promise.all([
detectionApi.listDetections(analysisRunId, params),
detectionApi.getRunGeoJson(analysisRunId, params),
])
if (
detectionResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
setDetectionItems(detectionsResponse.items)
setDetectionTotal(detectionsResponse.total)
setDetectionTruncated(Boolean(detectionsResponse.truncated))
setDetectionGeoJson(geoJsonResponse)
} catch (error) {
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
}
} finally {
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingDetectionResults(false)
}
}
}
const executeDetection = async (
projectId: string,
datasetId: string,
manifestPath: string | null,
modelId = selectedDetectionModelId,
modelAssetId = selectedModelAssetId,
confidenceThreshold = detectionConfidenceThreshold,
parametersJson: Record<string, unknown> = {},
) => {
if (
(activeDetectionControllerRef.current && !activeDetectionControllerRef.current.signal.aborted)
|| detectionJob?.status === 'queued'
|| detectionJob?.status === 'running'
) {
throw new DetectionJobError(
'Er wordt al een GPU-detectietaak gevolgd. Wacht tot die taak klaar is voordat u een nieuwe start.',
'DETECTION_JOB_ALREADY_ACTIVE',
detectionJob?.id ?? 'unknown',
)
}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: modelId,
model_asset_id: modelAssetId || null,
confidence_threshold: confidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: parametersJson,
}
const controller = new AbortController()
const executionSequence = detectionExecutionSequence.current + 1
detectionExecutionSequence.current = executionSequence
activeDetectionControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| detectionExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
try {
setDetectionJob(null)
const queuedJob = await detectionApi.runAsync(request)
assertExecutionCurrent()
setDetectionJob(queuedJob)
const completedJob = await waitForDetectionJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
detectionExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionJob(job)
}
},
})
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromJob(completedJob)
const run = explicitAnalysisRunId
? await detectionApi.getRun(explicitAnalysisRunId, projectId)
: (await detectionApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new DetectionJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde detectierun ontbreekt.',
'DETECTION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedDetectionResponse(request, completedJob, run)
setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId)
assertExecutionCurrent()
await loadDetectionResults(result.analysis_run_id)
assertExecutionCurrent()
await loadProjectData(projectId)
assertExecutionCurrent()
return result
} finally {
if (activeDetectionControllerRef.current === controller) {
activeDetectionControllerRef.current = null
}
}
}
const runDetection = async () => {
if (!selectedProjectId) {
setDetectionRunError('Kies eerst een werkruimte')
return
}
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId
if (!datasetId) {
setDetectionRunError('Kies eerst een rasterbron')
return
}
setDetectionRunError(null)
setDetectionRunResult(null)
setRunningDetection(true)
setDetectionWorkflowStage('detecting')
try {
await executeDetection(projectId, datasetId, detectionTileManifestPath.trim() || null)
if (selectedProjectIdRef.current === projectId) {
setDetectionWorkflowStage('complete')
}
} catch (error) {
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed')
}
} finally {
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false)
}
}
}
const uploadDetectionRaster = async (file: File): Promise<boolean> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false
}
const projectId = selectedProjectId
setDetectionRunError(null)
setDetectionWorkflowStage('uploading')
try {
const dataset = await datasetsApi.upload(projectId, {
file,
datasetType: 'raster',
source: 'user_upload',
datasetRole: 'source',
sourceName: 'manual',
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
})
if (selectedProjectIdRef.current !== projectId) throw abortedError()
setSelectedDetectionDatasetId(dataset.id)
setDetectionTileManifestPath('')
setDetectionRunResult(null)
setDetectionWorkflowStage('ready')
await loadProjectData(projectId)
if (selectedProjectIdRef.current !== projectId) throw abortedError()
return true
} catch (error) {
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed')
}
return false
}
}
const prepareAndRunDetection = async (
datasetIdOverride?: string,
modelIdOverride?: string,
): Promise<DetectionRunResponse | null> => {
if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen')
return null
}
const projectId = selectedProjectId
const assertProjectCurrent = () => {
if (selectedProjectIdRef.current !== projectId) throw abortedError()
}
const datasetId = datasetIdOverride || selectedDetectionDatasetId
if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
return null
}
const effectiveModelId = modelIdOverride || selectedDetectionModelId
const effectiveModelAssetId = effectiveModelId === 'yolo-configured'
? modelAssets.find((asset) => asset.active)?.model_asset_id ?? selectedModelAssetId
: selectedModelAssetId
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
setDetectionRunError('Het gekozen productie-analysemodel is niet beschikbaar; vernieuw de modelstatus en controleer de serverconfiguratie')
return null
}
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
setDetectionRunError('Kies eerst een lokaal modelbestand')
return null
}
setDetectionRunError(null)
setDetectionRunResult(null)
setRunningDetection(true)
try {
let manifestPath = detectionTileManifestPath.trim()
if (!manifestPath) {
setDetectionWorkflowStage('tiling')
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertProjectCurrent()
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles
if (expectedTileCount === null) {
throw new Error('De afmetingen van het luchtbeeld konden niet veilig worden bepaald')
}
if (!Number.isInteger(maxTiles) || (maxTiles ?? 0) <= 0) {
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
}
if (expectedTileCount > maxTiles!) {
throw new Error(
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
tile_size: 512,
overlap: 64,
})
assertProjectCurrent()
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
}
setDetectionTileManifestPath(manifestPath)
}
setDetectionWorkflowStage('validating')
const preflight = await detectionApi.getYoloPreflight({
tile_manifest_path: manifestPath,
model_asset_id: effectiveModelAssetId || null,
})
assertProjectCurrent()
setYoloPreflight(preflight)
setYoloPreflightError(null)
if (
!preflight.checks.manifest_valid ||
!preflight.checks.tile_paths_exist ||
!preflight.checks.tile_limit_ok ||
!preflight.checks.dependencies_available ||
preflight.checks.accelerator_ready !== true ||
!preflight.checks.model_file_exists
) {
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
}
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
projectId,
datasetId,
manifestPath,
effectiveModelId,
effectiveModelAssetId,
)
assertProjectCurrent()
setDetectionWorkflowStage('complete')
return result
} catch (error) {
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed')
}
return null
} finally {
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false)
}
}
}
const compareDetectionRunWithReference = async (
analysisRunId: string,
referenceDatasetId: string,
useCurrentFilters = true,
iouThresholdOverride?: number,
): Promise<DetectionQaResult | null> => {
if (!analysisRunId) {
setDetectionQaError('Kies eerst een detectierun')
return null
}
if (!referenceDatasetId) {
setDetectionQaError('Kies eerst een referentiebron')
return null
}
const projectId = selectedProjectIdRef.current
if (!projectId) {
setDetectionQaError('Kies eerst een werkruimte')
return null
}
const sequence = detectionQaRequestSequence.current + 1
detectionQaRequestSequence.current = sequence
setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null)
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
})
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
setDetectionQaResult(result)
await loadQualityChecks(projectId)
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
return result
} catch (error) {
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionQaError(formatError(error, 'Detection QA failed'))
}
return null
} finally {
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionQa(false)
}
}
}
const runDetectionQa = async (): Promise<DetectionQaResult | null> =>
compareDetectionRunWithReference(selectedDetectionRunId, detectionReferenceDatasetId)
const runDetectionCalibration = async () => {
if (!selectedProjectId) {
setDetectionCalibrationError('Kies eerst een werkruimte om te kalibreren')
return
}
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId
if (!datasetId) {
setDetectionCalibrationError('Kies eerst een rasterbron om te kalibreren')
return
}
if (!detectionReferenceDatasetId) {
setDetectionCalibrationError('Kies eerst een referentiebron om te kalibreren')
return
}
const referenceDatasetId = detectionReferenceDatasetId
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
setDetectionCalibrationError('Kies eerst een geconfigureerd detectiemodel; testgegevens kunnen niet gekalibreerd worden')
return
}
if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) {
setDetectionCalibrationError('Kalibratie met YOLO vereist een beeldtegelmanifest')
return
}
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
setDetectionCalibrationError('Kies eerst een lokaal modelbestand om te kalibreren')
return
}
const thresholds = parseCalibrationThresholds(calibrationThresholdText)
if (thresholds.length === 0) {
setDetectionCalibrationError('Geef minstens één geldige drempel tussen 0 en 1 op')
return
}
const sequence = detectionCalibrationSequence.current + 1
detectionCalibrationSequence.current = sequence
const assertCalibrationCurrent = () => {
if (
detectionCalibrationSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) throw abortedError()
}
setDetectionCalibrationError(null)
setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' })))
setRunningDetectionCalibration(true)
try {
// One inference pass answers every threshold. Detections above a higher
// cut are a subset of a lower-cut run, and suppression walks candidates
// in descending confidence, so the kept set above a cut does not depend
// on the threshold the run used. Running the model per threshold spent N
// GPU passes to reproduce identical numbers.
const lowestThreshold = Math.min(...thresholds)
setDetectionCalibrationRows((rows) =>
rows.map((row) => ({ ...row, status: 'running', message: 'Eén inferentie voor alle drempels' })),
)
setDetectionWorkflowStage('detecting')
const result = await executeDetection(
projectId,
datasetId,
detectionTileManifestPath.trim() || null,
selectedDetectionModelId,
selectedModelAssetId,
lowestThreshold,
{ calibration: true, calibration_thresholds: thresholds },
)
assertCalibrationCurrent()
setDetectionWorkflowStage('complete')
setSelectedDetectionRunId(result.analysis_run_id)
const qa = await detectionApi.compareWithReference(result.analysis_run_id, projectId, {
reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
min_confidence: null,
calibration_thresholds: thresholds,
})
assertCalibrationCurrent()
const sweep = new Map((qa.calibration_sweep ?? []).map((point) => [point.min_confidence, point]))
setDetectionCalibrationRows((rows) =>
rows.map((row) => {
const point = sweep.get(row.threshold)
if (!point) {
return { ...row, status: 'failed', message: 'Geen meetpunt voor deze drempel' }
}
return {
...row,
status: 'success',
analysis_run_id: result.analysis_run_id,
job_id: result.job_id,
quality_check_id: qa.quality_check_id,
detection_count: point.candidate_count,
precision: point.precision,
recall: point.recall,
f1_score: point.f1_score,
false_positives: point.false_positives,
false_negatives: point.false_negatives,
best_f1: point.best_f1_in_sweep,
message: point.best_f1_in_sweep ? 'Beste F1 in deze reeks' : null,
}
}),
)
await loadDetectionRuns(projectId)
assertCalibrationCurrent()
await loadQualityChecks(projectId)
assertCalibrationCurrent()
await loadProjectData(projectId)
assertCalibrationCurrent()
} catch (error) {
if (
!isAbortError(error)
&& detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
const message = formatError(error, 'Kalibratie mislukt')
setDetectionCalibrationRows((rows) =>
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
)
setDetectionCalibrationError(message)
}
} finally {
if (
detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionCalibration(false)
}
}
}
const applyDetectionOperatorProfile = (profile: DetectionOperatorProfileSelection) => {
setSelectedDetectionModelId('yolo-configured')
setSelectedModelAssetId(profile.modelAssetId)
setDetectionConfidenceThreshold(profile.confidenceThreshold)
}
const resetDetectionForProject = () => {
detectionExecutionSequence.current += 1
detectionRunsRequestSequence.current += 1
detectionResultsRequestSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
setSelectedDetectionDatasetId('')
setDetectionRuns([])
setSelectedDetectionRunId('')
setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null)
setDetectionRunResult(null)
setDetectionJob(null)
setDetectionReferenceDatasetId('')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
setDetectionRunError(null)
setLoadingDetectionResults(false)
setRunningDetection(false)
setDetectionWorkflowStage('idle')
}
return {
detectionModels,
modelAssets,
loadingDetectionModels,
detectionModelError,
modelAssetError,
selectedDetectionDatasetId,
selectedDetectionModelId,
selectedModelAssetId,
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionJob,
detectionRunResult,
detectionRunError,
detectionRuns,
selectedDetectionRunId,
detectionItems,
detectionTotal,
detectionTruncated,
detectionGeoJson,
detectionClassFilter,
detectionMinConfidenceFilter,
loadingDetectionResults,
detectionReferenceDatasetId,
detectionQaResult,
detectionQaError,
runningDetectionQa,
yoloPreflight,
loadingYoloPreflight,
yoloPreflightError,
calibrationThresholdText,
runningDetectionCalibration,
detectionCalibrationRows,
detectionCalibrationError,
detectionWorkflowStage,
loadDetectionModels,
loadYoloPreflight,
loadDetectionRuns,
loadDetectionResults,
runDetection,
uploadDetectionRaster,
prepareAndRunDetection,
compareDetectionRunWithReference,
runDetectionQa,
runDetectionCalibration,
applyDetectionOperatorProfile,
resetDetectionForProject,
setSelectedDetectionDatasetId,
setSelectedDetectionModelId,
setSelectedModelAssetId,
setDetectionTileManifestPath,
setDetectionConfidenceThreshold,
setSelectedDetectionRunId,
setDetectionClassFilter,
setDetectionMinConfidenceFilter,
setDetectionReferenceDatasetId,
setCalibrationThresholdText,
}
}
+246
View File
@@ -0,0 +1,246 @@
import { useState } from 'react'
import { exportsApi } from '../services/api'
import type {
DatasetCreateResponse,
ExportCreateResponse,
ExportRead,
MapResultExportRequest,
VectorSelectionBBox,
} from '../types'
import { formatError } from '../lib/formatError'
interface ExportWorkflowOptions {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
selectedDetectionRunId: string
selectedSegmentationRunId: string
isVectorDatasetType: (datasetType: string) => boolean
}
export function useExportWorkflow({
selectedProjectId,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
isVectorDatasetType,
}: ExportWorkflowOptions) {
const [exports, setExports] = useState<ExportRead[]>([])
const [latestExport, setLatestExport] = useState<ExportCreateResponse | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [loadingExports, setLoadingExports] = useState(false)
const [exporting, setExporting] = useState(false)
const [selectionExporting, setSelectionExporting] = useState(false)
const [selectionExportError, setSelectionExportError] = useState<string | null>(null)
const [latestSelectionExport, setLatestSelectionExport] = useState<ExportCreateResponse | null>(null)
const [exportPreview, setExportPreview] = useState<Record<string, unknown> | null>(null)
const loadExports = async (projectId = selectedProjectId) => {
if (!projectId) {
setExports([])
return
}
setLoadingExports(true)
setExportError(null)
try {
const response = await exportsApi.listProjectExports(projectId)
setExports(response.items)
} catch (error) {
setExportError(formatError(error, 'De downloads konden niet worden geladen'))
} finally {
setLoadingExports(false)
}
}
const exportSelectedDatasetGeoJson = async () => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setExportError('Kies eerst een vectorbron om als GeoJSON te bewaren.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson(selectedDataset.project_id, {
dataset_id: selectedDataset.id,
export_kind: 'dataset',
name: selectedDataset.name.replace(/\.(geo)?json$/i, ''),
})
setLatestExport(response)
await loadExports(selectedDataset.project_id)
} catch (error) {
setExportError(formatError(error, 'De gekozen bron kon niet worden geëxporteerd'))
} finally {
setExporting(false)
}
}
const exportSelectedDetectionRunGeoJson = async () => {
if (!selectedDetectionRunId || !selectedProjectId) {
setExportError('Kies eerst een detectierun om als GeoJSON te bewaren.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson(selectedProjectId, {
analysis_run_id: selectedDetectionRunId,
export_kind: 'detection_run',
intended_use: 'review',
})
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'De detectierun kon niet worden geëxporteerd'))
} finally {
setExporting(false)
}
}
const exportSelectedSegmentationRunGeoJson = async () => {
if (!selectedSegmentationRunId || !selectedProjectId) {
setExportError('Kies eerst een segmentatierun om als GeoJSON te bewaren.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson(selectedProjectId, {
analysis_run_id: selectedSegmentationRunId,
export_kind: 'segmentation_run',
})
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'De segmentatierun kon niet worden geëxporteerd'))
} finally {
setExporting(false)
}
}
const exportMapSelectionGeoJson = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setSelectionExportError('Kies eerst een vectorbron om het gebied mee te bewaren.')
return null
}
setSelectionExporting(true)
setSelectionExportError(null)
try {
const response = await exportsApi.exportGeojson(selectedDataset.project_id, {
dataset_id: selectedDataset.id,
area_id: areaId,
export_kind: 'vector_selection',
bbox: { ...bbox, crs: 'EPSG:4326' },
limit: 250,
name: `${selectedDataset.name.replace(/\.(geo)?json$/i, '')}-selection`,
})
setLatestExport(response)
setLatestSelectionExport(response)
await loadExports(selectedDataset.project_id)
return response
} catch (error) {
setSelectionExportError(formatError(error, 'De gebiedsdownload kon niet worden bewaard'))
return null
} finally {
setSelectionExporting(false)
}
}
const exportProjectMetadata = async () => {
if (!selectedProjectId) {
setExportError('Kies eerst een werkruimte om de projectgegevens te bewaren.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportProjectMetadata(selectedProjectId)
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'De projectgegevens konden niet worden geëxporteerd'))
} finally {
setExporting(false)
}
}
const exportProjectReport = async () => {
if (!selectedProjectId) {
setExportError('Kies eerst een werkruimte om een rapport te maken.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportProjectReport(selectedProjectId)
setLatestExport(response)
await loadExports(selectedProjectId)
} catch (error) {
setExportError(formatError(error, 'Het projectrapport kon niet worden gemaakt'))
} finally {
setExporting(false)
}
}
const persistMapResult = async (payload: MapResultExportRequest) => {
setSelectionExporting(true)
setSelectionExportError(null)
try {
const response = await exportsApi.exportMapResult(payload)
setLatestExport(response)
setLatestSelectionExport(response)
await loadExports(payload.project_id)
return response
} catch (error) {
setSelectionExportError(formatError(error, 'Het kaartresultaat kon niet worden bewaard.'))
return null
} finally {
setSelectionExporting(false)
}
}
const previewExportContent = async (exportId: string) => {
setExportError(null)
try {
if (!selectedProjectId) return
const response = await exportsApi.getContent(selectedProjectId, exportId)
setExportPreview(response.content)
} catch (error) {
setExportError(formatError(error, 'De inhoud van de download kon niet worden geladen'))
}
}
const downloadExportArtifact = (exportId: string) => {
if (!selectedProjectId) return
window.open(exportsApi.downloadUrl(selectedProjectId, exportId), '_blank', 'noopener,noreferrer')
}
const resetExportsForProject = () => {
setExports([])
setLatestExport(null)
setLatestSelectionExport(null)
setSelectionExportError(null)
setExportPreview(null)
}
return {
exports,
latestExport,
exportError,
loadingExports,
exporting,
selectionExporting,
selectionExportError,
latestSelectionExport,
exportPreview,
loadExports,
exportSelectedDatasetGeoJson,
exportSelectedDetectionRunGeoJson,
exportSelectedSegmentationRunGeoJson,
exportMapSelectionGeoJson,
persistMapResult,
exportProjectMetadata,
exportProjectReport,
previewExportContent,
downloadExportArtifact,
resetExportsForProject,
}
}
@@ -0,0 +1,173 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IDLE_STATUS, useFullGisWorkflow } from './useFullGisWorkflow'
import type { DatasetCreateResponse, VectorSelectionBBox } from '../types'
/**
* The workflow's error paths are what matter. A chain that reports "afgerond"
* after a step returned nothing tells an operator a result exists when it does
* not — the one mistake this panel must not make.
*/
const BBOX: VectorSelectionBBox = { min_x: 5.0, min_y: 51.1, max_x: 5.2, max_y: 51.3, crs: 'EPSG:4326' }
const DATASET = { id: 'derived-1', name: 'derived.geojson' } as DatasetCreateResponse
function setup(overrides: Partial<Parameters<typeof useFullGisWorkflow>[0]> = {}) {
const options = {
selectedDataset: { id: 'source-1' } as DatasetCreateResponse,
latestSelectionDataset: DATASET,
qaReferenceDatasetId: 'reference-1',
resolveBbox: () => BBOX,
resolveAreaId: () => 'area-1',
setSelectionBbox: vi.fn(),
onRunSelectionExtract: vi.fn().mockResolvedValue({ feature_count: 3 }),
onDeriveDataset: vi.fn().mockResolvedValue(DATASET),
onExportSelection: vi.fn().mockResolvedValue({}),
onRunQa: vi.fn().mockResolvedValue({ f1_score: 0.9 }),
...overrides,
}
return { options, view: renderHook(() => useFullGisWorkflow(options)) }
}
describe('the happy path', () => {
it('walks all four steps and reports completion', async () => {
const { options, view } = setup()
await act(async () => void (await view.result.current.run()))
expect(options.onRunSelectionExtract).toHaveBeenCalledWith(BBOX, 'area-1')
expect(options.onDeriveDataset).toHaveBeenCalledWith(BBOX, 'area-1')
expect(options.onExportSelection).toHaveBeenCalledWith(BBOX, 'area-1')
expect(options.onRunQa).toHaveBeenCalledWith(DATASET)
expect(view.result.current.status).toContain('afgerond')
expect(view.result.current.error).toBeNull()
expect(view.result.current.running).toBe(false)
})
it('starts idle', () => {
const { view } = setup()
expect(view.result.current.status).toBe(IDLE_STATUS)
expect(view.result.current.running).toBe(false)
})
})
describe('a step that produces nothing stops the chain', () => {
it('stops at the selection and does not derive a dataset', async () => {
const { options, view } = setup({ onRunSelectionExtract: vi.fn().mockResolvedValue(null) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.status).toBe('Stopped at query.')
expect(view.result.current.error).toContain('ruimtelijke selectie')
expect(options.onDeriveDataset).not.toHaveBeenCalled()
expect(options.onExportSelection).not.toHaveBeenCalled()
})
it('stops at the derived dataset and does not export or check quality', async () => {
const { options, view } = setup({ onDeriveDataset: vi.fn().mockResolvedValue(null) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.status).toBe('Stopped at dataset save.')
expect(options.onExportSelection).not.toHaveBeenCalled()
expect(options.onRunQa).not.toHaveBeenCalled()
})
it('reports an unfinished quality check without claiming completion', async () => {
const { view } = setup({ onRunQa: vi.fn().mockResolvedValue(null) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.status).toContain('niet afgerond')
})
})
describe('a thrown error', () => {
it('surfaces the message and marks the workflow stopped', async () => {
const { view } = setup({ onDeriveDataset: vi.fn().mockRejectedValue(new Error('PostGIS unavailable')) })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toBe('PostGIS unavailable')
expect(view.result.current.status).toBe('De werkstroom is gestopt.')
})
it('always releases the running flag', async () => {
const { view } = setup({ onRunSelectionExtract: vi.fn().mockRejectedValue(new Error('boom')) })
await act(async () => void (await view.result.current.run()))
await waitFor(() => expect(view.result.current.running).toBe(false))
})
it('does not present a non-Error rejection as a real message', async () => {
const { view } = setup({ onExportSelection: vi.fn().mockRejectedValue('a string') })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toBe('De volledige GIS-werkstroom is mislukt.')
})
})
describe('preconditions', () => {
it('refuses to start without a selection', async () => {
const { options, view } = setup({ resolveBbox: () => null })
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toContain('werkgebied')
expect(options.onRunSelectionExtract).not.toHaveBeenCalled()
})
it('refuses to start without a map layer', async () => {
const { options, view } = setup({ selectedDataset: null })
await act(async () => void (await view.result.current.run()))
expect(options.onRunSelectionExtract).not.toHaveBeenCalled()
})
it('reports a quality check that was skipped for lack of a reference', async () => {
const { options, view } = setup({ qaReferenceDatasetId: null })
await act(async () => void (await view.result.current.run()))
expect(options.onRunQa).not.toHaveBeenCalled()
expect(view.result.current.status).toContain('Kies een referentielaag')
})
})
describe('reuse mode', () => {
it('checks the last saved result without redoing the selection', async () => {
const { options, view } = setup()
act(() => view.result.current.setMode('reuse'))
await act(async () => void (await view.result.current.run()))
expect(options.onRunSelectionExtract).not.toHaveBeenCalled()
expect(options.onRunQa).toHaveBeenCalledWith(DATASET)
expect(view.result.current.status).toContain('opnieuw gebruikt')
})
it('refuses without a saved selection', async () => {
const { options, view } = setup({ latestSelectionDataset: null })
act(() => view.result.current.setMode('reuse'))
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toContain('Bewaar eerst')
expect(options.onRunQa).not.toHaveBeenCalled()
})
it('refuses without a reference layer', async () => {
const { options, view } = setup({ qaReferenceDatasetId: null })
act(() => view.result.current.setMode('reuse'))
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toContain('referentielaag')
expect(options.onRunQa).not.toHaveBeenCalled()
})
})
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useState } from 'react'
import type { DatasetCreateResponse, VectorSelectionBBox } from '../types'
/**
* The four-step GIS workflow: select, derive, export, check quality.
*
* Extracted for its error paths. Every step can fail or return nothing, and
* each outcome has to leave the operator with a status that says where the
* chain stopped — a workflow that silently reports "done" after a failed step
* is the one mistake this panel must not make. Inline in a 3.300-line
* component none of that was exercised.
*/
export type FullWorkflowMode = 'new' | 'reuse'
export const IDLE_STATUS = 'Klaar om de volledige GIS-werkstroom uit te voeren.'
const STOPPED_STATUS = 'De werkstroom is gestopt.'
const GENERIC_FAILURE = 'De volledige GIS-werkstroom is mislukt.'
interface FullGisWorkflowOptions {
selectedDataset: DatasetCreateResponse | null | undefined
latestSelectionDataset: DatasetCreateResponse | null | undefined
qaReferenceDatasetId: string | null | undefined
resolveBbox: () => VectorSelectionBBox | null
resolveAreaId: (bbox: VectorSelectionBBox) => string | undefined
setSelectionBbox: (bbox: VectorSelectionBBox) => void
onRunSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onDeriveDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
onExportSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onRunQa: (dataset: DatasetCreateResponse) => Promise<unknown>
}
export interface FullGisWorkflow {
running: boolean
status: string
error: string | null
mode: FullWorkflowMode
setMode: (mode: FullWorkflowMode) => void
run: () => Promise<void>
}
export function useFullGisWorkflow(options: FullGisWorkflowOptions): FullGisWorkflow {
const [running, setRunning] = useState(false)
const [status, setStatus] = useState(IDLE_STATUS)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<FullWorkflowMode>('new')
const {
selectedDataset,
latestSelectionDataset,
qaReferenceDatasetId,
resolveBbox,
resolveAreaId,
setSelectionBbox,
onRunSelectionExtract,
onDeriveDataset,
onExportSelection,
onRunQa,
} = options
const runReuse = useCallback(async () => {
if (!latestSelectionDataset) {
setError('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.')
return
}
if (!qaReferenceDatasetId) {
setError('Kies eerst een referentielaag voor de kwaliteitscontrole.')
return
}
setRunning(true)
setError(null)
try {
setStatus('Laatste bewaarde resultaatlaag opnieuw controleren...')
const qaResult = await onRunQa(latestSelectionDataset)
setStatus(
qaResult
? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.'
: 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.',
)
} catch (thrown) {
setError(thrown instanceof Error ? thrown.message : GENERIC_FAILURE)
setStatus(STOPPED_STATUS)
} finally {
setRunning(false)
}
}, [latestSelectionDataset, onRunQa, qaReferenceDatasetId])
const runNew = useCallback(async () => {
const bbox = resolveBbox()
if (!selectedDataset || !bbox) {
setError('Kies een kaartlaag en een werkgebied of laagbegrenzing.')
return
}
setRunning(true)
setError(null)
try {
setStatus('1/4 Bewaarde kaartobjecten selecteren...')
setSelectionBbox(bbox)
const areaId = resolveAreaId(bbox)
// Each step reports where the chain stopped. A step that returns nothing
// is a failure, not a reason to continue to the next one.
const selection = await onRunSelectionExtract(bbox, areaId)
if (!selection) {
setError('De ruimtelijke selectie kon niet worden afgerond.')
setStatus('Stopped at query.')
return
}
setStatus('2/4 Saving derived result dataset...')
const derived = await onDeriveDataset(bbox, areaId)
if (!derived) {
setError('De afgeleide resultaatlaag kon niet worden aangemaakt.')
setStatus('Stopped at dataset save.')
return
}
setStatus('3/4 Saving GeoJSON export artifact...')
await onExportSelection(bbox, areaId)
if (!qaReferenceDatasetId) {
setStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.')
return
}
setStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...')
const qaResult = await onRunQa(derived)
setStatus(
qaResult
? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.'
: 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.',
)
} catch (thrown) {
setError(thrown instanceof Error ? thrown.message : GENERIC_FAILURE)
setStatus(STOPPED_STATUS)
} finally {
setRunning(false)
}
}, [
onDeriveDataset,
onExportSelection,
onRunQa,
onRunSelectionExtract,
qaReferenceDatasetId,
resolveAreaId,
resolveBbox,
selectedDataset,
setSelectionBbox,
])
const run = useCallback(
() => (mode === 'reuse' ? runReuse() : runNew()),
[mode, runNew, runReuse],
)
return { running, status, error, mode, setMode, run }
}
+117
View File
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantQueryResponse } from '../types'
const mocks = vi.hoisted(() => ({
status: vi.fn(),
models: vi.fn(),
query: vi.fn(),
}))
vi.mock('../services/api/assistant', () => ({
assistantApi: mocks,
}))
import { useGeoAssistant } from './useGeoAssistant'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function response(answer: string): AssistantQueryResponse {
return {
answer,
model: 'geo-model',
scope_label: 'testgebied',
context_metrics: [],
temporal_series: [],
source_dataset_ids: [],
warnings: [],
generated_at: '2026-08-23T12:00:00Z',
}
}
describe('useGeoAssistant request scope', () => {
beforeEach(() => {
window.localStorage.clear()
mocks.status.mockResolvedValue({
enabled: true,
reachable: true,
status: 'ready',
base_url: 'http://localhost',
default_model: 'geo-model',
model_count: 1,
limitation_message: '',
})
mocks.models.mockResolvedValue({
items: [{ name: 'geo-model', capabilities: ['chat'] }],
total: 1,
default_model: 'geo-model',
})
})
it('ignores an answer that returns after the active project changed', async () => {
const pending = deferred<AssistantQueryResponse>()
mocks.query.mockReturnValueOnce(pending.promise)
const { result, rerender } = renderHook(
({ projectId }) => useGeoAssistant({
selectedProjectId: projectId,
selectedAreaId: null,
selectionBbox: null,
}),
{ initialProps: { projectId: 'project-1' } },
)
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let request!: Promise<boolean>
act(() => {
request = result.current.ask('Wat staat hier?')
})
rerender({ projectId: 'project-2' })
await act(async () => {
pending.resolve(response('antwoord uit project 1'))
await request
})
expect(result.current.messages).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('lets only the newest request update a conversation', async () => {
const older = deferred<AssistantQueryResponse>()
const newer = deferred<AssistantQueryResponse>()
mocks.query
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
const { result } = renderHook(() => useGeoAssistant({
selectedProjectId: 'project-1',
selectedAreaId: null,
selectionBbox: null,
}))
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let olderRequest!: Promise<boolean>
let newerRequest!: Promise<boolean>
act(() => { olderRequest = result.current.ask('Eerste vraag') })
act(() => { newerRequest = result.current.ask('Tweede vraag') })
await act(async () => {
newer.resolve(response('nieuwste antwoord'))
await newerRequest
})
await act(async () => {
older.resolve(response('verouderd antwoord'))
await olderRequest
})
const assistantMessages = result.current.messages.filter((message) => message.role === 'assistant')
expect(assistantMessages.map((message) => message.content)).toEqual(['nieuwste antwoord'])
expect(result.current.loading).toBe(false)
})
})
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { assistantApi } from '../services/api/assistant'
import type {
AssistantChatMessage,
AssistantModelRead,
AssistantQueryResponse,
AssistantStatus,
VectorSelectionBBox,
} from '../types'
export interface GeoAssistantMessage extends AssistantChatMessage {
id: string
response?: AssistantQueryResponse
}
interface UseGeoAssistantOptions {
selectedProjectId: string | null
selectedAreaId: string | null
selectionBbox: VectorSelectionBBox | null
}
const ASSISTANT_MODEL_PREFERENCE_KEY = 'geointel.assistant.model-preference.v1'
let assistantMessageSequence = 0
function nextAssistantMessageId(role: AssistantChatMessage['role']): string {
assistantMessageSequence += 1
return `${role}-${Date.now()}-${assistantMessageSequence}`
}
function readStoredPreference(): string {
try {
return window.localStorage.getItem(ASSISTANT_MODEL_PREFERENCE_KEY) || 'automatic'
} catch {
return 'automatic'
}
}
function assistantScopeKey(
projectId: string | null,
areaId: string | null,
bbox: VectorSelectionBBox | null,
): string {
return JSON.stringify([
projectId,
areaId,
bbox?.min_x ?? null,
bbox?.min_y ?? null,
bbox?.max_x ?? null,
bbox?.max_y ?? null,
bbox?.crs ?? null,
])
}
interface AssistantConversationState {
scopeKey: string
messages: GeoAssistantMessage[]
}
interface AssistantRequestState {
scopeKey: string
requestId: number
loading: boolean
error: string | null
}
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
const scopeKey = assistantScopeKey(selectedProjectId, selectedAreaId, selectionBbox)
const activeScopeRef = useRef(scopeKey)
const latestRequestIdRef = useRef(0)
if (activeScopeRef.current !== scopeKey) {
activeScopeRef.current = scopeKey
latestRequestIdRef.current += 1
}
const [status, setStatus] = useState<AssistantStatus | null>(null)
const [models, setModels] = useState<AssistantModelRead[]>([])
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
const [defaultModel, setDefaultModel] = useState('')
const [conversation, setConversation] = useState<AssistantConversationState>({ scopeKey, messages: [] })
const [requestState, setRequestState] = useState<AssistantRequestState>({
scopeKey,
requestId: 0,
loading: false,
error: null,
})
const [loadingModels, setLoadingModels] = useState(false)
const [modelError, setModelError] = useState<string | null>(null)
const messages = conversation.scopeKey === scopeKey ? conversation.messages : []
const loading = requestState.scopeKey === scopeKey && requestState.loading
const queryError = requestState.scopeKey === scopeKey ? requestState.error : null
const error = queryError ?? modelError
const selectedModel = useMemo(() => {
const available = new Set(models.map((model) => model.name))
if (selectedModelChoice !== 'automatic' && available.has(selectedModelChoice)) return selectedModelChoice
if (defaultModel && available.has(defaultModel)) return defaultModel
return models[0]?.name ?? ''
}, [defaultModel, models, selectedModelChoice])
const setSelectedModel = (value: string) => {
const valid = value === 'automatic' || models.some((model) => model.name === value)
const next = valid ? value : 'automatic'
setSelectedModelChoice(next)
try { window.localStorage.setItem(ASSISTANT_MODEL_PREFERENCE_KEY, next) } catch { /* voorkeur blijft sessielokaal */ }
}
const loadModels = async () => {
setLoadingModels(true)
setModelError(null)
try {
const currentStatus = await assistantApi.status()
setStatus(currentStatus)
if (!currentStatus.enabled || !currentStatus.reachable) {
setModels([])
setDefaultModel('')
return
}
const result = await assistantApi.models()
setModels(result.items)
const fallback = result.default_model && result.items.some((model) => model.name === result.default_model)
? result.default_model
: result.items[0]?.name ?? ''
setDefaultModel(fallback)
setSelectedModelChoice((current) => current === 'automatic' || result.items.some((model) => model.name === current) ? current : 'automatic')
} catch (requestError) {
setStatus(null)
setModels([])
setDefaultModel('')
setModelError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
} finally {
setLoadingModels(false)
}
}
useEffect(() => { void loadModels() }, [])
useEffect(() => {
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}, [scopeKey])
const ask = async (question: string): Promise<boolean> => {
const trimmed = question.trim()
if (!selectedProjectId || !trimmed || !selectedModel) return false
const requestId = latestRequestIdRef.current + 1
latestRequestIdRef.current = requestId
const requestScopeKey = scopeKey
const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed }
setConversation((current) => ({
scopeKey: requestScopeKey,
messages: [...(current.scopeKey === requestScopeKey ? current.messages : []), userMessage],
}))
setRequestState({ scopeKey: requestScopeKey, requestId, loading: true, error: null })
const isLatestRequest = () => (
latestRequestIdRef.current === requestId
&& activeScopeRef.current === requestScopeKey
)
try {
const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
const result = await assistantApi.query(selectedProjectId, {
question: trimmed,
model: selectedModel,
bbox: selectionBbox,
area_id: selectedAreaId,
history,
})
if (!isLatestRequest()) return false
setConversation((current) => current.scopeKey === requestScopeKey ? {
scopeKey: requestScopeKey,
messages: [...current.messages, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }],
} : current)
return true
} catch (requestError) {
if (!isLatestRequest()) return false
setRequestState({
scopeKey: requestScopeKey,
requestId,
loading: false,
error: formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'),
})
return false
} finally {
if (isLatestRequest()) {
setRequestState((current) => current.scopeKey === requestScopeKey && current.requestId === requestId
? { ...current, loading: false }
: current)
}
}
}
const clear = () => {
latestRequestIdRef.current += 1
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}
return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel }
}
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { buildRasterOverlays, pickActiveOverlays } from './useMapImageOverlays'
import type { DatasetCreateResponse } from '../types'
/**
* Five near-identical useMemo blocks in MapWorkspace each filtered partitions by
* source name, read `bbox_epsg4326`, and built the same overlay shape. Collapsing
* them into one builder makes the rule testable and the differences — which
* source, which label, which opacity — visible instead of buried in repetition.
*/
function dataset(overrides: Partial<DatasetCreateResponse> = {}): DatasetCreateResponse {
return {
id: 'dataset-1',
project_id: 'project-1',
name: 'dhmv.tif',
dataset_type: 'raster',
source: 'digitaal_vlaanderen_dhmv',
source_name: 'digitaal_vlaanderen_dhmv',
status: 'ready',
source_metadata: { bbox_epsg4326: [4.0, 51.0, 4.1, 51.1] },
...overrides,
} as DatasetCreateResponse
}
describe('buildRasterOverlays', () => {
it('builds an overlay for each matching partition', () => {
const overlays = buildRasterOverlays({
datasets: [dataset(), dataset({ id: 'dataset-2' })],
sourceNames: ['digitaal_vlaanderen_dhmv'],
opacity: 0.82,
url: (id) => `/terrain/${id}`,
label: () => 'Hoogtemodel',
})
expect(overlays.map((item) => item.url)).toEqual(['/terrain/dataset-1', '/terrain/dataset-2'])
expect(overlays[0].bbox).toEqual([4.0, 51.0, 4.1, 51.1])
expect(overlays[0].opacity).toBe(0.82)
})
it('skips a dataset from another source', () => {
const overlays = buildRasterOverlays({
datasets: [dataset({ source_name: 'vmm_flood_hazard' })],
sourceNames: ['digitaal_vlaanderen_dhmv'],
opacity: 0.82,
url: (id) => `/terrain/${id}`,
label: () => 'Hoogtemodel',
})
expect(overlays).toEqual([])
})
it('skips a dataset without usable bounds rather than drawing it wrong', () => {
const cases = [
dataset({ source_metadata: {} }),
dataset({ source_metadata: { bbox_epsg4326: [4.0, 51.0] } }),
dataset({ source_metadata: { bbox_epsg4326: 'not-an-array' } }),
]
for (const item of cases) {
expect(
buildRasterOverlays({
datasets: [item],
sourceNames: ['digitaal_vlaanderen_dhmv'],
opacity: 0.82,
url: (id) => `/terrain/${id}`,
label: () => 'Hoogtemodel',
}),
).toEqual([])
}
})
it('accepts several source names for one theme', () => {
const overlays = buildRasterOverlays({
datasets: [dataset({ source_name: 'spw_terrain' })],
sourceNames: ['digitaal_vlaanderen_dhmv', 'spw_terrain'],
opacity: 0.82,
url: (id) => `/terrain/${id}`,
label: () => 'Terrein',
})
expect(overlays).toHaveLength(1)
})
it('takes the label from the dataset so a scenario can name itself', () => {
const overlays = buildRasterOverlays({
datasets: [dataset({ source_name: 'vmm_flood_hazard', name: 'T100' })],
sourceNames: ['vmm_flood_hazard'],
opacity: 0.82,
url: (id) => `/flood/${id}`,
label: (item) => `Scenario ${item.name}`,
})
expect(overlays[0].label).toBe('Scenario T100')
})
})
describe('pickActiveOverlays', () => {
const overlay = (label: string) => ({ url: `/${label}`, bbox: [0, 0, 1, 1] as [number, number, number, number], label, opacity: 1 })
it('draws the most specific raster the theme resolved to', () => {
const active = pickActiveOverlays({
candidates: [[], [overlay('walous')], [overlay('terrain')]],
fallback: overlay('orthophoto'),
})
expect(active.map((item) => item.label)).toEqual(['walous'])
})
it('falls back to the orthophoto when no raster theme is active', () => {
const active = pickActiveOverlays({ candidates: [[], []], fallback: overlay('orthophoto') })
expect(active.map((item) => item.label)).toEqual(['orthophoto'])
})
it('draws nothing when there is neither a raster nor an orthophoto', () => {
expect(pickActiveOverlays({ candidates: [[], []], fallback: null })).toEqual([])
})
})
+157
View File
@@ -0,0 +1,157 @@
import { useMemo } from 'react'
import type { DatasetCreateResponse } from '../types'
import { getDatasetDisplayName } from '../lib/datasetDisplay'
import { terrainImageUrl } from '../lib/terrainImage'
import { floodHazardImageUrl } from '../lib/floodHazardImage'
import { thematicRasterImageUrl, walousRasterImageUrl } from '../lib/thematicRaster'
import { bathymetryRasterImageUrl } from '../lib/bathymetryRaster'
/**
* Which raster image the map draws underneath the active theme.
*
* MapWorkspace held five near-identical useMemo blocks for this — terrain,
* flood depth, thematic raster, WALOUS land cover and bathymetry — each
* filtering partitions by source name, reading `bbox_epsg4326` and assembling
* the same overlay shape. One builder makes the rule testable and leaves only
* the parts that genuinely differ per theme: the source, the label and the
* opacity.
*/
export interface MapImageOverlay {
url: string
bbox: [number, number, number, number]
label: string
opacity: number
}
interface RasterOverlayRequest {
datasets: DatasetCreateResponse[]
sourceNames: string[]
opacity: number
url: (datasetId: string) => string
label: (dataset: DatasetCreateResponse) => string
}
/** A bbox is only usable if the source actually published four numbers for it. */
function overlayBounds(dataset: DatasetCreateResponse): [number, number, number, number] | null {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
if (!Array.isArray(bounds) || bounds.length !== 4) return null
const numbers = bounds.map(Number)
return numbers.some((value) => !Number.isFinite(value))
? null
: (numbers as [number, number, number, number])
}
export function buildRasterOverlays({
datasets,
sourceNames,
opacity,
url,
label,
}: RasterOverlayRequest): MapImageOverlay[] {
return datasets.flatMap((dataset) => {
if (!sourceNames.includes(dataset.source_name ?? '')) return []
const bbox = overlayBounds(dataset)
// A raster we cannot place is not drawn at all: an overlay in the wrong
// spot is worse than no overlay.
if (!bbox) return []
return [{ url: url(dataset.id), bbox, label: label(dataset), opacity }]
})
}
/** The first non-empty raster wins; the orthophoto is the base layer. */
export function pickActiveOverlays({
candidates,
fallback,
}: {
candidates: MapImageOverlay[][]
fallback: MapImageOverlay | null
}): MapImageOverlay[] {
const active = candidates.find((item) => item.length > 0)
if (active) return active
return fallback ? [fallback] : []
}
/** Sources drawn as a continuous value ramp, which is what the legend explains. */
const VALUE_RAMP_SOURCES = ['department_omgeving_thematic_raster', 'spw_walous_land_cover']
export function isValueRampRasterSource(dataset: DatasetCreateResponse | null | undefined): boolean {
return VALUE_RAMP_SOURCES.includes(dataset?.source_name ?? '')
}
interface MapImageOverlayOptions {
selectedProjectId: string | null
activeThemeId: string
activeThemeDataset: DatasetCreateResponse | null | undefined
activeThemePartitions: DatasetCreateResponse[]
orthophotoImageOverlay: MapImageOverlay | null
floodScenarioLabel: (dataset: DatasetCreateResponse) => string
}
export function useMapImageOverlays({
selectedProjectId,
activeThemeId,
activeThemeDataset,
activeThemePartitions,
orthophotoImageOverlay,
floodScenarioLabel,
}: MapImageOverlayOptions): MapImageOverlay[] {
return useMemo(() => {
if (!selectedProjectId) return orthophotoImageOverlay ? [orthophotoImageOverlay] : []
const singleDataset = activeThemeDataset ? [activeThemeDataset] : []
// Ordered most specific first: a theme that resolved to a dedicated raster
// product should not be drawn as generic terrain underneath it.
const candidates = [
buildRasterOverlays({
datasets: singleDataset,
sourceNames: ['spw_bathymetry'],
opacity: 0.86,
url: (id) => bathymetryRasterImageUrl(selectedProjectId, id),
label: () => 'Waterbodemhoogte in mDNG',
}),
buildRasterOverlays({
datasets: singleDataset,
sourceNames: ['spw_walous_land_cover'],
opacity: 0.82,
url: (id) => walousRasterImageUrl(selectedProjectId, id),
label: getDatasetDisplayName,
}),
buildRasterOverlays({
datasets: singleDataset,
sourceNames: ['department_omgeving_thematic_raster'],
opacity: 0.78,
url: (id) => thematicRasterImageUrl(selectedProjectId, id),
label: getDatasetDisplayName,
}),
activeThemeId === 'flood_hazard'
? buildRasterOverlays({
datasets: activeThemePartitions,
sourceNames: ['vmm_flood_hazard'],
opacity: 0.82,
url: (id) => floodHazardImageUrl(selectedProjectId, id),
label: floodScenarioLabel,
})
: [],
activeThemeId === 'elevation'
? buildRasterOverlays({
datasets: activeThemePartitions,
sourceNames: ['digitaal_vlaanderen_dhmv', 'spw_terrain'],
opacity: 0.82,
url: (id) => terrainImageUrl(selectedProjectId, id),
label: getDatasetDisplayName,
})
: [],
]
return pickActiveOverlays({ candidates, fallback: orthophotoImageOverlay })
}, [
activeThemeDataset,
activeThemeId,
activeThemePartitions,
floodScenarioLabel,
orthophotoImageOverlay,
selectedProjectId,
])
}
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import { datasetsApi } from '../services/api'
import type {
DatasetCreateResponse,
DetectionQaResult,
DetectionRunResponse,
OrthophotoAcquisitionResult,
OrthophotoProductRead,
VectorSelectionBBox,
} from '../types'
import { formatError } from '../lib/formatError'
export type MapOrthophotoAnalysisStage =
| 'idle'
| 'acquiring'
| 'detecting'
| 'validating'
| 'complete'
| 'failed'
export const MAP_BUILDING_QA_IOU_THRESHOLD = 0.25
interface MapOrthophotoAnalysisOptions {
selectedProjectId: string | null
selectedAreaId: string
selectionBbox: VectorSelectionBBox | null
datasets: DatasetCreateResponse[]
loadProjectData: (projectId: string) => Promise<unknown>
prepareAndRunDetection: (datasetId?: string) => Promise<DetectionRunResponse | null>
compareDetectionRunWithReference: (
analysisRunId: string,
referenceDatasetId: string,
iouThreshold: number,
) => Promise<DetectionQaResult | null>
loadQualityEvidence: (qualityCheckId: string) => Promise<unknown>
onAnalysisReady: () => void
}
function findBuildingReference(datasets: DatasetCreateResponse[]): DatasetCreateResponse | null {
return datasets.find(
(dataset) =>
dataset.status === 'ready' &&
dataset.dataset_role === 'reference' &&
dataset.source_name === 'grb' &&
dataset.reference_layer_name === 'buildings',
) ?? null
}
function formatOrthophotoError(caught: unknown): string {
const code = caught instanceof Error ? (caught as Error & { code?: string }).code : undefined
if (code === 'ORTHOPHOTO_SELECTION_TOO_SMALL') {
return 'Maak de rechthoek minstens 128 bij 128 meter groot.'
}
if (code === 'ORTHOPHOTO_SELECTION_TOO_LARGE') {
return 'Maak de rechthoek maximaal 1.024 bij 1.024 meter groot.'
}
if (code === 'ORTHOPHOTO_SELECTION_OUTSIDE_AREA') {
return 'Teken de rechthoek binnen het ingeladen Belgische land- of zeegebied.'
}
if (code === 'ORTHOPHOTO_PROVIDER_UNAVAILABLE' || code === 'ORTHOPHOTO_PROVIDER_INVALID_RESPONSE') {
return 'De officiële luchtbeeldbron is tijdelijk niet bereikbaar. Probeer later opnieuw.'
}
if (code === 'ORTHOPHOTO_NOT_CONFIGURED') {
return 'Luchtbeelden ophalen is uitgeschakeld door de beheerder.'
}
return formatError(caught, 'De kaartgestuurde beeldanalyse is mislukt')
}
function formatQualityPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
export function useMapOrthophotoAnalysis({
selectedProjectId,
selectedAreaId,
selectionBbox,
datasets,
loadProjectData,
prepareAndRunDetection,
compareDetectionRunWithReference,
loadQualityEvidence,
onAnalysisReady,
}: MapOrthophotoAnalysisOptions) {
const [stage, setStage] = useState<MapOrthophotoAnalysisStage>('idle')
const [status, setStatus] = useState('')
const [error, setError] = useState<string | null>(null)
const [lastResult, setLastResult] = useState<OrthophotoAcquisitionResult | null>(null)
const [lastQuality, setLastQuality] = useState<DetectionQaResult | null>(null)
const [lastDetectionCount, setLastDetectionCount] = useState<number | null>(null)
const [lastAnalysisRunId, setLastAnalysisRunId] = useState<string | null>(null)
const [products, setProducts] = useState<OrthophotoProductRead[]>([])
const [selectedProductKey, setSelectedProductKey] = useState('most_recent')
useEffect(() => {
if (!selectedProjectId) {
setProducts([])
return
}
let cancelled = false
void datasetsApi.listOrthophotoProducts(selectedProjectId)
.then((response) => {
if (!cancelled) {
setProducts(response.items)
setSelectedProductKey((current) =>
response.items.some((item) => item.key === current) ? current : response.items[0]?.key ?? 'most_recent',
)
}
})
.catch((caught) => {
if (!cancelled) {
setError(formatError(caught, 'De luchtbeeldcatalogus kon niet worden geladen'))
}
})
return () => {
cancelled = true
}
}, [selectedProjectId])
useEffect(() => {
setStage('idle')
setStatus('')
setError(null)
setLastResult(null)
setLastQuality(null)
setLastDetectionCount(null)
setLastAnalysisRunId(null)
}, [selectionBbox?.min_x, selectionBbox?.min_y, selectionBbox?.max_x, selectionBbox?.max_y, selectedProductKey])
const run = async (bbox: VectorSelectionBBox): Promise<boolean> => {
if (!selectedProjectId) {
setError('De regionale werkruimte is nog niet geladen.')
setStage('failed')
return false
}
setError(null)
setLastResult(null)
setLastQuality(null)
setLastDetectionCount(null)
setLastAnalysisRunId(null)
setStage('acquiring')
setStatus('1/3 Officieel luchtbeeld voor de rechthoek ophalen...')
try {
const job = await datasetsApi.acquireOrthophoto(selectedProjectId, {
bbox,
area_id: selectedAreaId || undefined,
product_key: selectedProductKey,
})
const acquisition = job.result_json as unknown as OrthophotoAcquisitionResult | null
const datasetId = job.output_dataset_id || acquisition?.output_dataset_id
if (job.status !== 'success' || !datasetId || !acquisition) {
throw new Error(job.error_message || 'Het officiële luchtbeeld werd niet als dataset bewaard.')
}
setLastResult(acquisition)
await loadProjectData(selectedProjectId)
if (!acquisition.supports_detection) {
setStatus(
`${acquisition.display_name} is ingeladen en op de kaart geplaatst. ${acquisition.limitation_message}`,
)
setStage('complete')
return true
}
setStage('detecting')
setStatus('2/3 Lokaal AI-model herkent gebouwen...')
const detection = await prepareAndRunDetection(datasetId)
if (!detection) {
throw new Error('De beeldanalyse stopte. Open Beeldanalyse voor de technische oorzaak.')
}
setLastDetectionCount(detection.detection_count)
setLastAnalysisRunId(detection.analysis_run_id)
const reference = findBuildingReference(datasets)
if (reference) {
setStage('validating')
setStatus('3/3 Resultaat vergelijken met officiële GRB-gebouwen...')
const quality = await compareDetectionRunWithReference(
detection.analysis_run_id,
reference.id,
MAP_BUILDING_QA_IOU_THRESHOLD,
)
setLastQuality(quality)
if (quality) {
await loadQualityEvidence(quality.quality_check_id)
}
setStatus(
quality
? `Controle klaar: ${quality.matches.toLocaleString('nl-BE')} door GRB bevestigde AI-kandidaten, ${quality.false_positives.toLocaleString('nl-BE')} onbevestigde AI-kandidaten en ${quality.false_negatives.toLocaleString('nl-BE')} officiële GRB-gebouwen gemist door AI. AI-precision ${formatQualityPercentage(quality.precision)}, herkenningsgraad ${formatQualityPercentage(quality.recall)}.`
: `Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} kandidaten; kwaliteitscontrole kon niet afronden.`,
)
} else {
setStatus(
`Analyse klaar: ${detection.detection_count.toLocaleString('nl-BE')} kandidaatvormen. De GRB-referentielaag ontbreekt voor automatische controle.`,
)
}
setStage('complete')
onAnalysisReady()
return true
} catch (caught) {
setError(formatOrthophotoError(caught))
setStatus('Analyse gestopt.')
setStage('failed')
return false
}
}
return {
stage,
status,
error,
lastResult,
imageUrl: lastResult && selectedProjectId
? datasetsApi.orthophotoImageUrl(selectedProjectId, lastResult.output_dataset_id)
: null,
lastQuality,
lastDetectionCount,
lastAnalysisRunId,
products,
selectedProductKey,
setSelectedProductKey,
running: stage === 'acquiring' || stage === 'detecting' || stage === 'validating',
run,
}
}
@@ -0,0 +1,133 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useMapRectangleSelection } from './useMapRectangleSelection'
import type { VectorSelectionBBox } from '../types'
/**
* Corner tracking lived inline in a 3.300-line component and was never
* exercised. The rule worth pinning is that a first corner produces no
* rectangle at all: acting on it would analyse a zero-width area.
*/
const BBOX: VectorSelectionBBox = { min_x: 5.0, min_y: 51.1, max_x: 5.2, max_y: 51.3, crs: 'EPSG:4326' }
function setup(initial: VectorSelectionBBox | null = null) {
return renderHook(() => useMapRectangleSelection(initial))
}
describe('two-click rectangle', () => {
it('yields no rectangle from the first corner', () => {
const view = setup()
let result: VectorSelectionBBox | null = BBOX
act(() => {
result = view.result.current.placeCorner([5.0, 51.1])
})
expect(result).toBeNull()
expect(view.result.current.firstCorner).toEqual([5.0, 51.1])
})
it('yields the normalised rectangle from the second corner', () => {
const view = setup()
act(() => void view.result.current.placeCorner([5.2, 51.3]))
let result: VectorSelectionBBox | null = null
act(() => {
result = view.result.current.placeCorner([5.0, 51.1])
})
expect(result).not.toBeNull()
expect(result!.min_x).toBeCloseTo(5.0)
expect(result!.min_y).toBeCloseTo(51.1)
expect(result!.max_x).toBeCloseTo(5.2)
expect(result!.max_y).toBeCloseTo(51.3)
})
it('leaves no half-finished gesture behind', () => {
const view = setup()
act(() => void view.result.current.setDrawing(true))
act(() => void view.result.current.placeCorner([5.2, 51.3]))
act(() => void view.result.current.placeCorner([5.0, 51.1]))
expect(view.result.current.firstCorner).toBeNull()
expect(view.result.current.drawing).toBe(false)
})
it('normalises corners placed in any order', () => {
const view = setup()
act(() => void view.result.current.placeCorner([5.0, 51.3]))
let result: VectorSelectionBBox | null = null
act(() => {
result = view.result.current.placeCorner([5.2, 51.1])
})
expect(result!.min_x).toBeLessThan(result!.max_x)
expect(result!.min_y).toBeLessThan(result!.max_y)
})
})
describe('coordinate fields', () => {
it('starts from the rectangle already on the map', () => {
const view = setup(BBOX)
expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_y: 51.3 })
})
it('parses a complete rectangle', () => {
const view = setup()
act(() =>
view.result.current.setBboxInput({ min_x: '5.0', min_y: '51.1', max_x: '5.2', max_y: '51.3' }),
)
expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_y: 51.3 })
})
it('reports no rectangle while the fields are incomplete', () => {
const view = setup()
act(() => view.result.current.setBboxInput({ min_x: '5.0', min_y: '', max_x: '', max_y: '' }))
expect(view.result.current.typedBbox).toBeNull()
})
it('supports an updater so one field can change at a time', () => {
const view = setup(BBOX)
act(() => view.result.current.setBboxInput((previous) => ({ ...previous, max_x: '5.4' })))
expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_x: 5.4 })
})
it('shows a rectangle that was set elsewhere', () => {
const view = setup()
act(() => view.result.current.showBbox(BBOX))
expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_y: 51.3 })
})
it('clears the fields when the selection is cleared', () => {
const view = setup(BBOX)
act(() => view.result.current.showBbox(null))
expect(view.result.current.typedBbox).toBeNull()
})
})
describe('reset', () => {
it('abandons a half-drawn rectangle', () => {
const view = setup()
act(() => void view.result.current.placeCorner([5.0, 51.1]))
act(() => view.result.current.reset())
expect(view.result.current.firstCorner).toBeNull()
expect(view.result.current.drawing).toBe(false)
})
})
@@ -0,0 +1,78 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'
import type { VectorSelectionBBox } from '../types'
import { bboxToInputState, normalizeBboxFromCorners, parseBboxInput } from '../components/map/mapWorkspaceUtils'
/**
* The state of drawing a rectangle on the map: whether a gesture is in
* progress, which corner has been placed, and what is typed in the coordinate
* fields.
*
* Deliberately only the interaction state. What happens *with* a finished
* rectangle — retiring stale results, starting an analysis — belongs to the
* workspace, which owns those. Injecting that here would make the hook depend
* on values declared after it.
*/
export type BboxInputState = ReturnType<typeof bboxToInputState>
export interface MapRectangleSelection {
/** A rectangle gesture is in progress. */
drawing: boolean
setDrawing: Dispatch<SetStateAction<boolean>>
/** The first click of a two-click rectangle, if one has been placed. */
firstCorner: [number, number] | null
bboxInput: BboxInputState
setBboxInput: Dispatch<SetStateAction<BboxInputState>>
/** The rectangle currently in the coordinate fields, if it parses. */
typedBbox: VectorSelectionBBox | null
/**
* Place a corner. Returns the finished rectangle on the second corner, and
* ``null`` on the first — the caller only acts when a rectangle exists.
*/
placeCorner: (coordinate: [number, number]) => VectorSelectionBBox | null
/** Abandon a half-drawn rectangle and leave drawing mode. */
reset: () => void
/** Show a rectangle in the coordinate fields. */
showBbox: (bbox: VectorSelectionBBox | null) => void
}
export function useMapRectangleSelection(initialBbox: VectorSelectionBBox | null): MapRectangleSelection {
const [drawing, setDrawing] = useState(false)
const [firstCorner, setFirstCorner] = useState<[number, number] | null>(null)
const [bboxInput, setBboxInput] = useState<BboxInputState>(bboxToInputState(initialBbox))
const placeCorner = useCallback(
(coordinate: [number, number]): VectorSelectionBBox | null => {
if (!firstCorner) {
setFirstCorner(coordinate)
return null
}
setFirstCorner(null)
setDrawing(false)
return normalizeBboxFromCorners(firstCorner, coordinate)
},
[firstCorner],
)
const reset = useCallback(() => {
setFirstCorner(null)
setDrawing(false)
}, [])
const showBbox = useCallback((bbox: VectorSelectionBBox | null) => {
setBboxInput(bboxToInputState(bbox))
}, [])
return {
drawing,
setDrawing,
firstCorner,
bboxInput,
setBboxInput,
typedBbox: parseBboxInput(bboxInput),
placeCorner,
reset,
showBbox,
}
}
@@ -0,0 +1,60 @@
import { useState } from 'react'
import type { DatasetCreateResponse, VectorSelectionBBox } from '../types'
import { formatError } from '../lib/formatError'
import { datasetsApi } from '../services/api'
interface UseMapSelectionDatasetOptions {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
isVectorDatasetType: (datasetType: string) => boolean
loadProjectData: (projectId: string) => Promise<unknown>
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
setMapLayerVisible: (visible: boolean) => void
}
export function useMapSelectionDataset({
selectedProjectId,
selectedDataset,
isVectorDatasetType,
loadProjectData,
loadDatasetDetails,
setMapLayerVisible,
}: UseMapSelectionDatasetOptions) {
const [selectionDatasetSaving, setSelectionDatasetSaving] = useState(false)
const [selectionDatasetError, setSelectionDatasetError] = useState<string | null>(null)
const [latestSelectionDataset, setLatestSelectionDataset] = useState<DatasetCreateResponse | null>(null)
const deriveMapSelectionDataset = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedProjectId || !selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setSelectionDatasetError('Kies eerst een vectorbron om het gebied als bron te bewaren.')
return null
}
setSelectionDatasetSaving(true)
setSelectionDatasetError(null)
try {
const derived = await datasetsApi.deriveVectorSelection(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
limit: 250,
output_name: `${selectedDataset.name.replace(/\.(geo)?json$/i, '')}-selection-dataset`,
})
setLatestSelectionDataset(derived)
await loadProjectData(selectedProjectId)
await loadDatasetDetails(selectedProjectId, derived)
setMapLayerVisible(true)
return derived
} catch (error) {
setSelectionDatasetError(formatError(error, 'Het gebied kon niet als bron worden bewaard'))
return null
} finally {
setSelectionDatasetSaving(false)
}
}
return {
selectionDatasetSaving,
selectionDatasetError,
latestSelectionDataset,
deriveMapSelectionDataset,
}
}
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import { datasetsApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
import { bathymetryRasterSelectionToMapSelection } from '../lib/bathymetryRaster'
interface MapSelectionExtractOptions {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
isVectorDatasetType: (datasetType: string) => boolean
}
export function useMapSelectionExtract({
selectedProjectId,
selectedDataset,
isVectorDatasetType,
}: MapSelectionExtractOptions) {
const [mapSelectionBbox, setMapSelectionBbox] = useState<VectorSelectionBBox | null>(null)
const [mapSelectionResult, setMapSelectionResult] = useState<VectorSelectionResponse | null>(null)
const [mapSelectionLoading, setMapSelectionLoading] = useState(false)
const [mapSelectionError, setMapSelectionError] = useState<string | null>(null)
const requestSequence = useRef(0)
useEffect(() => {
requestSequence.current += 1
setMapSelectionBbox(null)
setMapSelectionLoading(false)
}, [selectedProjectId])
useEffect(() => {
requestSequence.current += 1
setMapSelectionResult(null)
setMapSelectionError(null)
setMapSelectionLoading(false)
}, [selectedProjectId, selectedDataset?.id])
const runMapSelectionExtract = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedProjectId || !selectedDataset) {
setMapSelectionError('Open a vector dataset before extracting a map area.')
return null
}
const terrainDataset = selectedDataset.dataset_type === 'raster' && ['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(selectedDataset.source_name ?? '')
const floodHazardDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'vmm_flood_hazard'
const thematicRasterDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'department_omgeving_thematic_raster'
const bathymetryRasterDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'spw_bathymetry'
if (!isVectorDatasetType(selectedDataset.dataset_type) && !terrainDataset && !floodHazardDataset && !thematicRasterDataset && !bathymetryRasterDataset) {
setMapSelectionError('Gebiedsanalyse ondersteunt een vectorlaag of een beheerd thematisch raster.')
return null
}
const sequence = requestSequence.current + 1
requestSequence.current = sequence
setMapSelectionLoading(true)
setMapSelectionError(null)
setMapSelectionBbox(bbox)
try {
const response = terrainDataset
? terrainSelectionToMapSelection(await datasetsApi.selectTerrain(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
}))
: floodHazardDataset
? floodHazardSelectionToMapSelection(await datasetsApi.selectFloodHazard(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
}))
: thematicRasterDataset
? thematicRasterSelectionToMapSelection(await datasetsApi.selectThematicRaster(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
}))
: bathymetryRasterDataset
? bathymetryRasterSelectionToMapSelection(await datasetsApi.selectBathymetryRaster(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
}))
: await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
limit: 1000,
})
if (requestSequence.current !== sequence) {
return null
}
setMapSelectionResult(response)
return response
} catch (error) {
if (requestSequence.current !== sequence) {
return null
}
setMapSelectionResult(null)
setMapSelectionError(formatError(error, 'Area extraction failed'))
return null
} finally {
if (requestSequence.current === sequence) {
setMapSelectionLoading(false)
}
}
}
const resetMapSelectionExtract = () => {
requestSequence.current += 1
setMapSelectionBbox(null)
setMapSelectionResult(null)
setMapSelectionError(null)
setMapSelectionLoading(false)
}
return {
mapSelectionBbox,
mapSelectionResult,
mapSelectionLoading,
mapSelectionError,
runMapSelectionExtract,
resetMapSelectionExtract,
setMapSelectionBbox,
}
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react'
import type { DatasetCreateResponse, JobRead, QaComparisonRequest, QaComparisonResult, QualityCheckRead } from '../types'
import { formatError } from '../lib/formatError'
import { qaApi } from '../services/api'
interface UseMapSelectionQaOptions {
selectedProjectId: string | null
latestSelectionDataset: DatasetCreateResponse | null
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
loadProjectData: (projectId: string) => Promise<unknown>
}
export function useMapSelectionQa({
selectedProjectId,
latestSelectionDataset,
loadQualityChecks,
loadProjectData,
}: UseMapSelectionQaOptions) {
const [selectedMapQaReferenceDatasetId, setSelectedMapQaReferenceDatasetId] = useState('')
const [mapSelectionQaRunning, setMapSelectionQaRunning] = useState(false)
const [mapSelectionQaError, setMapSelectionQaError] = useState<string | null>(null)
const [mapSelectionQaResult, setMapQaResult] = useState<QaComparisonResult | null>(null)
const [latestMapSelectionQualityCheckId, setLatestMapSelectionQualityCheckId] = useState<string | null>(null)
const runMapSelectionQa = async (candidateDataset = latestSelectionDataset) => {
if (!selectedProjectId) {
setMapSelectionQaError('Kies eerst een werkruimte om een kwaliteitscontrole te draaien.')
return null
}
if (!candidateDataset) {
setMapSelectionQaError('Save the map selection as a dataset before running QA/QC.')
return null
}
if (!selectedMapQaReferenceDatasetId) {
setMapSelectionQaError('Kies eerst een referentiebron voor de kwaliteitscontrole.')
return null
}
if (candidateDataset.id === selectedMapQaReferenceDatasetId) {
setMapSelectionQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
return null
}
setMapSelectionQaRunning(true)
setMapSelectionQaError(null)
setMapQaResult(null)
setLatestMapSelectionQualityCheckId(null)
try {
const request: QaComparisonRequest = {
candidate_dataset_id: candidateDataset.id,
reference_dataset_id: selectedMapQaReferenceDatasetId,
iou_threshold: 0.5,
area_id: candidateDataset.area_id ?? null,
}
const job: JobRead = await qaApi.runQa(selectedProjectId, request)
if (job.status === 'failed') {
setMapSelectionQaError(job.error_message || 'Map selection QA/QC failed')
return null
}
const payload = job.result_json
if (!payload || typeof payload !== 'object') {
setMapSelectionQaError('Map selection QA/QC result was not available')
return null
}
const parsed = payload as unknown as QaComparisonResult
if (!parsed || typeof parsed.status !== 'string') {
setMapSelectionQaError('Map selection QA/QC result format was unexpected')
return null
}
setMapQaResult(parsed)
setLatestMapSelectionQualityCheckId(parsed.quality_check_id ?? null)
await loadQualityChecks(selectedProjectId)
await loadProjectData(selectedProjectId)
return parsed
} catch (error) {
setMapSelectionQaError(formatError(error, 'Map selection QA/QC failed'))
return null
} finally {
setMapSelectionQaRunning(false)
}
}
return {
selectedMapQaReferenceDatasetId,
mapSelectionQaRunning,
mapSelectionQaError,
mapSelectionQaResult,
latestMapSelectionQualityCheckId,
runMapSelectionQa,
setSelectedMapQaReferenceDatasetId,
}
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { settleWithConcurrency } from './useMapThemeSelectionInsights'
describe('settleWithConcurrency', () => {
it('keeps result order and never exceeds the acquisition limit', async () => {
let active = 0
let maximumActive = 0
const results = await settleWithConcurrency([0, 1, 2, 3, 4, 5], 3, async (value) => {
active += 1
maximumActive = Math.max(maximumActive, active)
await new Promise((resolve) => setTimeout(resolve, (5 - value) * 2))
active -= 1
return value * 10
})
expect(maximumActive).toBe(3)
expect(results).toEqual([
{ status: 'fulfilled', value: 0 },
{ status: 'fulfilled', value: 10 },
{ status: 'fulfilled', value: 20 },
{ status: 'fulfilled', value: 30 },
{ status: 'fulfilled', value: 40 },
{ status: 'fulfilled', value: 50 },
])
})
it('retains individual acquisition failures without stopping the queue', async () => {
const results = await settleWithConcurrency(['ok', 'fail', 'later'], 2, async (value) => {
if (value === 'fail') {
throw new Error('provider unavailable')
}
return value.toUpperCase()
})
expect(results[0]).toEqual({ status: 'fulfilled', value: 'OK' })
expect(results[1].status).toBe('rejected')
expect(results[2]).toEqual({ status: 'fulfilled', value: 'LATER' })
})
})
@@ -0,0 +1,373 @@
import { useEffect, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { datasetsApi } from '../services/api/datasets'
import { aoiOperationsApi } from '../services/api/aoiOperations'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
import { bathymetryRasterSelectionToMapSelection } from '../lib/bathymetryRaster'
export type MapThemeAcquisitionKind =
| 'thematic_raster'
| 'walous'
| 'dhmv'
| 'spw_terrain'
| 'flood_hazard'
| 'grb'
| 'official_vector'
| 'bathymetry_profiles'
export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind
productKey: string
displayName: string
historyProductKeys?: string[]
coverageZone?: string
serverOrchestrated?: boolean
}
export interface MapThemeQuery<TThemeId extends string> {
themeId: TThemeId
dataset?: DatasetCreateResponse
datasetIds?: string[]
partitioned?: boolean
acquisition?: MapThemeAcquisition
acquisitionBboxes?: VectorSelectionBBox[]
featureLimit?: number
}
export interface MapThemeInsight<TThemeId extends string> {
themeId: TThemeId
dataset: DatasetCreateResponse
partitioned?: boolean
acquisition?: MapThemeAcquisition
result: VectorSelectionResponse
}
export async function settleWithConcurrency<T, TResult>(
items: T[],
concurrency: number,
task: (item: T, index: number) => Promise<TResult>,
): Promise<Array<PromiseSettledResult<TResult>>> {
const results = new Array<PromiseSettledResult<TResult>>(items.length)
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)))
let nextIndex = 0
const runWorker = async () => {
while (nextIndex < items.length) {
const index = nextIndex
nextIndex += 1
try {
results[index] = { status: 'fulfilled', value: await task(items[index], index) }
} catch (reason) {
results[index] = { status: 'rejected', reason }
}
}
}
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
return results
}
export function useMapThemeSelectionInsights<TThemeId extends string>(
selectedProjectId: string | null,
onDatasetsChanged?: () => Promise<unknown>,
) {
const [themeInsights, setThemeInsights] = useState<Array<MapThemeInsight<TThemeId>>>([])
const [themeInsightsLoading, setThemeInsightsLoading] = useState(false)
const [themeInsightsError, setThemeInsightsError] = useState<string | null>(null)
const requestSequence = useRef(0)
useEffect(() => {
requestSequence.current += 1
setThemeInsights([])
setThemeInsightsError(null)
setThemeInsightsLoading(false)
}, [selectedProjectId])
const clearThemeInsights = () => {
requestSequence.current += 1
setThemeInsights([])
setThemeInsightsError(null)
setThemeInsightsLoading(false)
}
const loadThemeInsights = async (
bbox: VectorSelectionBBox,
queries: Array<MapThemeQuery<TThemeId>>,
areaId?: string,
): Promise<Array<MapThemeInsight<TThemeId>>> => {
if (!selectedProjectId) {
setThemeInsights([])
setThemeInsightsError('Open eerst een project om de selectie te analyseren.')
return []
}
const sequence = requestSequence.current + 1
requestSequence.current = sequence
setThemeInsightsLoading(true)
setThemeInsightsError(null)
try {
const queryOrder = new Map(queries.map((query, index) => [query.themeId, index]))
const settled = await settleWithConcurrency(
queries,
3,
async ({ themeId, dataset: existingDataset, datasetIds, partitioned, acquisition, acquisitionBboxes, featureLimit }) => {
let dataset = existingDataset
let acquiredDatasets: DatasetCreateResponse[] = []
const resultLimit = featureLimit ?? 1000
if (acquisition) {
const requestedBboxes = acquisitionBboxes?.length ? acquisitionBboxes : [bbox]
if (acquisition.serverOrchestrated) {
let operation = await aoiOperationsApi.create(selectedProjectId, {
...(areaId ? { area_id: areaId } : { bbox }),
operation_type: 'acquire',
provider_key: acquisition.kind,
product_key: acquisition.productKey,
coverage_zone: acquisition.coverageZone,
max_attempts: 3,
parameters_json: { force_refresh: false },
})
const deadline = Date.now() + 20 * 60 * 1000
while (['queued', 'running'].includes(operation.status) && Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 1500))
operation = await aoiOperationsApi.get(selectedProjectId, operation.id)
}
if (operation.status !== 'success') {
throw new Error(operation.error_message || `De gebiedsverwerking eindigde als ${operation.status}.`)
}
const outputIds = Array.isArray(operation.result_json?.['output_dataset_ids'])
? operation.result_json['output_dataset_ids'].map(String)
: []
acquiredDatasets = await Promise.all(outputIds.map((datasetId) => datasetsApi.get(selectedProjectId, datasetId)))
dataset = acquiredDatasets[0]
}
const acquireProduct = async (acquisitionBbox: VectorSelectionBBox, productKey: string) => {
const commonPayload = {
bbox: acquisitionBbox,
area_id: areaId,
force_refresh: false,
}
const acquisitionJob = acquisition.kind === 'thematic_raster'
? await datasetsApi.acquireThematicRaster(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey,
})
: acquisition.kind === 'walous'
? await datasetsApi.acquireWalous(selectedProjectId, {
...commonPayload,
product_key: productKey,
})
: acquisition.kind === 'dhmv'
? await datasetsApi.acquireDhmv(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey as 'dtm_1m' | 'dsm_1m',
})
: acquisition.kind === 'spw_terrain'
? await datasetsApi.acquireSpwTerrain(selectedProjectId, {
...commonPayload,
product_key: 'spw_mnt_1m_2021_2022',
})
: acquisition.kind === 'flood_hazard'
? await datasetsApi.acquireFloodHazard(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey,
})
: acquisition.kind === 'grb'
? await datasetsApi.acquireGrb(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
})
: acquisition.kind === 'bathymetry_profiles'
? await datasetsApi.acquireBathymetryProfiles(selectedProjectId, commonPayload)
: await datasetsApi.acquireOfficialVector(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey,
})
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error(
acquisitionJob.error_message
|| `De officiële kaartbron ${acquisition.displayName} kon niet worden ingeladen.`,
)
}
return datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)
}
const acquisitionResults = acquisition.serverOrchestrated ? [] : await settleWithConcurrency(
requestedBboxes,
1,
(acquisitionBbox) => acquireProduct(acquisitionBbox, acquisition.productKey),
)
const failedAcquisition = acquisitionResults.find((item) => item.status === 'rejected')
if (failedAcquisition?.status === 'rejected') {
throw failedAcquisition.reason
}
if (!acquisition.serverOrchestrated) acquiredDatasets = acquisitionResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : [])
dataset = acquiredDatasets[0]
const historyProductKeys = acquisition.kind === 'walous'
? [...new Set(acquisition.historyProductKeys ?? [])].filter((key) => key !== acquisition.productKey)
: []
if (historyProductKeys.length > 0) {
const historyRequests = historyProductKeys.flatMap((productKey) =>
requestedBboxes.map((acquisitionBbox) => ({ acquisitionBbox, productKey })),
)
const historyResults = await settleWithConcurrency(
historyRequests,
1,
({ acquisitionBbox, productKey }) => acquireProduct(acquisitionBbox, productKey),
)
const failedHistory = historyResults.find((item) => item.status === 'rejected')
if (failedHistory?.status === 'rejected') {
throw failedHistory.reason
}
acquiredDatasets.push(
...historyResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []),
)
}
}
if (!dataset) {
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
}
const acquiredDatasetIds = acquiredDatasets.map((item) => item.id)
const selectedDatasetIds = acquiredDatasetIds.length > 0 ? acquiredDatasetIds : datasetIds ?? []
const acquiredAsPartitions = acquiredDatasetIds.length > 1
const result = dataset.dataset_type === 'raster' && ['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')
? terrainSelectionToMapSelection(
dataset.source_name === 'digitaal_vlaanderen_dhmv' && (partitioned || acquiredAsPartitions)
? await datasetsApi.selectTerrainPartitions(selectedProjectId, {
bbox,
area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'dtm_1m'),
...(selectedDatasetIds.length > 0 ? { dataset_ids: selectedDatasetIds } : {}),
})
: await datasetsApi.selectTerrain(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}),
)
: dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard'
? floodHazardSelectionToMapSelection(
partitioned || acquiredAsPartitions
? await datasetsApi.selectFloodHazardPartitions(selectedProjectId, {
bbox,
area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'pluviaal_current_t100'),
...(selectedDatasetIds.length > 0 ? { dataset_ids: selectedDatasetIds } : {}),
})
: await datasetsApi.selectFloodHazard(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}),
)
: dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster'
? thematicRasterSelectionToMapSelection(await datasetsApi.selectThematicRaster(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}))
: dataset.dataset_type === 'raster' && dataset.source_name === 'spw_walous_land_cover'
? thematicRasterSelectionToMapSelection(await datasetsApi.selectWalous(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}))
: dataset.dataset_type === 'raster' && dataset.source_name === 'spw_bathymetry'
? bathymetryRasterSelectionToMapSelection(await datasetsApi.selectBathymetryRaster(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}))
: selectedDatasetIds.length > 1 && dataset.dataset_type !== 'raster'
? await datasetsApi.selectVectorFeaturePartitions(selectedProjectId, {
dataset_ids: selectedDatasetIds,
bbox,
area_id: areaId,
limit: resultLimit,
})
: dataset.source_name === 'vmm_vha_bathymetry_profiles' && partitioned
? await datasetsApi.selectBathymetryProfilePartitions(selectedProjectId, {
bbox,
area_id: areaId,
limit: resultLimit,
})
: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
limit: resultLimit,
})
const insight: MapThemeInsight<TThemeId> = {
themeId,
dataset,
partitioned,
acquisition,
result,
}
if (requestSequence.current === sequence) {
setThemeInsights((current) => (
[...current.filter((item) => item.themeId !== themeId), insight]
.sort(
(left, right) => (queryOrder.get(left.themeId) ?? 0) - (queryOrder.get(right.themeId) ?? 0),
)
))
}
return insight
},
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
const failures = settled.flatMap((item, index) => (
item.status === 'rejected'
? [{
dataset:
queries[index]?.dataset?.name
?? queries[index]?.acquisition?.displayName
?? queries[index]?.themeId
?? 'Onbekende bron',
reason: formatError(item.reason, 'Bron kon niet worden bevraagd.'),
}]
: []
))
const failureCount = failures.length
if (requestSequence.current !== sequence) {
return []
}
setThemeInsights(successful)
let refreshFailure: string | null = null
if (successful.some((item) => item.acquisition) && onDatasetsChanged) {
try {
await onDatasetsChanged()
} catch (error) {
refreshFailure = formatError(error, 'De datasetlijst kon na de analyse niet worden vernieuwd.')
}
}
if (failureCount > 0) {
const details = failures
.slice(0, 4)
.map((failure) => `${failure.dataset}: ${failure.reason}`)
.join(' · ')
const remainder = failureCount > 4 ? ` · en ${failureCount - 4} andere` : ''
setThemeInsightsError(
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd. ${details}${remainder}`,
)
} else if (refreshFailure) {
setThemeInsightsError(refreshFailure)
}
return successful
} catch (error) {
if (requestSequence.current !== sequence) {
return []
}
setThemeInsights([])
setThemeInsightsError(formatError(error, 'De gebiedsanalyse is mislukt.'))
return []
} finally {
if (requestSequence.current === sequence) {
setThemeInsightsLoading(false)
}
}
}
return {
themeInsights,
themeInsightsLoading,
themeInsightsError,
loadThemeInsights,
clearThemeInsights,
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useMemo, useState } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../types'
interface MapWorkspaceStateOptions {
areas: AreaRead[]
changeDetectionGeoJson: GeoJSON.FeatureCollection | null
segmentationGeoJson: GeoJSON.FeatureCollection | null
detectionGeoJson: GeoJSON.FeatureCollection | null
datasetContent: GeoJSON.FeatureCollection | null
selectedDataset: DatasetCreateResponse | null
datasetLayerActive?: boolean
}
export function useMapWorkspaceState({
areas,
changeDetectionGeoJson,
segmentationGeoJson,
detectionGeoJson,
datasetContent,
selectedDataset,
datasetLayerActive = Boolean(datasetContent),
}: MapWorkspaceStateOptions) {
const [mapLayerVisible, setMapLayerVisible] = useState(true)
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
const [selectedMapAreaId, setSelectedMapAreaId] = useState('')
const [areaLayerVisible, setAreaLayerVisible] = useState(true)
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
useEffect(() => {
if (areas.length === 0) {
setSelectedMapAreaId('')
} else if (!selectedMapAreaId || !areas.some((area) => area.id === selectedMapAreaId)) {
const operationalScopeArea = areas.find((area) => /vervoerregio|operationele grens/i.test(area.name))
if (operationalScopeArea) {
setSelectedMapAreaId(operationalScopeArea.id)
} else {
const largestArea = [...areas].sort((left, right) => (right.area_m2 ?? 0) - (left.area_m2 ?? 0))[0]
if (largestArea?.area_m2) {
setSelectedMapAreaId(largestArea.id)
} else {
setSelectedMapAreaId(areas[0].id)
}
}
}
}, [areas, selectedMapAreaId])
const selectedMapArea = useMemo(
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
[areas, selectedMapAreaId],
)
const areaFeatureCollection = useMemo<GeoJSON.FeatureCollection | null>(() => {
if (!selectedMapArea?.geometry) {
return null
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: selectedMapArea.geometry,
properties: {
layer_type: 'project_area',
area_id: selectedMapArea.id,
name: selectedMapArea.name,
area_m2: selectedMapArea.area_m2 ?? null,
original_crs: selectedMapArea.original_crs ?? null,
},
},
],
}
}, [selectedMapArea])
const mapFeatureCollection = useMemo(
() => changeDetectionGeoJson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
[changeDetectionGeoJson, segmentationGeoJson, detectionGeoJson, datasetContent],
)
const mapLayerLabel = useMemo(() => {
if (changeDetectionGeoJson) {
return 'Veranderingsanalyse'
}
if (segmentationGeoJson) {
return 'AI-segmentaties'
}
if (detectionGeoJson) {
return 'AI-detecties'
}
if (datasetLayerActive && selectedDataset) {
return selectedDataset.name
}
return 'Geen actieve kaartlaag'
}, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features?.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features?.length ?? 0
useEffect(() => {
setSelectedMapFeature(null)
}, [mapFeatureCollection, areaFeatureCollection])
return {
mapLayerVisible,
mapLayerOpacity,
selectedMapAreaId,
areaLayerVisible,
areaLayerOpacity,
selectedMapFeature,
areaFeatureCollection,
mapFeatureCollection,
mapLayerLabel,
mapFeatureCount,
areaFeatureCount,
setMapLayerVisible,
setMapLayerOpacity,
setSelectedMapAreaId,
setAreaLayerVisible,
setAreaLayerOpacity,
setSelectedMapFeature,
}
}
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from 'react'
import { datasetsApi, externalApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type {
BathymetrySourceRead,
CoverageResolveResponse,
DhmvProductRead,
SpwTerrainProductRead,
FloodHazardProductRead,
GrbProductRead,
OfficialVectorProductRead,
ThematicRasterProductRead,
VectorSelectionBBox,
} from '../types'
export interface OfficialMapProducts {
thematic: ThematicRasterProductRead[]
walous: ThematicRasterProductRead[]
dhmv: DhmvProductRead[]
spwTerrain: SpwTerrainProductRead[]
floodHazard: FloodHazardProductRead[]
grb: GrbProductRead[]
officialVector: OfficialVectorProductRead[]
bathymetry: BathymetrySourceRead[]
}
const EMPTY_PRODUCTS: OfficialMapProducts = {
thematic: [],
walous: [],
dhmv: [],
spwTerrain: [],
floodHazard: [],
grb: [],
officialVector: [],
bathymetry: [],
}
export function useOfficialMapProducts(selectedProjectId: string | null) {
const [products, setProducts] = useState<OfficialMapProducts>(EMPTY_PRODUCTS)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
if (!selectedProjectId) {
setProducts(EMPTY_PRODUCTS)
setLoading(false)
setError(null)
return () => {
cancelled = true
}
}
setLoading(true)
setError(null)
void Promise.all([
datasetsApi.listThematicRasterProducts(selectedProjectId),
datasetsApi.listWalousProducts(selectedProjectId),
datasetsApi.listDhmvProducts(selectedProjectId),
datasetsApi.listSpwTerrainProducts(selectedProjectId),
datasetsApi.listFloodHazardProducts(selectedProjectId),
datasetsApi.listGrbProducts(selectedProjectId),
datasetsApi.listOfficialVectorProducts(selectedProjectId),
datasetsApi.listBathymetrySources(selectedProjectId),
])
.then(([thematic, walous, dhmv, spwTerrain, floodHazard, grb, officialVector, bathymetry]) => {
if (!cancelled) {
setProducts({
thematic: thematic.items,
walous: walous.items,
dhmv: dhmv.items,
spwTerrain: spwTerrain.items,
floodHazard: floodHazard.items,
grb: grb.items,
officialVector: officialVector.items,
bathymetry: bathymetry.items,
})
}
})
.catch((requestError) => {
if (!cancelled) {
setProducts(EMPTY_PRODUCTS)
setError(formatError(requestError, 'De officiële regionale kaartcatalogi konden niet worden geladen.'))
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [selectedProjectId])
const resolveCoverage = useCallback(
async (bbox: { minx: number; miny: number; maxx: number; maxy: number }) => {
if (!selectedProjectId) {
setError('Selecteer eerst een werkruimte.')
return null
}
try {
const coverage = await externalApi.resolveCoverage({
projectId: selectedProjectId,
bbox,
})
setError(null)
return coverage
} catch (requestError) {
setError(formatError(requestError, 'De regionale databronnen konden niet veilig worden bepaald.'))
return null
}
},
[selectedProjectId],
)
const resolveCoveragePartitions = useCallback(
async (bboxes: VectorSelectionBBox[]): Promise<Array<{
bbox: VectorSelectionBBox
coverage: CoverageResolveResponse
}> | null> => {
if (!selectedProjectId) {
setError('Selecteer eerst een werkruimte.')
return null
}
try {
const resolved: Array<{
bbox: VectorSelectionBBox
coverage: CoverageResolveResponse
}> = []
for (let offset = 0; offset < bboxes.length; offset += 4) {
const batch = bboxes.slice(offset, offset + 4)
resolved.push(...await Promise.all(batch.map(async (bbox) => ({
bbox,
coverage: await externalApi.resolveCoverage({
projectId: selectedProjectId,
bbox: {
minx: bbox.min_x,
miny: bbox.min_y,
maxx: bbox.max_x,
maxy: bbox.max_y,
},
}),
}))))
}
setError(null)
return resolved
} catch (requestError) {
setError(formatError(
requestError,
'De regionale dekking kon niet voor alle bronpartities worden bepaald.',
))
return null
}
},
[selectedProjectId],
)
return { products, loading, error, resolveCoverage, resolveCoveragePartitions }
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react'
import { formatAuthError } from '../lib/authError'
import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
import { vergeetGedeeldeVerzoeken } from '../services/api/client'
const signedOutSession: AuthSession = {
authentication_required: true,
authenticated: false,
username: null,
expires_at: null,
role: null,
guest_access_enabled: false,
authentik_enabled: false,
guest_project_id: null,
}
export function useOperatorSession() {
const [session, setSession] = useState<AuthSession | null>(null)
const [sessionError, setSessionError] = useState<string | null>(null)
const [loggingOut, setLoggingOut] = useState(false)
useEffect(() => {
let active = true
getAuthSession()
.then((value) => {
if (active) {
setSession(value)
setSessionError(null)
}
})
.catch((error) => {
if (active) {
setSession(signedOutSession)
setSessionError(formatAuthError(error, 'De aanmeldservice is tijdelijk niet bereikbaar. Probeer het over enkele ogenblikken opnieuw.'))
}
})
return () => {
active = false
}
}, [])
useEffect(() => {
const expireSession = () => {
setSession((current) => ({
...signedOutSession,
guest_access_enabled: current?.guest_access_enabled ?? false,
authentik_enabled: current?.authentik_enabled ?? false,
}))
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
}
window.addEventListener('geointel:session-expired', expireSession)
return () => window.removeEventListener('geointel:session-expired', expireSession)
}, [])
const handleLogout = async () => {
setLoggingOut(true)
try {
setSession(await logout())
// Geen gedeelde antwoorden meenemen naar de volgende gebruiker.
vergeetGedeeldeVerzoeken()
setSessionError(null)
} catch (error) {
setSessionError(formatAuthError(error, 'Uitloggen is niet gelukt. Vernieuw de pagina en probeer opnieuw.'))
} finally {
setLoggingOut(false)
}
}
const handleAuthenticated = (authenticatedSession: AuthSession) => {
vergeetGedeeldeVerzoeken()
setSession(authenticatedSession)
setSessionError(null)
}
return { session, sessionError, loggingOut, handleAuthenticated, handleLogout }
}
+287
View File
@@ -0,0 +1,287 @@
import { FormEvent, useMemo, useRef, useState } from 'react'
import {
NATIONAL_WORKSPACE_PROJECT_NAME,
NATIONAL_WORKSPACE_REGION,
REGIONAL_WORKSPACE_PROJECT_NAME,
} from '../config/primaryFocus'
import { areasApi } from '../services/api/areas'
import { datasetsApi } from '../services/api/datasets'
import { projectsApi } from '../services/api/projects'
import type {
AreaCreate,
AreaListResponse,
AreaRead,
DatasetCreateResponse,
DatasetListResponse,
ProjectCreate,
ProjectRead,
} from '../types'
const DEMO_PROJECT_NAME = 'GeoIntel Demo - Building QA'
function isDemoProject(project: ProjectRead): boolean {
return project.name === DEMO_PROJECT_NAME
}
function hasMappedAnalysisContext(
data: { areas: AreaRead[]; datasets: DatasetCreateResponse[] },
): boolean {
const hasRasterDataset = data.datasets.some((dataset) => dataset.dataset_type === 'raster')
const hasVectorDataset = data.datasets.some(
(dataset) => dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson',
)
return data.areas.length > 0 && hasRasterDataset && hasVectorDataset
}
export function useProjectWorkspace() {
const [projects, setProjects] = useState<ProjectRead[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [areas, setAreas] = useState<AreaRead[]>([])
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
const [loadingProjects, setLoadingProjects] = useState(false)
const [loadingAreas, setLoadingAreas] = useState(false)
const [loadingDatasets, setLoadingDatasets] = useState(false)
const [archivingProjectId, setArchivingProjectId] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const projectDataRequestSequence = useRef(0)
const [projectForm, setProjectForm] = useState<ProjectCreate>({
name: '',
description: '',
region: NATIONAL_WORKSPACE_REGION,
})
const [areaForm, setAreaForm] = useState({
name: '',
geometry: '',
crs: 'EPSG:4326',
})
const selectedProject = useMemo(
() => projects.find((project) => project.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
)
const fetchProjectData = async (projectId: string): Promise<{ areas: AreaRead[]; datasets: DatasetCreateResponse[] }> => {
const [areaResponse, datasetResponse]: [AreaListResponse, DatasetListResponse] = await Promise.all([
areasApi.list(projectId),
datasetsApi.list(projectId),
])
return { areas: areaResponse.items, datasets: datasetResponse.items }
}
const pickInitialProjectId = async (items: ProjectRead[], preferredProjectId?: string | null) => {
if (preferredProjectId && items.some((project) => project.id === preferredProjectId)) {
return preferredProjectId
}
if (selectedProjectId && items.some((project) => project.id === selectedProjectId)) {
return selectedProjectId
}
const nationalProject = items.find((project) => project.name === NATIONAL_WORKSPACE_PROJECT_NAME)
if (nationalProject) {
return nationalProject.id
}
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
if (regionalProject) {
try {
const data = await fetchProjectData(regionalProject.id)
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
return regionalProject.id
}
} catch {
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
}
}
const demoProjects = items.filter(isDemoProject)
const candidates = Array.from(
new Map(
[...items, ...demoProjects].map((project) => [project.id, project]),
).values(),
).slice(0, 12)
const inspectedCandidates: Array<{
project: ProjectRead
data: { areas: AreaRead[]; datasets: DatasetCreateResponse[] }
}> = []
for (const project of candidates) {
try {
const data = await fetchProjectData(project.id)
inspectedCandidates.push({ project, data })
if (hasMappedAnalysisContext(data)) {
return project.id
}
} catch {
// Project list should still render if a candidate's detail endpoints are temporarily unavailable.
}
}
const completeContext = inspectedCandidates.find(
({ data }) => data.areas.length > 0 && data.datasets.length > 0,
)
return completeContext?.project.id ?? demoProjects[0]?.id ?? items[0]?.id ?? null
}
const loadProjects = async (preferredProjectId?: string | null) => {
setLoadingProjects(true)
setErrorMessage(null)
try {
const [response, nationalResponse, canonicalResponse] = await Promise.all([
projectsApi.list(),
projectsApi.list({ name: NATIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
])
const regionalItems = [...canonicalResponse.items, ...response.items]
const items = Array.from(
new Map(
[...nationalResponse.items, ...regionalItems].map((project) => [project.id, project]),
).values(),
)
setProjects(items)
const nextProjectId = await pickInitialProjectId(items, preferredProjectId)
if (nextProjectId && nextProjectId !== selectedProjectId) {
setSelectedProjectId(nextProjectId)
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Werkruimtes konden niet worden geladen')
} finally {
setLoadingProjects(false)
}
}
const loadProjectData = async (projectId: string) => {
const requestId = ++projectDataRequestSequence.current
setLoadingAreas(true)
setLoadingDatasets(true)
setErrorMessage(null)
try {
const projectData = await fetchProjectData(projectId)
if (requestId !== projectDataRequestSequence.current) {
return null
}
setAreas(projectData.areas)
setDatasets(projectData.datasets)
return projectData
} catch (error) {
if (requestId === projectDataRequestSequence.current) {
setErrorMessage(error instanceof Error ? error.message : 'De gegevens van de werkruimte konden niet worden geladen')
}
return null
} finally {
if (requestId === projectDataRequestSequence.current) {
setLoadingAreas(false)
setLoadingDatasets(false)
}
}
}
const createProject = async (event: FormEvent) => {
event.preventDefault()
if (!projectForm.name.trim()) {
setErrorMessage('Geef een naam voor de werkruimte op')
return
}
try {
const createdProject = await projectsApi.create({
name: projectForm.name.trim(),
description: projectForm.description?.trim() || undefined,
region: projectForm.region?.trim() || NATIONAL_WORKSPACE_REGION,
})
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
setSelectedProjectId(createdProject.id)
await loadProjects(createdProject.id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden aangemaakt')
}
}
const createArea = async (event: FormEvent) => {
event.preventDefault()
if (!selectedProjectId) {
setErrorMessage('Kies eerst een werkruimte')
return
}
let geometry: AreaCreate['geometry']
try {
geometry = JSON.parse(areaForm.geometry) as AreaCreate['geometry']
} catch {
setErrorMessage('De opgegeven GeoJSON-geometrie is ongeldig')
return
}
try {
await areasApi.create(selectedProjectId, {
name: areaForm.name,
crs: areaForm.crs,
geometry,
})
await loadProjectData(selectedProjectId)
setAreaForm((previous) => ({ ...previous, name: '' }))
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Het gebied kon niet worden aangemaakt')
}
}
const activateMunicipality = async (niscode: string): Promise<AreaRead | null> => {
if (!selectedProjectId) {
setErrorMessage('Kies eerst een werkruimte')
return null
}
setLoadingAreas(true)
setErrorMessage(null)
try {
const area = await areasApi.activateMunicipality(selectedProjectId, niscode)
await loadProjectData(selectedProjectId)
return area
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De gemeente kon niet als werkgebied worden geladen')
return null
} finally {
setLoadingAreas(false)
}
}
const archiveProject = async (projectId: string) => {
setArchivingProjectId(projectId)
setErrorMessage(null)
try {
await projectsApi.update(projectId, { status: 'archived' })
if (selectedProjectId === projectId) {
setSelectedProjectId(null)
resetProjectData()
}
await loadProjects()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden gearchiveerd')
} finally {
setArchivingProjectId(null)
}
}
const resetProjectData = () => {
projectDataRequestSequence.current += 1
setAreas([])
setDatasets([])
}
return {
projects,
selectedProject,
selectedProjectId,
areas,
datasets,
loadingProjects,
loadingAreas,
loadingDatasets,
archivingProjectId,
errorMessage,
projectForm,
areaForm,
loadProjects,
loadProjectData,
createProject,
createArea,
activateMunicipality,
archiveProject,
resetProjectData,
setSelectedProjectId,
setErrorMessage,
setProjectForm,
setAreaForm,
}
}
Loaded 100 of 764 files, more files were not shown because too many files have changed in this diff. Show more