import { useEffect, useMemo, useState } from 'react' import GeoMap from '../GeoMap' import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' import { featureCollectionBounds } from '../../lib/geojsonBounds' import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights' import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { TemporalTrendChart } from './TemporalTrendChart' import { terrainImageUrl } from '../../lib/terrainImage' import { floodHazardImageUrl } from '../../lib/floodHazardImage' import { thematicRasterImageUrl } from '../../lib/thematicRaster' 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' | 'space_occupation' | 'open_space' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'soil' | 'water' | 'flood_hazard' | 'elevation' | 'accessibility' | 'services' | '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: '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: 'Historische DOV-bodemkartering met bodemtype, textuur en drainageklasse voor Mol.', 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: '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'], }, ] const DATA_THEME_MAP_STYLES: Record = { buildings: { fill: '#d45f3d', line: '#9f3e24' }, 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' }, 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' }, } function datasetAvailabilityLabel(dataset: DatasetCreateResponse, partitionCount = 1): string { const regionalSuffix = partitionCount > 1 ? ` · ${partitionCount} gemeenten` : '' if (dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv') { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid${regionalSuffix}` } if (dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard') { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} overstromingsscenario${regionalSuffix}` } if (dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster') { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) const year = Number(dataset.source_metadata?.['observation_year']) const resolutionLabel = Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster' return `${resolutionLabel} officiële bron${Number.isFinite(year) ? ` · ${year}` : ''}` } return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar` } 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() } 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 === 'digitaal_vlaanderen_dhmv') { return theme.id === 'elevation' } if (dataset.source_name === 'department_omgeving_thematic_raster') { return dataset.source_metadata?.['theme'] === theme.id } if (dataset.source_name === 'dov_soil_map') { return theme.id === 'soil' } const searchText = datasetSearchText(dataset) return theme.tokens.some((token) => searchText.includes(token)) } function isMunicipalityAreaName(name: string | null | undefined): boolean { return /^Gemeente\s/i.test(name ?? '') } function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean { return Boolean( dataset?.dataset_type === 'raster' && ['digitaal_vlaanderen_dhmv', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''), ) } function datasetProductKey(dataset: DatasetCreateResponse): string { return String(dataset.source_metadata?.['product_key'] ?? '') } function datasetCoversSelectedArea( dataset: DatasetCreateResponse, selectedAreaId: string | null, regionalScope = false, ): boolean { const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '') if (coverageScope !== 'municipality' || !dataset.area_id) { return true } if (regionalScope) { return true } return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId } function rasterPartitionsForDataset( datasets: DatasetCreateResponse[], representative: DatasetCreateResponse | null, selectedAreaId: string | null, regionalScope: boolean, ): DatasetCreateResponse[] { if (!representative) { return [] } if (!regionalScope || !isPartitionedRaster(representative)) { return [representative] } const productKey = datasetProductKey(representative) return datasets .filter( (dataset) => dataset.source_name === representative.source_name && datasetProductKey(dataset) === productKey && datasetCoversSelectedArea(dataset, selectedAreaId, true), ) .sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? ''))) } function pickThemeDataset( datasets: DatasetCreateResponse[], theme: DataTheme, selectedAreaId: string | null, regionalScope = false, ): DatasetCreateResponse | null { const candidates = datasets.filter( (dataset) => datasetMatchesTheme(dataset, theme) && datasetCoversSelectedArea(dataset, selectedAreaId, 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 === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) + (dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) + (dataset.source_name === 'vmm_flood_hazard' ? 5_000_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 } function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null { return dataset ? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null : 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 ? getDatasetDisplayName(first) : 'Tijdreeks' const range = temporalRangeLabel(items) return range ? `${source} (${range})` : 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 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)}` } 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'] ?? '')}` } const period = dataset.source_metadata?.['acquisition_period'] if (typeof period === 'string' && period.trim()) { return `opnameperiode ${period}` } return formatObservationDate(dataset.observed_at) } function floodScenarioLabel(dataset: DatasetCreateResponse): string { const configured = dataset.source_metadata?.['product_display_name'] return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset) } 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 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}` } 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 formatPercentage(value: number | null | undefined): string { return typeof value === 'number' && Number.isFinite(value) ? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%` : 'n.v.t.' } 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, 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) } 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 orthophotoAnalysisQuality: DetectionQaResult | null orthophotoAnalysisDetectionCount: number | null orthophotoProducts: OrthophotoProductRead[] selectedOrthophotoProductKey: string orthophotoResult: OrthophotoAcquisitionResult | null orthophotoImageUrl: string | null availableMapDatasets: DatasetCreateResponse[] selectedMapDatasetId: string onSelectMapArea: (areaId: string) => void onSetContextSourceLabel: (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 onClearMapSelectionExtract: () => void onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise onPersistMapResult: (payload: MapResultExportRequest) => Promise onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise onSelectMapQaReferenceDataset: (datasetId: string) => void onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise onOpenMapSelectionQualityEvidence: () => void onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise onSelectOrthophotoProduct: (productKey: string) => void onClearQualityEvidence?: () => void onOpenAssistant: () => void onOpenExports: () => 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, orthophotoAnalysisQuality, orthophotoAnalysisDetectionCount, orthophotoProducts, selectedOrthophotoProductKey, orthophotoResult, orthophotoImageUrl, availableMapDatasets, selectedMapDatasetId, onSelectMapArea, onSetContextSourceLabel, onOpenDatasetInMap, onSetAreaLayerVisible, onSetAreaLayerOpacity, onSetMapLayerVisible, onSetMapLayerOpacity, onSetMapContentMode, onSelectMapFeature, onMapViewportChange, onSetMapSelectionBbox, onRunMapSelectionExtract, onClearMapSelectionExtract, onExportMapSelection, onPersistMapResult, onDeriveMapSelectionDataset, onSelectMapQaReferenceDataset, onRunMapSelectionQa, onOpenMapSelectionQualityEvidence, onRunOrthophotoAnalysis, onSelectOrthophotoProduct, onClearQualityEvidence, onOpenAssistant, onOpenExports, }: MapWorkspaceProps): JSX.Element { const [advancedMode, setAdvancedMode] = useState(false) const [activeThemeId, setActiveThemeId] = useState(() => { const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null return themeIdForDataset(selectedDataset) ?? '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 [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('') 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 regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) 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 floodHazardDatasets = useMemo( () => { const scoped = availableMapDatasets .filter( (dataset) => dataset.source_name === 'vmm_flood_hazard' && datasetCoversSelectedArea(dataset, selectedMapAreaId, regionalScopeSelected), ) .sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')) if (!regionalScopeSelected) { return scoped } const products = new Map() for (const dataset of scoped) { const key = datasetProductKey(dataset) if (key && !products.has(key)) { products.set(key, dataset) } } return Array.from(products.values()) }, [availableMapDatasets, regionalScopeSelected, selectedMapAreaId], ) const themeDatasetMap = useMemo(() => { const result = Object.fromEntries( DATA_THEMES.map((theme) => [ theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId, regionalScopeSelected), ]), ) as Record const selectedFloodHazard = floodHazardDatasets.find((dataset) => dataset.id === selectedFloodHazardDatasetId) if (selectedFloodHazard) { result.flood_hazard = selectedFloodHazard } return result }, [availableMapDatasets, floodHazardDatasets, regionalScopeSelected, selectedFloodHazardDatasetId, selectedMapAreaId]) const themePartitionMap = useMemo( () => Object.fromEntries( DATA_THEMES.map((theme) => [ theme.id, rasterPartitionsForDataset( availableMapDatasets, themeDatasetMap[theme.id], selectedMapAreaId, regionalScopeSelected, ), ]), ) as Record, [availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap], ) 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 selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null const orthophotoImageOverlay = useMemo( () => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4 ? { url: orthophotoImageUrl, bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number], label: orthophotoResult.display_name, opacity: 0.9, } : null, [orthophotoImageUrl, orthophotoResult], ) const activeThemeDataset = themeDatasetMap[activeTheme.id] const activeThemePartitions = themePartitionMap[activeTheme.id] const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset) const terrainImageOverlays = useMemo( () => activeTheme.id === 'elevation' && selectedProjectId ? activeThemePartitions.flatMap((dataset) => { const bounds = dataset.source_metadata?.['bbox_epsg4326'] return dataset.source_name === 'digitaal_vlaanderen_dhmv' && Array.isArray(bounds) && bounds.length === 4 ? [{ url: terrainImageUrl(selectedProjectId, dataset.id), bbox: bounds.map(Number) as [number, number, number, number], label: getDatasetDisplayName(dataset), opacity: 0.82, }] : [] }) : [], [activeTheme.id, activeThemePartitions, selectedProjectId], ) const floodHazardImageOverlays = useMemo( () => activeTheme.id === 'flood_hazard' && selectedProjectId ? activeThemePartitions.flatMap((dataset) => { const bounds = dataset.source_metadata?.['bbox_epsg4326'] return dataset.source_name === 'vmm_flood_hazard' && Array.isArray(bounds) && bounds.length === 4 ? [{ url: floodHazardImageUrl(selectedProjectId, dataset.id), bbox: bounds.map(Number) as [number, number, number, number], label: floodScenarioLabel(dataset), opacity: 0.82, }] : [] }) : [], [activeTheme.id, activeThemePartitions, selectedProjectId], ) const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] : null const thematicRasterImageOverlays = useMemo( () => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4 ? [{ url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id), bbox: thematicRasterBounds.map(Number) as [number, number, number, number], label: getDatasetDisplayName(activeThemeDataset), opacity: 0.78, }] : [], [activeThemeDataset, selectedProjectId, thematicRasterBounds], ) const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde') const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde') const activeImageOverlays = useMemo( () => thematicRasterImageOverlays.length > 0 ? thematicRasterImageOverlays : floodHazardImageOverlays.length > 0 ? floodHazardImageOverlays : terrainImageOverlays.length > 0 ? terrainImageOverlays : orthophotoImageOverlay ? [orthophotoImageOverlay] : [], [floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays], ) 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 themeTemporalSeriesMap = useMemo( () => Object.fromEntries( DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme)]), ) as Record, [availableMapDatasets], ) const activeTemporalSeriesGroups = themeTemporalSeriesMap[activeTheme.id] const availableEvolutionThemes = DATA_THEMES.filter((theme) => themeTemporalSeriesMap[theme.id].some((group) => group.items.length >= 2), ) const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey) ?? activeTemporalSeriesGroups[0] const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES const activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2 && activeTemporalSeries.every((dataset) => dataset.source_name === 'grb') && new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime() - new Date(activeTemporalSeries[0].observed_at ?? 0).getTime() <= 7 * 24 * 60 * 60 * 1000 useEffect(() => { onSetContextSourceLabel( analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null : null, ) return () => onSetContextSourceLabel(null) }, [activeTemporalSeriesGroup?.label, analysisMode, onSetContextSourceLabel]) 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 ?? (!regionalRasterThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) 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 activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter( (metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key, ) const terrainReliefMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'relief_m') const populationDensityMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'population_density_mean_per_ha') const scoreMedianMetric = activeSupportingMetrics.find((metric) => metric.metric_key.endsWith('_median')) const activeSecondaryMetric = activeMetricUnit === 'm TAW' ? terrainReliefMetric ? `${terrainReliefMetric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits: 2 })} m reliëf` : null : activeMetricUnit === 'inwoners' ? populationDensityMetric ? selectionMetricLabel(populationDensityMetric) : null : activeMetricUnit.startsWith('score') ? scoreMedianMetric ? selectionMetricLabel(scoreMedianMetric) : null : 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 activeSecondaryLabel = activeMetricUnit === 'ha' ? 'Aandeel selectie' : activeMetricUnit === 'm TAW' ? 'Reliëf' : activeMetricUnit === 'inwoners' ? 'Gemiddelde dichtheid' : activeMetricUnit.startsWith('score') ? 'Mediaan' : 'Dichtheid' 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(() => { if (floodHazardDatasets.some((dataset) => dataset.id === selectedFloodHazardDatasetId)) { return } const preferred = floodHazardDatasets.find( (dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100', ) ?? floodHazardDatasets[0] setSelectedFloodHazardDatasetId(preferred?.id ?? '') }, [floodHazardDatasets, selectedFloodHazardDatasetId]) useEffect(() => { const first = activeTemporalSeries[0] const last = activeTemporalSeries[activeTemporalSeries.length - 1] setEarlierDatasetId(first?.id ?? '') setLaterDatasetId(last?.id ?? '') clearTemporalComparison() }, [activeTemporalSeries]) useEffect(() => { if (advancedMode || !activeThemeDataset || selectedMapDataset?.id === activeThemeDataset.id) { return } onOpenDatasetInMap(activeThemeDataset) }, [activeTheme, activeThemeDataset, advancedMode, onOpenDatasetInMap, selectedMapDataset]) useEffect(() => { if (!selectedMapDataset) { return } const matchingThemeId = themeIdForDataset(selectedMapDataset) if (matchingThemeId) { setActiveThemeId(matchingThemeId) } }, [selectedMapDataset]) const downloadSelectedMapFeature = () => { if (!selectedFeatureGeoJson) { return } downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson, 'application/geo+json') } const copySelectedMapFeatureProperties = () => { copyText(JSON.stringify(featureProperties ?? {}, null, 2)) } const setSelectionBbox = (bbox: VectorSelectionBBox | null) => { onSetMapSelectionBbox(bbox) setBboxInput(bboxToInputState(bbox)) } const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => ( bbox && selectedMapArea && selectedAreaBbox && bboxesEqual(bbox, selectedAreaBbox) ? selectedMapArea.id : undefined ) 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, areaIdForSelection(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, 'application/geo+json') } const copyAreaSelection = () => { copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) } const downloadActiveThemeResult = () => { if (!activeSelectionResult) { return } if (activeThemeDataset?.dataset_type === 'raster') { downloadJsonFile(`${activeTheme.id}-analysis.json`, { project_id: selectedProjectId, area_id: areaIdForSelection(mapSelectionBbox) ?? null, area_name: selectedMapArea?.name ?? null, theme: activeTheme, dataset_id: activeThemeDataset.id, dataset_name: activeThemeDataset.name, source_name: activeThemeDataset.source_name, result: activeSelectionResult, }) return } downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson, 'application/geo+json') } const copyActiveThemeResult = () => { copyText(JSON.stringify(activeSelectionResult ?? {}, null, 2)) } const downloadTemporalComparison = () => { if (!temporalComparison) { return } downloadJsonFile(`${activeTheme.id}-evolution-${temporalComparison.earlier.observed_at.slice(0, 10)}-${temporalComparison.later.observed_at.slice(0, 10)}.json`, temporalComparison) } const copyTemporalComparison = () => { copyText(JSON.stringify(temporalComparison ?? {}, null, 2)) } const persistActiveResultAndOpenDownloads = async () => { if (!selectedProjectId || !mapSelectionBbox) { onOpenExports() return } const areaId = areaIdForSelection(mapSelectionBbox) const payload: MapResultExportRequest | null = analysisMode === 'evolution' ? temporalComparison && earlierDatasetId && laterDatasetId ? { project_id: selectedProjectId, mode: 'evolution', bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, earlier_dataset_id: earlierDatasetId, later_dataset_id: laterDatasetId, area_id: areaId, theme_id: activeTheme.id, name: `${activeTheme.id}-evolution`, } : null : activeSelectionResult && activeThemeDataset ? { project_id: selectedProjectId, mode: 'current', bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, dataset_id: activeThemeDataset.id, area_id: areaId, partitioned: regionalRasterThemeActive, product_key: String(activeThemeDataset.source_metadata?.['product_key'] ?? '') || undefined, theme_id: activeTheme.id, name: `${activeTheme.id}-analysis`, } : null if (!payload) { onOpenExports() return } const persisted = await onPersistMapResult(payload) if (persisted) { onOpenExports() } } const saveAreaSelectionExport = () => { const bbox = parseBboxInput(bboxInput) if (!bbox) { return } onExportMapSelection(bbox, areaIdForSelection(bbox)) } const saveAreaSelectionDataset = () => { const bbox = parseBboxInput(bboxInput) if (!bbox) { return } onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox)) } const openSelectedDatabaseLayer = (datasetId: string) => { const dataset = availableMapDatasets.find((item) => item.id === datasetId) if (dataset) { onOpenDatasetInMap(dataset) } } const selectDataTheme = (theme: DataTheme) => { const temporalGroup = themeTemporalSeriesMap[theme.id][0] const dataset = analysisMode === 'evolution' ? temporalGroup?.items[temporalGroup.items.length - 1] ?? null : themeDatasetMap[theme.id] if (!dataset) { return } setActiveThemeId(theme.id) clearTemporalComparison() onOpenDatasetInMap(dataset) } const setExplorerMode = (mode: 'current' | 'evolution') => { setAnalysisMode(mode) clearTemporalComparison() if (mode !== 'evolution' || activeTemporalSeriesGroups.length > 0) { return } const fallbackTheme = availableEvolutionThemes[0] const fallbackGroup = fallbackTheme ? themeTemporalSeriesMap[fallbackTheme.id][0] : null const fallbackDataset = fallbackGroup?.items[fallbackGroup.items.length - 1] if (fallbackTheme && fallbackDataset) { setActiveThemeId(fallbackTheme.id) onOpenDatasetInMap(fallbackDataset) } } const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const availableThemes = DATA_THEMES.flatMap((theme) => { const dataset = themeDatasetMap[theme.id] return dataset ? [{ themeId: theme.id, dataset, partitioned: regionalScopeSelected && isPartitionedRaster(dataset), }] : [] }) await loadThemeInsights(bbox, availableThemes, areaId) } const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { setSelectionBbox(bbox) const tasks: Array> = [loadAllThemeResults(bbox, areaId)] if (!regionalRasterThemeActive) { tasks.push(onRunMapSelectionExtract(bbox, areaId)) } if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId)) } await Promise.all(tasks) } const runTemporalComparison = () => { if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) { return } void compareTemporalSnapshots( earlierDatasetId, laterDatasetId, mapSelectionBbox, areaIdForSelection(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, areaIdForSelection(bbox)) } 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 selectionAreaId = areaIdForSelection(bbox) const selection = await onRunMapSelectionExtract(bbox, selectionAreaId) 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, selectionAreaId) 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, selectionAreaId) 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.' : regionalRasterThemeActive ? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.' : 'Sleep een rechthoek of analyseer het volledige werkgebied.'}

Werkgebied {thematicRasterImageOverlays.length > 0 ? ( {thematicLegendMin} → {thematicLegendMax} ) : activeImageOverlays.length > 0 ? ( {activeImageOverlays[0].label} {activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''} ) : null} {analysisOverlayActive ? ( <> AI-kandidaten 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.

)}
) }