Publish curated GeoIntel source snapshot
This commit is contained in:
commit
0aae654d6e
764 files changed
+169154
No files matched your search
Whitespace-only changes.
@@ -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
Whitespace-only changes.
Whitespace-only changes.
@@ -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 & 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'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 bovenliggende 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user