import { useEffect, useMemo, useState } from 'react' import GeoMap from '../GeoMap' import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types' import { featureCollectionBounds } from '../../lib/geojsonBounds' import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights' import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = [] const MOL_PROJECT_NAME = 'Mol Municipality Workbench' const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench' type DataThemeId = 'buildings' | 'population' | 'forest' | 'water' | 'roads' | 'parcels' interface DataTheme { id: DataThemeId label: string shortLabel: string description: string tokens: string[] } interface TemporalSeriesGroup { key: string label: string items: DatasetCreateResponse[] } const DATA_THEMES: DataTheme[] = [ { 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: '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: 'water', label: 'Water', shortLabel: 'Water', description: 'Waterlopen, grachten, kanalen en wateroppervlakken.', tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'], }, { 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'], }, ] const DATA_THEME_MAP_STYLES: Record = { buildings: { fill: '#d45f3d', line: '#9f3e24' }, population: { fill: '#7559a6', line: '#5b3f88' }, forest: { fill: '#347950', line: '#225f3b' }, water: { fill: '#2676a8', line: '#155b85' }, roads: { fill: '#6b7280', line: '#4b5563' }, parcels: { fill: '#a7792f', line: '#7d571f' }, } 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'], ] .filter(Boolean) .join(' ') .toLowerCase() } function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean { const searchText = datasetSearchText(dataset) return theme.tokens.some((token) => searchText.includes(token)) } function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme): DatasetCreateResponse | null { const candidates = datasets.filter((dataset) => datasetMatchesTheme(dataset, theme)) candidates.sort((left, right) => { const score = (dataset: DatasetCreateResponse) => (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.dataset_role === 'reference' ? 10_000 : 0) + (dataset.observed_at ? new Date(dataset.observed_at).getTime() / 100_000_000 : 0) + (dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0) return score(right) - score(left) }) return candidates[0] ?? null } 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?.source_name ?? first?.source ?? 'Tijdreeks' const firstYear = first?.observed_at ? new Date(first.observed_at).getUTCFullYear() : null const last = items[items.length - 1] const lastYear = last?.observed_at ? new Date(last.observed_at).getUTCFullYear() : null return firstYear && lastYear ? `${source} (${firstYear}-${lastYear})` : source } function listThemeTemporalSeries(datasets: DatasetCreateResponse[], theme: DataTheme): TemporalSeriesGroup[] { const groups = new Map() for (const dataset of datasets) { if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) { continue } const items = groups.get(dataset.temporal_series_key) ?? [] items.push(dataset) groups.set(dataset.temporal_series_key, items) } return Array.from(groups.entries()) .filter(([, items]) => items.length >= 2) .map(([key, items]) => { const ordered = [...items].sort( (left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(), ) return { key, label: temporalSeriesLabel(ordered), items: ordered } }) .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) }) } 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)) } function operationalScopeProjectLabel(project: ProjectRead): string { if (project.name === MOL_PROJECT_NAME) { return 'Mol' } if (project.name === KEMPEN_PROJECT_NAME) { return 'Kempen (28 gemeenten)' } return project.name } 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) } 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 ) } 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` } 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') } 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}` } function formatTemporalMetric(value: number, unit: string): string { const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2 return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}` } 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/a' } function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) { const points = collectGeometryPoints(feature?.geometry) if (!feature?.geometry || points.length === 0) { return { bboxLabel: 'n/a', coordinateCount: 0, geometryType: feature?.geometry?.type ?? 'none', } } 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, } } 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', } } 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', } } 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', } } function formatBboxLabel(bbox: VectorSelectionBBox | null): string { if (!bbox) { return 'n/a' } return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}` } 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) : '', } } function parseBboxInput(input: ReturnType): 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' } } function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: [feature], } } 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) } function copyText(text: string): void { if (navigator.clipboard?.writeText) { void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text)) return } fallbackCopyText(text) } function downloadJsonFile(filename: string, payload: unknown): void { const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/geo+json' }) 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) } interface MapWorkspaceProps { 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 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 availableMapDatasets: DatasetCreateResponse[] selectedMapDatasetId: string onSelectMapArea: (areaId: string) => 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 onClearMapSelectionExtract: () => void onExportMapSelection: (bbox: VectorSelectionBBox) => Promise onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => Promise onSelectMapQaReferenceDataset: (datasetId: string) => void onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise onOpenMapSelectionQualityEvidence: () => void onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise onClearQualityEvidence?: () => void } export function MapWorkspace({ selectedProjectId, projects, areas, selectedMapAreaId, areaFeatureCollection, mapFeatureCollection, qualityEvidenceGeoJson = null, qualityEvidenceFeatureCount = 0, qualityEvidenceLoading = false, qualityEvidenceError = null, qualityEvidenceWarnings = [], mapLayerLabel, mapLayerSourceLabel, mapLayerProvenance, mapLayerVisible, mapLayerOpacity, areaLayerVisible, areaLayerOpacity, mapFeatureCount, areaFeatureCount, viewportVectorEnabled, viewportVectorStatus, viewportVectorTone, fitMapDataOnChange, mapContentMode, analysisLayerAvailable, selectedMapFeature, selectedFeature = selectedMapFeature, mapSelectionBbox, mapSelectionResult, mapSelectionLoading, mapSelectionError, selectionExporting, selectionExportError, latestSelectionExportPath, selectionDatasetSaving, selectionDatasetError, latestSelectionDataset, latestSelectionDatasetName, mapQaReferenceDatasets, selectedMapQaReferenceDatasetId, mapSelectionQaRunning, mapSelectionQaError, mapSelectionQaResult, latestMapSelectionQualityCheckId, orthophotoAnalysisStage, orthophotoAnalysisStatus, orthophotoAnalysisError, orthophotoAnalysisRunning, availableMapDatasets, selectedMapDatasetId, onSelectMapArea, onOpenDatasetInMap, onSetAreaLayerVisible, onSetAreaLayerOpacity, onSetMapLayerVisible, onSetMapLayerOpacity, onSetMapContentMode, onSelectMapFeature, onMapViewportChange, onSetMapSelectionBbox, onRunMapSelectionExtract, onClearMapSelectionExtract, onExportMapSelection, onDeriveMapSelectionDataset, onSelectMapQaReferenceDataset, onRunMapSelectionQa, onOpenMapSelectionQualityEvidence, onRunOrthophotoAnalysis, onClearQualityEvidence, }: MapWorkspaceProps): JSX.Element { const [advancedMode, setAdvancedMode] = useState(false) const [activeThemeId, setActiveThemeId] = useState('buildings') const { themeInsights, themeInsightsLoading: themeResultsLoading, themeInsightsError: themeResultsError, loadThemeInsights, clearThemeInsights, } = useMapThemeSelectionInsights(selectedProjectId) const { temporalComparison, temporalComparisonLoading, temporalComparisonError, compareTemporalSnapshots, clearTemporalComparison, } = useTemporalComparison(selectedProjectId) const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current') const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('') const [earlierDatasetId, setEarlierDatasetId] = useState('') const [laterDatasetId, setLaterDatasetId] = useState('') const [bboxSelectionMode, setBboxSelectionMode] = useState(false) const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox)) const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false) const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Ready to run persisted GIS workflow.') const [fullWorkflowError, setFullWorkflowError] = useState(null) const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new') const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) const featureProperties = selectedMapFeature?.properties ?? null const featureSummaryEntries = featureProperties ? Object.entries(featureProperties) .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object') .slice(0, 6) : [] const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : [] const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature) const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null const selectedFeatureBbox = useMemo(() => getFeatureBBox(selectedMapFeature), [selectedMapFeature]) const activeLayerBbox = useMemo(() => getFeatureCollectionBBox(mapFeatureCollection), [mapFeatureCollection]) const selectedAreaBbox = useMemo(() => getFeatureCollectionBBox(areaFeatureCollection), [areaFeatureCollection]) const currentSelectionBbox = parseBboxInput(bboxInput) const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? [] const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12) const selectedFeatureStem = safeFileStem( featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature', ) const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson` const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL const themeDatasetMap = useMemo( () => Object.fromEntries( DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme)]), ) as Record, [availableMapDatasets], ) const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id] const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection) const activeThemeDataset = themeDatasetMap[activeTheme.id] const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length const activeTemporalSeriesGroups = useMemo( () => listThemeTemporalSeries(availableMapDatasets, activeTheme), [activeTheme, availableMapDatasets], ) const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey) ?? activeTemporalSeriesGroups[0] const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES const themeResults = useMemo( () => themeInsights.flatMap((insight) => { const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId) return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : [] }), [themeInsights], ) const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result ?? mapSelectionResult const selectedAreaSquareMetres = useMemo( () => bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2 ? selectedMapArea.area_m2 : selectionAreaSquareMetres(mapSelectionBbox), [mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2], ) const selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0 const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0 ? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000) : null const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten' const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel const activeSecondaryMetric = selectedAreaSquareMetres && selectedAreaSquareMetres > 0 ? activeMetricUnit === 'ha' ? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking` : `${(activeMetricValue / (selectedAreaSquareMetres / 1_000_000)).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${activeMetricUnit} / km2` : null const selectedResultProperties = useMemo(() => { const keys = new Map>() for (const feature of activeSelectionResult?.geojson.features ?? []) { for (const [key, value] of Object.entries(feature.properties ?? {})) { if (value === null || value === undefined || typeof value === 'object' || key.endsWith('_id')) { continue } const values = keys.get(key) ?? new Set() if (values.size < 4) { values.add(String(value)) } keys.set(key, values) } } return Array.from(keys.entries()) .filter(([, values]) => values.size > 0) .slice(0, 8) .map(([key, values]) => ({ key, values: Array.from(values) })) }, [activeSelectionResult]) useEffect(() => { setBboxInput(bboxToInputState(mapSelectionBbox)) }, [mapSelectionBbox]) useEffect(() => { setSelectedTemporalSeriesKey((current) => activeTemporalSeriesGroups.some((group) => group.key === current) ? current : activeTemporalSeriesGroups[0]?.key ?? '', ) }, [activeTemporalSeriesGroups]) useEffect(() => { const first = activeTemporalSeries[0] const last = activeTemporalSeries[activeTemporalSeries.length - 1] setEarlierDatasetId(first?.id ?? '') setLaterDatasetId(last?.id ?? '') clearTemporalComparison() }, [activeTemporalSeries]) useEffect(() => { if (advancedMode || !activeThemeDataset || (selectedMapDataset && datasetMatchesTheme(selectedMapDataset, activeTheme))) { return } onOpenDatasetInMap(activeThemeDataset) }, [activeTheme, activeThemeDataset, advancedMode, onOpenDatasetInMap, selectedMapDataset]) useEffect(() => { if (!selectedMapDataset) { return } const matchingTheme = DATA_THEMES.find((theme) => datasetMatchesTheme(selectedMapDataset, theme)) if (matchingTheme) { setActiveThemeId(matchingTheme.id) } }, [selectedMapDataset]) const downloadSelectedMapFeature = () => { if (!selectedFeatureGeoJson) { return } downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson) } const copySelectedMapFeatureProperties = () => { copyText(JSON.stringify(featureProperties ?? {}, null, 2)) } const setSelectionBbox = (bbox: VectorSelectionBBox | null) => { onSetMapSelectionBbox(bbox) setBboxInput(bboxToInputState(bbox)) } const startBboxSelection = () => { setFirstSelectionCorner(null) clearThemeInsights() clearTemporalComparison() setBboxSelectionMode(true) } const handleMapCoordinateSelect = (coordinate: [number, number]) => { if (!firstSelectionCorner) { setFirstSelectionCorner(coordinate) return } const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate) setSelectionBbox(bbox) setFirstSelectionCorner(null) setBboxSelectionMode(false) void analyzeSelection(bbox) } const runAreaExtract = () => { const bbox = parseBboxInput(bboxInput) if (!bbox) { return } void analyzeSelection(bbox) } const clearAreaSelection = () => { setBboxSelectionMode(false) setFirstSelectionCorner(null) setBboxInput(bboxToInputState(null)) clearThemeInsights() clearTemporalComparison() onClearMapSelectionExtract() } const handleSelectMapArea = (areaId: string) => { clearAreaSelection() onSelectMapArea(areaId) } const downloadAreaSelection = () => { if (!mapSelectionResult) { return } downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson) } const copyAreaSelection = () => { copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) } const downloadActiveThemeSelection = () => { if (!activeSelectionResult) { return } downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson) } const copyActiveThemeSelection = () => { copyText(JSON.stringify(activeSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) } const saveAreaSelectionExport = () => { const bbox = parseBboxInput(bboxInput) if (!bbox) { return } onExportMapSelection(bbox) } const saveAreaSelectionDataset = () => { const bbox = parseBboxInput(bboxInput) if (!bbox) { return } onDeriveMapSelectionDataset(bbox) } const openSelectedDatabaseLayer = (datasetId: string) => { const dataset = availableMapDatasets.find((item) => item.id === datasetId) if (dataset) { onOpenDatasetInMap(dataset) } } const selectDataTheme = (theme: DataTheme) => { const dataset = themeDatasetMap[theme.id] if (!dataset) { return } setActiveThemeId(theme.id) clearTemporalComparison() onOpenDatasetInMap(dataset) } const setExplorerMode = (mode: 'current' | 'evolution') => { setAnalysisMode(mode) clearTemporalComparison() } const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const availableThemes = DATA_THEMES.flatMap((theme) => { const dataset = themeDatasetMap[theme.id] return dataset ? [{ themeId: theme.id, dataset }] : [] }) await loadThemeInsights(bbox, availableThemes, areaId) } const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { setSelectionBbox(bbox) const tasks: Array> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)] if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox)) } await Promise.all(tasks) } const runTemporalComparison = () => { if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) { return } void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox) } const handleMapBboxPreview = (bbox: VectorSelectionBBox) => { setSelectionBbox(bbox) } const handleMapBboxSelect = (bbox: VectorSelectionBBox) => { setFirstSelectionCorner(null) setBboxSelectionMode(false) void analyzeSelection(bbox) } const runQuickAoiExtract = () => { const bbox = selectedAreaBbox ?? activeLayerBbox if (!bbox) { return } setSelectionBbox(bbox) void analyzeSelection(bbox, selectedAreaBbox ? selectedMapArea?.id : undefined) } const runFullGisWorkflow = async () => { const bbox = currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox if (fullWorkflowMode === 'reuse') { if (!latestSelectionDataset) { setFullWorkflowError('Save a map selection dataset before reusing the latest result.') return } if (!selectedMapQaReferenceDatasetId) { setFullWorkflowError('Select a reference dataset before reusing the latest result for QA/QC.') return } setFullWorkflowRunning(true) setFullWorkflowError(null) try { setFullWorkflowStatus('Reusing latest saved dataset for QA/QC...') const qaResult = await onRunMapSelectionQa(latestSelectionDataset) setFullWorkflowStatus(qaResult ? 'Reused latest saved dataset and completed QA/QC.' : 'Latest saved dataset reused, but QA/QC did not complete.') } catch (error) { setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.') setFullWorkflowStatus('Workflow stopped.') } finally { setFullWorkflowRunning(false) } return } if (!selectedMapDataset || !bbox) { setFullWorkflowError('Select a database layer and AOI/layer extent before running the full workflow.') return } setFullWorkflowRunning(true) setFullWorkflowError(null) try { setFullWorkflowStatus('1/4 Querying persisted vector_features...') setSelectionBbox(bbox) const selection = await onRunMapSelectionExtract(bbox) if (!selection) { setFullWorkflowError('Persisted vector query did not complete.') setFullWorkflowStatus('Stopped at query.') return } setFullWorkflowStatus('2/4 Saving derived result dataset...') const derived = await onDeriveMapSelectionDataset(bbox) if (!derived) { setFullWorkflowError('Derived result dataset was not created.') setFullWorkflowStatus('Stopped at dataset save.') return } setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...') await onExportMapSelection(bbox) if (selectedMapQaReferenceDatasetId) { setFullWorkflowStatus('4/4 Running QA/QC against selected reference...') const qaResult = await onRunMapSelectionQa(derived) setFullWorkflowStatus(qaResult ? 'Full GIS workflow complete with QA/QC result.' : 'Dataset/export complete; QA/QC did not complete.') } else { setFullWorkflowStatus('Dataset/export complete. Select a reference dataset to add QA/QC.') } } catch (error) { setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.') setFullWorkflowStatus('Workflow stopped.') } finally { setFullWorkflowRunning(false) } } if (!advancedMode) { return (

{activeScopeLabel} · geografische verkenner

Wat bevindt zich in dit gebied?

Kies een datathema, teken een rechthoek en lees de beschikbare gegevens meteen uit.

2

Selecteer een gebied

{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer het volledige werkgebied.'}

Werkgebied {analysisOverlayActive ? ( <> Gevonden gebouwen Selectie ) : analysisMode === 'evolution' && temporalComparison?.object_changes.available ? ( <> Nieuw Verdwenen Gewijzigd ) : ( <> {activeTheme.shortLabel} Selectie )}
{bboxSelectionMode ? (
Rechthoek tekenen Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los.
) : null} {viewportVectorEnabled && viewportVectorStatus ? (
{viewportVectorStatus}
) : null}
Werkgebied: {selectedMapArea?.name ?? 'Geen werkgebied geselecteerd'} Bron:{' '} {analysisOverlayActive ? `${mapLayerLabel} · ${mapLayerSourceLabel}` : analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks' : activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'niet beschikbaar'} {usesDefaultOsmBasemap ? Ondergrond: OpenStreetMap : null}
) } return (

Spatial review

Map workspace

{mapFeatureCollection ? `${mapFeatureCount} features` : viewportVectorEnabled ? 'zoom to load' : 'no layer'}
{usesDefaultOsmBasemap ? (
Local/demo basemap Public OpenStreetMap tiles are active. Configure VITE_MAP_STYLE_URL for production or heavier use.
) : null}
Map content
onSetAreaLayerOpacity(Number(event.target.value))} data-testid="map-area-opacity" />
onSetMapLayerOpacity(Number(event.target.value))} data-testid="map-layer-opacity" />
{mapLayerLabel} {selectedMapDataset ? `DB layer: ${selectedMapDataset.name}` : 'No database layer selected'} {areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'} {mapFeatureCollection ? `${mapFeatureCount} features loaded` : viewportVectorEnabled ? 'Database layer selected; visible features load by viewport' : 'No vector/result layer loaded'} {viewportVectorEnabled && viewportVectorStatus ? ( {viewportVectorStatus} ) : null}
Layer details {mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport layer selected' : 'No active layer'}
Area of interest {selectedMapArea?.name ?? 'No area selected'} {areaFeatureCollection ? `${areaFeatureCount} AOI features loaded` : 'AOI overlay disabled'}
Active layer {mapLayerLabel} {mapLayerSourceLabel}
Feature state {mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Awaiting viewport detail' : 'No layer rendered'} {mapLayerProvenance}
QA/QC evidence {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} evidence features` : 'No evidence overlay'} {qualityEvidenceLoading ? 'Loading persisted evidence' : 'Matches, false positives and false negatives'}
Layer source {mapLayerSourceLabel}
Provenance {mapLayerProvenance}
Draw state {mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport delivery active' : 'No active vector or result layer'}
QA evidence overlay {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} rendered` : 'off'}
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
QA/QC evidence overlay {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} persisted features` : 'Not loaded'} {qualityEvidenceError ?

{qualityEvidenceError}

: null} {qualityEvidenceWarnings.length > 0 ? (

{qualityEvidenceWarnings.length} evidence id{qualityEvidenceWarnings.length === 1 ? '' : 's'} could not be resolved.

) : null}
Match candidate Match reference False positive False negative
{onClearQualityEvidence ? ( ) : null}
) : null} {!mapFeatureCollection && !viewportVectorEnabled ? (
No active vector or result layer

Open a dataset, detection run, segmentation run or change result to draw it here.

{availableMapDatasets.length > 0 ? ( <>

Open a ready vector dataset

{availableMapDatasets.map((dataset) => ( ))}
) : (

No ready vector datasets available yet. Upload or seed a vector dataset first.

)}
) : null}

Operational GIS run

Database selection test

{mapSelectionResult ? `${mapSelectionResult.feature_count} hits` : 'ready'}

Select a persisted vector layer, then query stored vector_features from PostGIS with the AOI or layer extent.

Database layer {selectedMapDataset?.name ?? 'Select a layer'}
AOI bbox {selectedAreaBbox ? 'available' : 'missing'}
Layer bbox {activeLayerBbox ? 'available' : 'missing'}
Result {mapSelectionResult ? `${mapSelectionResult.feature_count} features` : 'not run'}
{mapSelectionError ?

{mapSelectionError}

: null}
1 Layer {selectedMapDataset ? selectedMapDataset.name : 'Select database layer'}
2 Extent {currentSelectionBbox ? 'AOI/layer bbox ready' : 'Use AOI or layer extent'}
3 Query {mapSelectionResult ? `${mapSelectionResult.feature_count} persisted features` : 'Run PostGIS selection'}
4 Dataset {latestSelectionDatasetName ?? 'Save query result'}
5 QA/QC {mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n/a'}` : 'Compare with reference'}
6 Export {latestSelectionExportPath ? 'GeoJSON artifact ready' : 'Save handoff artifact'}
Query, save, QA and export {fullWorkflowStatus}
{selectionDatasetError ?

{selectionDatasetError}

: null} {selectionExportError ?

{selectionExportError}

: null} {mapSelectionQaError ?

{mapSelectionQaError}

: null} {fullWorkflowError ?

{fullWorkflowError}

: null}
Advanced selection and inspection BBox, feature extract and raw properties

Persisted vector query

Area selection

{mapSelectionResult ? `${mapSelectionResult.feature_count} selected` : bboxSelectionMode ? 'selecting' : 'ready'}
{bboxSelectionMode ? (firstSelectionCorner ? 'Click the opposite corner' : 'Click the first corner on the map') : 'BBox EPSG:4326'} {formatBboxLabel(currentSelectionBbox)}
{mapSelectionError ?

{mapSelectionError}

: null} {mapSelectionResult ? (
Features {mapSelectionResult.feature_count}
Limit {mapSelectionResult.limit}
Truncated {mapSelectionResult.truncated ? 'yes' : 'no'}
Source vector_features
{selectionExportError ?

{selectionExportError}

: null} {latestSelectionExportPath ? (

Saved selection artifact: {latestSelectionExportPath}

) : null} {selectionDatasetError ?

{selectionDatasetError}

: null} {latestSelectionDatasetName ? (

Saved derived dataset: {latestSelectionDatasetName}

) : null} {latestSelectionDatasetName ? (
{mapSelectionQaError ?

{mapSelectionQaError}

: null} {mapSelectionQaResult ? (

QA evidence

Saved selection comparison

Precision {mapSelectionQaResult.precision ?? 'n/a'}
Recall {mapSelectionQaResult.recall ?? 'n/a'}
F1 {mapSelectionQaResult.f1_score ?? 'n/a'}
Mean IoU {mapSelectionQaResult.mean_iou ?? 'n/a'}
Matches {mapSelectionQaResult.matches}
False positives {mapSelectionQaResult.false_positives}
False negatives {mapSelectionQaResult.false_negatives}
Quality check id {latestMapSelectionQualityCheckId ?? 'not persisted'}
{mapSelectionQaResult.warnings.length > 0 ? (
Map selection QA warnings
    {mapSelectionQaResult.warnings.map((warning) => (
  • {warning}
  • ))}
) : null}
) : null}
) : null} {areaSelectionPreviewFeatures.length > 0 ? (
{areaSelectionPreviewFeatures.map((feature, index) => ( ))}
Feature Class Source id
{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)} {String(feature.properties?.['feature_class'] ?? 'n/a')} {String(feature.properties?.['source_feature_id'] ?? 'n/a')}
) : (

No persisted vector features intersect this selection.

)}
) : null}

Selected feature

{'Selection & extract'}

{selectedMapFeature ? 'ready' : 'waiting'}
{selectedMapFeature ? ( <>
Geometry {featureGeometrySummary.geometryType}
Coordinates {featureGeometrySummary.coordinateCount}
Properties {featureExtractionEntries.length}
BBox EPSG:4326 {featureGeometrySummary.bboxLabel}
{featureExtractionEntries.length > 0 ? (
{featureExtractionEntries.map(([key, value]) => ( ))}
Property Value
{key} {typeof value === 'object' ? JSON.stringify(value) : String(value)}
) : (

The selected feature has geometry but no persisted properties.

)} ) : (
No feature selected

Click a visible vector, detection, segmentation or change feature on the map to extract its attributes and GeoJSON.

)}

Feature inspector

{selectedMapFeature?.geometry?.type ?? 'none'}
{selectedMapFeature ? ( <> {featureSummaryEntries.length > 0 ? (
{featureSummaryEntries.map(([key, value]) => (
{key} {String(value)}
))}
) : null}
{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}
) : (

Click a visible map feature to inspect its properties.

)}
) }