import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { BoxSelect, MapPinned, SlidersHorizontal, Trash2 } from 'lucide-react' import GeoMap from '../GeoMap' import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts' 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' import { bathymetryRasterImageUrl } from '../../lib/bathymetryRaster' import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus' import { MAP_ANALYSIS_BUDGET_MS, exceedsPerformanceBudget, formatPerformanceDuration, } from '../../lib/performanceBudget' import { bboxToInputState, bboxesEqual, copyText, downloadJsonFile, formatArea, formatBboxLabel, formatPercentage, formatTemporalMetric, getFeatureBBox, getFeatureCollectionBBox, getFeatureGeometrySummary, isMunicipalityAreaName, isSelectionBoundedDataset, normalizeBboxFromCorners, operationalScopeProjectLabel, parseBboxInput, productCoversZones, readablePropertyName, resultCountLabel, resultMetricLabel, safeFileStem, selectedAreaCoverageZones, selectedFeatureCollection, selectionAnalysisScale, selectionAreaSquareMetres, selectionDimensions, selectionMetricLabel, splitSelectionBbox, } from './mapWorkspaceUtils' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = [] type DataThemeId = | 'administrative' | 'buildings' | 'space_occupation' | 'open_space' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'soil' | 'water' | 'bathymetry' | 'flood_hazard' | 'elevation' | 'accessibility' | 'services' | 'roads' | 'parcels' | 'maritime_planning' | 'marine_environment' interface DataTheme { id: DataThemeId label: string shortLabel: string description: string tokens: string[] } interface TemporalSeriesGroup { key: string label: string items: DatasetCreateResponse[] } interface OnDemandMapProduct extends MapThemeAcquisition { theme: DataThemeId availabilityLabel: string attribution: string limitationMessage: string } interface PlannedOnDemandMapProduct extends OnDemandMapProduct { acquisitionBboxes: VectorSelectionBBox[] } function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean { const scale = selectionAnalysisScale(bbox) if (scale === 'overview') return false const dimensions = selectionDimensions(bbox) if (product.kind === 'dhmv' || product.kind === 'flood_hazard') { return dimensions.areaSquareMetres <= 280_000_000 } if (product.kind === 'thematic_raster') { return dimensions.widthMetres <= 50_000 && dimensions.heightMetres <= 50_000 && dimensions.areaSquareMetres <= 2_800_000_000 } return true } const DATA_THEMES: DataTheme[] = [ { id: 'administrative', label: 'Bestuurlijke indeling', shortLabel: 'Bestuursgebieden', description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.', tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'], }, { id: 'buildings', label: 'Bebouwing', shortLabel: 'Gebouwen', description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.', tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'], }, { id: '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: 'bathymetry', label: 'Waterbodem', shortLabel: 'Dwarsprofielen', description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.', tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'], }, { id: 'flood_hazard', label: 'Overstroming', shortLabel: 'Overstroomd oppervlak', description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.', tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'], }, { id: 'elevation', label: 'Hoogte & reliëf', shortLabel: 'Hoogte', description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.', tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'], }, { id: 'accessibility', label: 'Bereikbaarheid', shortLabel: 'Knooppuntwaarde', description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.', tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'], }, { id: 'services', label: 'Voorzieningen', shortLabel: 'Voorzieningenniveau', description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.', tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'], }, { id: 'roads', label: 'Wegen', shortLabel: 'Wegen', description: 'Wegen en wegsegmenten uit een persistente bron.', tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'], }, { id: 'parcels', label: 'Percelen', shortLabel: 'Percelen', description: 'Kadastrale of administratieve perceelcontouren.', tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'], }, { id: 'maritime_planning', label: 'Maritieme planning', shortLabel: 'Plan- en gebruikszones', description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.', tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'], }, { id: 'marine_environment', label: 'Mariene rapportagezones', shortLabel: 'Zeegebieden', description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.', tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'], }, ] const COVERAGE_THEME_BY_MAP_THEME: Record = { administrative: 'admin', buildings: 'buildings', space_occupation: 'land_cover_use', open_space: 'land_cover_use', population: 'population', forest: 'land_cover_use', nature_value: 'nature', agriculture: 'land_cover_use', soil: 'soil', water: 'surface_water', bathymetry: 'bathymetry', flood_hazard: 'flood_climate', elevation: 'elevation', accessibility: 'roads', services: 'population', roads: 'roads', parcels: 'parcels', maritime_planning: 'maritime_planning', marine_environment: 'marine_environment', } function coverageStatusLabel(status: CoverageStatus): string { const labels: Record = { operational: 'Beschikbaar', partial: 'Gedeeltelijk', not_configured: 'Niet gekoppeld', unsupported: 'Niet ondersteund', } return labels[status] } function coverageZoneLabel(zone: string): string { const labels: Record = { belgium: 'Belgie', flanders: 'Vlaanderen', wallonia: 'Wallonie', brussels: 'Brussel', belgian_north_sea: 'Belgische Noordzee', territorial_sea: 'Territoriale zee', exclusive_economic_zone: 'EEZ', continental_shelf: 'Continentaal plat', } return labels[zone] ?? zone } const DATA_THEME_MAP_STYLES: Record = { administrative: { fill: '#5f6f7f', line: '#344554' }, 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' }, bathymetry: { fill: '#0e7490', line: '#164e63' }, flood_hazard: { fill: '#1597c2', line: '#075985' }, elevation: { fill: '#a57a4b', line: '#315f59' }, accessibility: { fill: '#0f766e', line: '#115e59' }, services: { fill: '#b66d16', line: '#854d0e' }, roads: { fill: '#6b7280', line: '#4b5563' }, parcels: { fill: '#a7792f', line: '#7d571f' }, maritime_planning: { fill: '#2f7f8f', line: '#145d6a' }, marine_environment: { fill: '#3475a3', line: '#1c557d' }, } function datasetAvailabilityLabel( dataset: DatasetCreateResponse, partitions: DatasetCreateResponse[] = [dataset], ): string { const partitionCount = partitions.length 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 === 'spw_bathymetry') { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) const period = String(dataset.source_metadata?.['survey_period'] ?? '2019-2022') return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} waterbodemhoogte · ${period}` } if (dataset.source_name === 'vmm_vha_bathymetry_profiles') { const profiles = partitions.reduce( (total, item) => total + (item.feature_count ?? item.vector_summary?.feature_count ?? 0), 0, ) const documents = partitions.reduce( (total, item) => total + Number(item.source_metadata?.['document_count'] ?? 0), 0, ) return `${profiles.toLocaleString('nl-BE')} profielen · ${documents.toLocaleString('nl-BE')} bronbladen${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}` : ''}` } if (dataset.source_name === 'ngi_adminvector') { return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële bestuursgebieden` } if (dataset.source_name === 'rbins_msp_2026') { return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële planobjecten · 2026-2034` } if (dataset.source_name === 'rbins_marine_reporting_units') { return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële zeegebieden` } 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 === 'vmm_vha_bathymetry_profiles') { return theme.id === 'bathymetry' } if (dataset.source_name === 'spw_bathymetry') { return theme.id === 'bathymetry' } 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' } if (dataset.source_name === 'ngi_adminvector') { return theme.id === 'administrative' } if (dataset.source_name === 'rbins_msp_2026') { return theme.id === 'maritime_planning' } if (dataset.source_name === 'rbins_marine_reporting_units') { return theme.id === 'marine_environment' } const searchText = datasetSearchText(dataset) return theme.tokens.some((token) => searchText.includes(token)) } function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean { return Boolean( dataset?.dataset_type === 'raster' && ['digitaal_vlaanderen_dhmv', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''), ) } function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean { return Boolean( dataset?.source_name === 'vmm_vha_bathymetry_profiles' && dataset.source_metadata?.['regional_partitions_complete'] === true, ) } function datasetProductKey(dataset: DatasetCreateResponse): string { return String(dataset.source_metadata?.['product_key'] ?? '') } function datasetCoversSelectedArea( dataset: DatasetCreateResponse, selectedAreaId: string | null, selectedAreaName: string | null | undefined, regionalScope = false, ): boolean { if (isSelectionBoundedDataset(dataset.source_metadata)) { return false } const selectedZones = selectedAreaCoverageZones(selectedAreaName) const configuredZones = dataset.source_metadata?.['coverage_zones'] if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) { const datasetZones = configuredZones.map((zone) => String(zone)) if (!selectedZones.some((zone) => datasetZones.includes(zone))) { return false } } const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '') if (isPartitionedBathymetry(dataset)) { return regionalScope ? true : Boolean(selectedAreaId) && dataset.area_id === selectedAreaId } if (coverageScope !== 'municipality' || !dataset.area_id) { return true } if (regionalScope) { return true } return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId } function rasterPartitionsForDataset( datasets: DatasetCreateResponse[], representative: DatasetCreateResponse | null, selectedAreaId: string | null, selectedAreaName: string | null | undefined, regionalScope: boolean, ): DatasetCreateResponse[] { if (!representative) { return [] } if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) { return [representative] } const productKey = datasetProductKey(representative) const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '') return datasets .filter( (dataset) => dataset.source_name === representative.source_name && ( isPartitionedBathymetry(representative) ? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256 : datasetProductKey(dataset) === productKey ) && datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true), ) .sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? ''))) } function pickThemeDataset( datasets: DatasetCreateResponse[], theme: DataTheme, selectedAreaId: string | null, selectedAreaName: string | null | undefined, regionalScope = false, ): DatasetCreateResponse | null { const candidates = datasets.filter( (dataset) => datasetMatchesTheme(dataset, theme) && datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope), ) candidates.sort((left, right) => { const priorityScore = (dataset: DatasetCreateResponse) => (dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) + (dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) + (dataset.source_name === 'grb' ? 100_000 : 0) + (dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) + (dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) + (dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) + (dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) + (dataset.source_name === '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_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) + (dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) + (dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) + (dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) + (dataset.dataset_role === 'reference' ? 10_000 : 0) const priorityDifference = priorityScore(right) - priorityScore(left) if (priorityDifference !== 0) return priorityDifference const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime() if (observedAtDifference !== 0) return observedAtDifference const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime() if (importedAtDifference !== 0) return importedAtDifference return (right.feature_count ?? right.vector_summary?.feature_count ?? 0) - (left.feature_count ?? left.vector_summary?.feature_count ?? 0) }) return candidates[0] ?? null } 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'] ?? '')}` } if (dataset.source_name === 'vmm_vha_bathymetry_profiles') { const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4) const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4) return firstMeasurement && lastMeasurement ? `historische profielen ${firstMeasurement}-${lastMeasurement}` : 'historische profielmetingen' } if (dataset.source_name === 'spw_bathymetry') { return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG` } if (dataset.source_name === 'department_omgeving_thematic_raster') { const observationYear = Number(dataset.source_metadata?.['observation_year']) if (Number.isFinite(observationYear)) { return `referentiejaar ${observationYear}` } } 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) } 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 coverage: CoverageResolveResponse | null coverageLoading: boolean coverageError: string | null coverageDurationMs: number | null coverageBudgetExceeded: boolean workspaceLoading: boolean workspaceError: string | null selectionExporting: boolean selectionExportError: string | null latestSelectionExportPath: string | null selectionDatasetSaving: boolean selectionDatasetError: string | null latestSelectionDataset: DatasetCreateResponse | null latestSelectionDatasetName: string | null mapQaReferenceDatasets: DatasetCreateResponse[] selectedMapQaReferenceDatasetId: string mapSelectionQaRunning: boolean mapSelectionQaError: string | null mapSelectionQaResult: QaComparisonResult | null latestMapSelectionQualityCheckId: string | null orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed' orthophotoAnalysisStatus: string orthophotoAnalysisError: string | null orthophotoAnalysisRunning: boolean orthophotoAnalysisQuality: DetectionQaResult | null orthophotoAnalysisDetectionCount: number | null orthophotoProducts: OrthophotoProductRead[] selectedOrthophotoProductKey: string orthophotoResult: OrthophotoAcquisitionResult | null orthophotoImageUrl: string | null availableMapDatasets: DatasetCreateResponse[] selectedMapDatasetId: string onSelectMapArea: (areaId: string) => void onSetContextSourceLabel: (label: string | null) => void onSetContextLayerLabel: (label: string | null) => void onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void onSetAreaLayerVisible: (visible: boolean) => void onSetAreaLayerOpacity: (opacity: number) => void onSetMapLayerVisible: (visible: boolean) => void onSetMapLayerOpacity: (opacity: number) => void onSetMapContentMode: (mode: 'dataset' | 'analysis') => void onSelectMapFeature: (feature: GeoJSON.Feature | null) => void onMapViewportChange: (viewport: MapViewportState) => void onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise 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 onRefreshProjectData: () => Promise 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, coverage, coverageLoading, coverageError, coverageDurationMs, coverageBudgetExceeded, workspaceLoading, workspaceError, 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, onSetContextLayerLabel, onOpenDatasetInMap, onSetAreaLayerVisible, onSetAreaLayerOpacity, onSetMapLayerVisible, onSetMapLayerOpacity, onSetMapContentMode, onSelectMapFeature, onMapViewportChange, onSetMapSelectionBbox, onRunMapSelectionExtract, onClearMapSelectionExtract, onExportMapSelection, onPersistMapResult, onDeriveMapSelectionDataset, onSelectMapQaReferenceDataset, onRunMapSelectionQa, onOpenMapSelectionQualityEvidence, onRunOrthophotoAnalysis, onSelectOrthophotoProduct, onClearQualityEvidence, onRefreshProjectData, 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 activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) const selectedCoverageZones = useMemo( () => selectedAreaCoverageZones(selectedMapArea?.name), [selectedMapArea?.name], ) const flandersScopeSelected = Boolean( selectedCoverageZones?.includes('flanders') || (!selectedCoverageZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME), ) const { themeInsights, themeInsightsLoading: themeResultsLoading, themeInsightsError: themeResultsError, loadThemeInsights, clearThemeInsights, } = useMapThemeSelectionInsights(selectedProjectId, onRefreshProjectData) const { products: officialMapProducts, loading: officialMapProductsLoading, error: officialMapProductsError, resolveCoverage, resolveCoveragePartitions, } = useOfficialMapProducts(selectedProjectId) const { temporalComparison, temporalComparisonLoading, temporalComparisonError, compareTemporalSnapshots, clearTemporalComparison, } = useTemporalComparison(selectedProjectId) const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current') const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('') const [selectedDhmvProductKey, setSelectedDhmvProductKey] = useState<'dtm_1m' | 'dsm_1m'>('dtm_1m') const [selectedFloodHazardProductKey, setSelectedFloodHazardProductKey] = useState('pluviaal_current_t100') 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('Klaar om de volledige GIS-werkstroom uit te voeren.') const [fullWorkflowError, setFullWorkflowError] = useState(null) const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new') const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState(null) const mapAnalysisRequestSequence = useRef(0) const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) const featureProperties = selectedMapFeature?.properties ?? null const isBathymetryProfile = featureProperties?.['provider'] === 'vmm_vha_bathymetry_profiles' || featureProperties?.['measurement_semantics'] === 'historical_cross_section_profile_point' const bathymetryDocumentUrl = typeof featureProperties?.['source_document_url'] === 'string' && featureProperties['source_document_url'].startsWith('https://vha.waterinfo.be/') ? featureProperties['source_document_url'] : 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, selectedMapArea?.name, 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, selectedMapArea?.name, selectedMapAreaId], ) const themeDatasetMap = useMemo(() => { const result = Object.fromEntries( DATA_THEMES.map((theme) => [ theme.id, pickThemeDataset( availableMapDatasets, theme, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected, ), ]), ) as Record const selectedFloodHazard = floodHazardDatasets.find( (dataset) => dataset.id === selectedFloodHazardDatasetId || (flandersScopeSelected && datasetProductKey(dataset) === selectedFloodHazardProductKey), ) if (selectedFloodHazard) { result.flood_hazard = selectedFloodHazard } else if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) { result.flood_hazard = null } if (flandersScopeSelected && officialMapProducts.dhmv.length > 0) { result.elevation = availableMapDatasets.find( (dataset) => dataset.source_name === 'digitaal_vlaanderen_dhmv' && datasetProductKey(dataset) === selectedDhmvProductKey && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), ) ?? null } if (flandersScopeSelected && officialMapProducts.thematic.length > 0) { for (const product of officialMapProducts.thematic) { if (!result[product.theme]) { result[product.theme] = null } } } if (flandersScopeSelected && officialMapProducts.grb.length > 0) { for (const product of officialMapProducts.grb) { if (!result[product.key]) { result[product.key] = null } } } if (officialMapProducts.officialVector.length > 0) { for (const product of officialMapProducts.officialVector.filter((item) => productCoversZones(item.coverage_zones, selectedCoverageZones), )) { if (!result[product.theme]) { result[product.theme] = null } } } return result }, [ availableMapDatasets, flandersScopeSelected, floodHazardDatasets, officialMapProducts.dhmv.length, officialMapProducts.floodHazard.length, officialMapProducts.grb, officialMapProducts.officialVector, officialMapProducts.thematic, regionalScopeSelected, selectedDhmvProductKey, selectedFloodHazardDatasetId, selectedFloodHazardProductKey, selectedMapArea?.name, selectedMapAreaId, selectedCoverageZones, ]) const themePartitionMap = useMemo( () => Object.fromEntries( DATA_THEMES.map((theme) => [ theme.id, rasterPartitionsForDataset( availableMapDatasets, themeDatasetMap[theme.id], selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected, ), ]), ) as Record, [availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId, themeDatasetMap], ) const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id] const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? [] const coverageCounts = useMemo( () => coverage?.items.reduce>( (counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }), { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, ) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, [coverage], ) const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => { const result: OnDemandMapProduct[] = [] const effectiveZones = zones ?? selectedCoverageZones const includesFlanders = Boolean( effectiveZones?.includes('flanders') || (!effectiveZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME), ) if (includesFlanders) { for (const product of officialMapProducts.thematic) { result.push({ kind: 'thematic_raster', productKey: product.key, displayName: product.display_name, theme: product.theme, availabilityLabel: `${product.native_resolution_m} m · ${product.observation_year} · laad bij selectie`, attribution: product.attribution, limitationMessage: product.limitation_message, }) } for (const product of officialMapProducts.grb) { result.push({ kind: 'grb', productKey: product.key, displayName: product.display_name, theme: product.key, availabilityLabel: 'officiële vectorbron · laad bij selectie', attribution: product.attribution, limitationMessage: product.limitation_message, }) } for (const source of officialMapProducts.bathymetry.filter( (item) => item.key === 'vha_inland_profiles' && item.acquisition_supported && item.configured, )) { result.push({ kind: 'bathymetry_profiles', productKey: source.key, displayName: source.display_name, theme: 'bathymetry', availabilityLabel: 'historische profielpunten · laad bij selectie', attribution: source.attribution, limitationMessage: source.limitation_message, }) } } for (const product of officialMapProducts.officialVector.filter((item) => productCoversZones(item.coverage_zones, effectiveZones), )) { result.push({ kind: 'official_vector', productKey: product.key, displayName: product.display_name, theme: product.theme, availabilityLabel: `${product.observation_label} · officiële vectorbron · laad bij selectie`, attribution: product.attribution, limitationMessage: product.limitation_message, }) } const dhmvProduct = includesFlanders ? officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey) : null if (dhmvProduct) { result.push({ kind: 'dhmv', productKey: dhmvProduct.key, displayName: dhmvProduct.display_name, theme: 'elevation', availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · laad bij selectie`, attribution: dhmvProduct.attribution, limitationMessage: dhmvProduct.limitation_message, }) } const floodProduct = includesFlanders ? officialMapProducts.floodHazard.find( (product) => product.key === selectedFloodHazardProductKey, ) : null if (floodProduct) { result.push({ kind: 'flood_hazard', productKey: floodProduct.key, displayName: floodProduct.display_name, theme: 'flood_hazard', availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · laad bij selectie`, attribution: floodProduct.attribution, limitationMessage: floodProduct.limitation_message, }) } return result }, [ activeScopeProject?.name, officialMapProducts, selectedCoverageZones, selectedDhmvProductKey, selectedFloodHazardProductKey, ]) const onDemandProductMap = useMemo(() => { const result = new Map() for (const product of onDemandProductsForZones(selectedCoverageZones)) { result.set(product.theme, product) } return result }, [onDemandProductsForZones, selectedCoverageZones]) const mapSelectionScale = mapSelectionBbox ? selectionAnalysisScale(mapSelectionBbox) : null const selectionRelevantThemes = useMemo(() => { if (!mapSelectionBbox || !coverage) { return DATA_THEMES } const boundedThemes = new Set( (mapSelectionBbox ? onDemandProductsForZones(coverage.intersected_zones).filter( (product) => productSupportsSelection(product, mapSelectionBbox), ) : []) .map((product) => product.theme), ) return DATA_THEMES.filter((theme) => { if (boundedThemes.has(theme.id)) { return true } if (!themeDatasetMap[theme.id]) { return false } const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id] return coverage.items.some( (item) => item.theme === coverageTheme && item.status === 'operational', ) }) }, [coverage, mapSelectionBbox, mapSelectionScale, onDemandProductsForZones, themeDatasetMap]) const unavailableSelectionThemeCount = Math.max(DATA_THEMES.length - selectionRelevantThemes.length, 0) const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id] ? null : onDemandProductMap.get(activeTheme.id) ?? null 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 regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset) const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive const onDemandThemeActive = analysisMode === 'current' && Boolean(activeOnDemandMapProduct) const regionalOnDemandThemeActive = regionalScopeSelected && onDemandThemeActive const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThemeActive const coverageSelectionAvailable = analysisMode === 'current' && Boolean(selectedProjectId && selectedMapArea) useEffect(() => { if ( analysisMode !== 'current' || activeThemeAvailable || ( selectedProjectId && officialMapProductsLoading && onDemandProductMap.size === 0 && !officialMapProductsError ) ) { return } const fallbackTheme = DATA_THEMES.find((theme) => flandersScopeSelected && theme.id === 'space_occupation' && Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)), ) ?? DATA_THEMES.find((theme) => Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)), ) if (!fallbackTheme) { return } setActiveThemeId(fallbackTheme.id) const fallbackDataset = themeDatasetMap[fallbackTheme.id] if (fallbackDataset) { onOpenDatasetInMap(fallbackDataset) } }, [ activeThemeAvailable, analysisMode, flandersScopeSelected, officialMapProductsError, officialMapProductsLoading, onDemandProductMap, onOpenDatasetInMap, selectedProjectId, themeDatasetMap, ]) 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 bathymetryRasterBounds = activeThemeDataset?.source_name === 'spw_bathymetry' ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] : null const bathymetryRasterImageOverlays = useMemo( () => activeThemeDataset?.source_name === 'spw_bathymetry' && selectedProjectId && Array.isArray(bathymetryRasterBounds) && bathymetryRasterBounds.length === 4 ? [{ url: bathymetryRasterImageUrl(selectedProjectId, activeThemeDataset.id), bbox: bathymetryRasterBounds.map(Number) as [number, number, number, number], label: 'Waterbodemhoogte in mDNG', opacity: 0.86, }] : [], [activeThemeDataset, bathymetryRasterBounds, selectedProjectId], ) 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( () => bathymetryRasterImageOverlays.length > 0 ? bathymetryRasterImageOverlays : thematicRasterImageOverlays.length > 0 ? thematicRasterImageOverlays : floodHazardImageOverlays.length > 0 ? floodHazardImageOverlays : terrainImageOverlays.length > 0 ? terrainImageOverlays : orthophotoImageOverlay ? [orthophotoImageOverlay] : [], [bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays], ) 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(() => { const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null : regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen' : activeOnDemandMapProduct?.displayName ?? null onSetContextSourceLabel(contextSourceLabel) return () => onSetContextSourceLabel(null) }, [ activeTemporalSeriesGroup?.label, activeOnDemandMapProduct?.displayName, analysisMode, onSetContextSourceLabel, regionalBathymetryThemeActive, ]) 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 readSelectionThemeCount = useMemo( () => new Set(themeResults.map((result) => result.theme.id)).size, [themeResults], ) const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId) const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset const activeSelectionResult = activeThemeInsight?.result ?? (!regionalPartitionedThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) useEffect(() => { const contextLayerLabel = analysisMode === 'current' && activeOnDemandMapProduct ? activeSelectionResult ? `${( activeSelectionResult.total_feature_count ?? activeSelectionResult.feature_count ).toLocaleString('nl-BE')} objecten gemeten` : 'Op aanvraag' : null onSetContextLayerLabel(contextLayerLabel) return () => onSetContextLayerLabel(null) }, [ activeOnDemandMapProduct, activeSelectionResult, analysisMode, onSetContextLayerLabel, ]) const explorerMapFeatureCollection = regionalBathymetryThemeActive ? null : analysisMode === 'evolution' && temporalComparison?.geojson.features.length ? temporalComparison.geojson : onDemandThemeActive ? null : mapFeatureCollection const explorerSelectionFeatureCollection = analysisMode === 'current' ? activeSelectionResult?.geojson ?? null : null const selectedAreaSquareMetres = useMemo( () => bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2 ? selectedMapArea.area_m2 : selectionAreaSquareMetres(mapSelectionBbox), [mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2], ) const selectionScaleNotice = useMemo(() => { if (!mapSelectionBbox || !mapSelectionScale) return null const dimensions = selectionDimensions(mapSelectionBbox) const widthKm = dimensions.widthMetres / 1000 const heightKm = dimensions.heightMetres / 1000 if (mapSelectionScale === 'regional') { const partitionCount = splitSelectionBbox(mapSelectionBbox).length return `Regionale analyse van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} × ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Geschikte detailbronnen worden automatisch over ${partitionCount} begrensde bronpartities verwerkt; 5 m-rasters worden alleen meegenomen wanneer het veilige pixelbudget volstaat.` } if (mapSelectionScale === 'overview') { return `Overzichtsanalyse van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} × ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Alleen landelijke en vooraf ingeladen bronnen die deze schaal betrouwbaar ondersteunen worden bevraagd. Teken maximaal 50 × 50 km voor regionale thema's of 20 × 20 km voor alle detailbronnen.` } return null }, [mapSelectionBbox, mapSelectionScale]) 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(() => { const selected = floodHazardDatasets.find( (dataset) => datasetProductKey(dataset) === selectedFloodHazardProductKey, ) if (selected) { if (selected.id !== selectedFloodHazardDatasetId) { setSelectedFloodHazardDatasetId(selected.id) } return } if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) { setSelectedFloodHazardDatasetId('') return } const preferred = floodHazardDatasets.find( (dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100', ) ?? floodHazardDatasets[0] setSelectedFloodHazardDatasetId(preferred?.id ?? '') if (preferred && datasetProductKey(preferred)) { setSelectedFloodHazardProductKey(datasetProductKey(preferred)) } }, [ flandersScopeSelected, floodHazardDatasets, officialMapProducts.floodHazard.length, selectedFloodHazardDatasetId, selectedFloodHazardProductKey, ]) 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 selectedProductKey = datasetProductKey(selectedMapDataset) if ( selectedMapDataset.source_name === 'digitaal_vlaanderen_dhmv' && (selectedProductKey === 'dtm_1m' || selectedProductKey === 'dsm_1m') ) { setSelectedDhmvProductKey(selectedProductKey) } if (selectedMapDataset.source_name === 'vmm_flood_hazard' && selectedProductKey) { setSelectedFloodHazardProductKey(selectedProductKey) setSelectedFloodHazardDatasetId(selectedMapDataset.id) } 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 ? 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, areaIdForSelection(bbox)) } const runAreaExtract = () => { const bbox = parseBboxInput(bboxInput) if (!bbox) { return } void analyzeSelection(bbox, areaIdForSelection(bbox)) } const clearAreaSelection = () => { mapAnalysisRequestSequence.current += 1 setMapAnalysisDurationMs(null) 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 (activeResultDataset?.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: activeResultDataset.id, dataset_name: activeResultDataset.name, source_name: activeResultDataset.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 && activeResultDataset ? { project_id: selectedProjectId, mode: 'current', bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, dataset_id: activeResultDataset.id, area_id: areaId, partitioned: regionalPartitionedThemeActive, product_key: regionalRasterThemeActive ? String(activeResultDataset.source_metadata?.['product_key'] ?? '') || undefined : undefined, partition_scope_key: regionalBathymetryThemeActive ? String(activeResultDataset.source_metadata?.['partition_scope_key'] ?? 'flanders') : 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] const onDemandProduct = analysisMode === 'current' ? onDemandProductMap.get(theme.id) : null if (!dataset && !onDemandProduct) { return } setActiveThemeId(theme.id) clearTemporalComparison() if (dataset) { 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 handleAnalysisModeKeyDown = ( event: KeyboardEvent, mode: 'current' | 'evolution', ) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return event.preventDefault() const nextMode = event.key === 'ArrowLeft' || event.key === 'Home' ? 'current' : event.key === 'ArrowRight' || event.key === 'End' ? 'evolution' : mode setExplorerMode(nextMode) window.requestAnimationFrame(() => { document.getElementById(`geo-analysis-tab-${nextMode}`)?.focus() }) } const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { let resolvedZones = selectedCoverageZones const scale = selectionAnalysisScale(bbox) if (analysisMode === 'current' && selectedProjectId) { const resolvedCoverage = await resolveCoverage({ minx: bbox.min_x, miny: bbox.min_y, maxx: bbox.max_x, maxy: bbox.max_y, }) if (!resolvedCoverage) { clearThemeInsights() return } resolvedZones = resolvedCoverage.intersected_zones } let resolvedProducts: PlannedOnDemandMapProduct[] = [] if (analysisMode === 'current' && scale !== 'overview') { const zoneProducts = resolvedZones ? onDemandProductsForZones(resolvedZones) : [] if (scale === 'detail' || !selectedProjectId) { resolvedProducts = zoneProducts .filter((product) => productSupportsSelection(product, bbox)) .map((product) => ({ ...product, acquisitionBboxes: [bbox], })) } else { const detailTiles = splitSelectionBbox(bbox) const tileCoverage = await resolveCoveragePartitions(detailTiles) if (!tileCoverage) { clearThemeInsights() return } const grouped = new Map() for (const item of tileCoverage) { for (const product of onDemandProductsForZones(item.coverage.intersected_zones)) { if (product.kind === 'thematic_raster' || !productSupportsSelection(product, bbox)) continue const key = `${product.kind}:${product.productKey}` const existing = grouped.get(key) if (existing) { existing.acquisitionBboxes.push(item.bbox) } else { grouped.set(key, { ...product, acquisitionBboxes: [item.bbox] }) } } } for (const product of zoneProducts.filter( (candidate) => candidate.kind === 'thematic_raster' && productSupportsSelection(candidate, bbox), )) { grouped.set(`${product.kind}:${product.productKey}`, { ...product, acquisitionBboxes: [bbox], }) } resolvedProducts = [...grouped.values()] } } const availableThemes: Array> = [] for (const theme of DATA_THEMES) { const dataset = themeDatasetMap[theme.id] if (dataset) { availableThemes.push({ themeId: theme.id, dataset, partitioned: regionalScopeSelected && (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)), }) continue } const onDemandProducts = resolvedProducts.filter((product) => product.theme === theme.id) if (onDemandProducts.length > 0) { for (const onDemandProduct of onDemandProducts) { availableThemes.push({ themeId: theme.id, acquisition: { kind: onDemandProduct.kind, productKey: onDemandProduct.productKey, displayName: onDemandProduct.displayName, }, acquisitionBboxes: onDemandProduct.acquisitionBboxes, }) } continue } } await loadThemeInsights(bbox, availableThemes, areaId) } const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { const requestId = mapAnalysisRequestSequence.current + 1 mapAnalysisRequestSequence.current = requestId const startedAt = Date.now() setMapAnalysisDurationMs(null) setSelectionBbox(bbox) const tasks: Array> = [loadAllThemeResults(bbox, areaId)] if (activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive) { tasks.push(onRunMapSelectionExtract(bbox, areaId)) } if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId)) } try { await Promise.all(tasks) } finally { if (mapAnalysisRequestSequence.current === requestId) { setMapAnalysisDurationMs(Date.now() - startedAt) } } } 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, areaIdForSelection(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('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.') return } if (!selectedMapQaReferenceDatasetId) { setFullWorkflowError('Kies eerst een referentielaag voor de kwaliteitscontrole.') return } setFullWorkflowRunning(true) setFullWorkflowError(null) try { setFullWorkflowStatus('Laatste bewaarde resultaatlaag opnieuw controleren...') const qaResult = await onRunMapSelectionQa(latestSelectionDataset) setFullWorkflowStatus(qaResult ? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.' : 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.') } catch (error) { setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.') setFullWorkflowStatus('De werkstroom is gestopt.') } finally { setFullWorkflowRunning(false) } return } if (!selectedMapDataset || !bbox) { setFullWorkflowError('Kies een kaartlaag en een werkgebied of laagbegrenzing.') return } setFullWorkflowRunning(true) setFullWorkflowError(null) try { setFullWorkflowStatus('1/4 Bewaarde kaartobjecten selecteren...') setSelectionBbox(bbox) const selectionAreaId = areaIdForSelection(bbox) const selection = await onRunMapSelectionExtract(bbox, selectionAreaId) if (!selection) { setFullWorkflowError('De ruimtelijke selectie kon niet worden afgerond.') setFullWorkflowStatus('Stopped at query.') return } setFullWorkflowStatus('2/4 Saving derived result dataset...') const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId) if (!derived) { setFullWorkflowError('De afgeleide resultaatlaag kon niet worden aangemaakt.') setFullWorkflowStatus('Stopped at dataset save.') return } setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...') await onExportMapSelection(bbox, selectionAreaId) if (selectedMapQaReferenceDatasetId) { setFullWorkflowStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...') const qaResult = await onRunMapSelectionQa(derived) setFullWorkflowStatus(qaResult ? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.' : 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.') } else { setFullWorkflowStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.') } } catch (error) { setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.') setFullWorkflowStatus('De werkstroom is gestopt.') } finally { setFullWorkflowRunning(false) } } if (!advancedMode) { return (

{activeScopeLabel} · geografische verkenner

Gebied analyseren

Kies een thema, teken een rechthoek en lees de beschikbare gegevens.

{workspaceLoading ? (
) : workspaceError ? (
De werkruimte kon niet volledig worden geladen {workspaceError}
) : null}
2

Selecteer een gebied

{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : !activeThemeAvailable ? 'Teken een rechthoek om de databeschikbaarheid voor dit gebied te controleren.' : regionalRasterThemeActive ? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.' : onDemandThemeActive ? 'Teken een rechthoek; officiële regionale kaartbronnen worden begrensd opgehaald, bewaard en hergebruikt.' : regionalBathymetryThemeActive ? 'Teken een rechthoek of analyseer Vlaanderen; alleen overlappende VHA-partities worden samengevoegd.' : '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' : regionalBathymetryThemeActive ? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities` : activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : activeOnDemandMapProduct?.displayName ?? 'niet beschikbaar'} {usesDefaultOsmBasemap ? Ondergrond: OpenStreetMap : null}
) } return (

Ruimtelijke controle

Kaartwerkruimte

{mapFeatureCollection ? `${mapFeatureCount} objecten` : viewportVectorEnabled ? 'zoom in om te laden' : 'geen laag'}
{usesDefaultOsmBasemap ? (
Publieke kaartondergrond De publieke OpenStreetMap-ondergrond is actief. Configureer voor intensief gebruik een eigen kaartstijl.
) : null}
Kaartinhoud
onSetAreaLayerOpacity(Number(event.target.value))} data-testid="map-area-opacity" />
onSetMapLayerOpacity(Number(event.target.value))} data-testid="map-layer-opacity" />
{mapLayerLabel} {selectedMapDataset ? `Databaselaag: ${selectedMapDataset.name}` : 'Geen databaselaag gekozen'} {areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Geen werkgebied geladen'} {mapFeatureCollection ? `${mapFeatureCount} objecten geladen` : viewportVectorEnabled ? 'Databaselaag gekozen; zichtbare objecten laden volgens de kaartuitsnede' : 'Geen vector- of resultaatlaag geladen'} {viewportVectorEnabled && viewportVectorStatus ? ( {viewportVectorStatus} ) : null}
Details van de kaartlagen {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Kaartuitsnedelaag gekozen' : 'Geen actieve laag'}
Werkgebied {selectedMapArea?.name ?? 'Geen gebied geselecteerd'} {areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Werkgebiedlaag uitgeschakeld'}
Actieve kaartlaag {mapLayerLabel} {mapLayerSourceLabel}
Status kaartobjecten {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Wachten op detail van de kaartuitsnede' : 'Geen laag getekend'} {mapLayerProvenance}
Kaartbewijs kwaliteitscontrole {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewijsobjecten` : 'Geen bewijslaag'} {qualityEvidenceLoading ? 'Bewaard bewijs laden' : 'Overeenkomsten, onterecht gevonden en gemiste objecten'}
Bron van de kaartlaag {mapLayerSourceLabel}
Herkomst {mapLayerProvenance}
Weergavestatus {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Laden volgens kaartuitsnede actief' : 'Geen actieve vector- of resultaatlaag'}
Kaartbewijs {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} getekend` : 'uitgeschakeld'}
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
Kaartbewijs kwaliteitscontrole {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewaarde objecten` : 'Niet geladen'} {qualityEvidenceError ?

{qualityEvidenceError}

: null} {qualityEvidenceWarnings.length > 0 ? (

{qualityEvidenceWarnings.length} {qualityEvidenceWarnings.length === 1 ? 'bewijsverwijzing kon' : 'bewijsverwijzingen konden'} niet worden teruggevonden.

) : null}
Overeenkomst resultaat Overeenkomst referentie Onterecht gevonden Gemist
{onClearQualityEvidence ? ( ) : null}
) : null} {!mapFeatureCollection && !viewportVectorEnabled ? (
Geen actieve vector- of resultaatlaag

Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen.

{availableMapDatasets.length > 0 ? ( <>

Open een beschikbare vectorlaag

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

Nog geen gebruiksklare vectorlagen beschikbaar. Voeg eerst een vectorbestand toe.

)}
) : null} {mapSelectionBbox ? (

Dekking van deze selectie

{activeTheme.label}

{coverageLoading ? controleren : null}
{coverageError ?

{coverageError}

: null} {coverageDurationMs !== null ? (

Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}. {coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''}

) : null} {coverage ? ( <>
{coverage.intersected_zones.map((zone) => ( {coverageZoneLabel(zone)} ))} {coverage.outside_supported_scope ? deels buiten scope : null}
{activeCoverageItems.map((item) => (
{coverageZoneLabel(item.zone)} {coverageStatusLabel(item.status)} {item.source_names.join(', ') || 'Geen broncontract'}
))} {activeCoverageItems.length === 0 ? (

Deze selectie raakt geen bewaarde Belgische land- of zeezone.

) : null}
{coverageCounts.operational} beschikbaar {coverageCounts.partial} gedeeltelijk {coverageCounts.not_configured} niet gekoppeld {coverageCounts.unsupported} niet ondersteund
{coverage.warnings.map((warning) =>

{warning}

)} ) : !coverageLoading && !coverageError ? (

De dekkingsmatrix wordt bepaald zodra de selectie volledig is.

) : null}
) : null}

Operationele GIS-controle

Databaselaag doorzoeken

{mapSelectionResult ? `${mapSelectionResult.feature_count} resultaten` : 'gereed'}

Kies een bewaarde vectorlaag en doorzoek daarna de objecten in PostGIS binnen het werkgebied of de volledige laag.

Databaselaag {selectedMapDataset?.name ?? 'Kies een laag'}
Begrenzing werkgebied {selectedAreaBbox ? 'beschikbaar' : 'ontbreekt'}
Begrenzing kaartlaag {activeLayerBbox ? 'beschikbaar' : 'ontbreekt'}
Resultaat {mapSelectionResult ? `${mapSelectionResult.feature_count} objecten` : 'nog niet uitgevoerd'}
{mapSelectionError ?

{mapSelectionError}

: null}
1 Kaartlaag {selectedMapDataset ? selectedMapDataset.name : 'Kies een databaselaag'}
2 Begrenzing {currentSelectionBbox ? 'Gebiedsbegrenzing gereed' : 'Gebruik het werkgebied of de laagbegrenzing'}
3 Selectie {mapSelectionResult ? `${mapSelectionResult.feature_count} bewaarde objecten` : 'Voer de ruimtelijke selectie uit'}
4 Resultaatlaag {latestSelectionDatasetName ?? 'Bewaar het selectieresultaat'}
5 Kwaliteitscontrole {mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n.v.t.'}` : 'Vergelijk met een referentielaag'}
6 Download {latestSelectionExportPath ? 'GeoJSON-bestand gereed' : 'Bewaar een downloadbestand'}
Selecteren, bewaren, controleren en downloaden {fullWorkflowStatus}
{selectionDatasetError ?

{selectionDatasetError}

: null} {selectionExportError ?

{selectionExportError}

: null} {mapSelectionQaError ?

{mapSelectionQaError}

: null} {fullWorkflowError ?

{fullWorkflowError}

: null}
Geavanceerde selectie en inspectie Coördinaten, objectextractie en ruwe eigenschappen

Bewaarde vectorobjecten

Gebiedsselectie

{mapSelectionResult ? `${mapSelectionResult.feature_count} geselecteerd` : bboxSelectionMode ? 'selecteren' : 'gereed'}
{bboxSelectionMode ? (firstSelectionCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'} {formatBboxLabel(currentSelectionBbox)}
{mapSelectionError ?

{mapSelectionError}

: null} {mapSelectionResult ? (
Objecten {mapSelectionResult.feature_count}
Limiet {mapSelectionResult.limit}
Afgekapt {mapSelectionResult.truncated ? 'ja' : 'nee'}
Bron Bewaarde databankobjecten
{selectionExportError ?

{selectionExportError}

: null} {latestSelectionExportPath ? (

De geselecteerde download is bewaard.

) : null} {selectionDatasetError ?

{selectionDatasetError}

: null} {latestSelectionDatasetName ? (

Bewaarde afgeleide laag: {latestSelectionDatasetName}

) : null} {latestSelectionDatasetName ? (
{mapSelectionQaError ?

{mapSelectionQaError}

: null} {mapSelectionQaResult ? (

Kaartbewijs

Vergelijking van de bewaarde selectie

Precisie {mapSelectionQaResult.precision ?? 'n.v.t.'}
Herkenningsgraad {mapSelectionQaResult.recall ?? 'n.v.t.'}
F1 {mapSelectionQaResult.f1_score ?? 'n.v.t.'}
Gemiddelde overlap {mapSelectionQaResult.mean_iou ?? 'n.v.t.'}
Overeenkomsten {mapSelectionQaResult.matches}
Onterecht gevonden {mapSelectionQaResult.false_positives}
Gemist {mapSelectionQaResult.false_negatives}
Status bewijs {latestMapSelectionQualityCheckId ? 'bewaard' : 'niet bewaard'}
{mapSelectionQaResult.warnings.length > 0 ? (
Aandachtspunten
    {mapSelectionQaResult.warnings.map((warning) => (
  • {warning}
  • ))}
) : null}
) : null}
) : null} {areaSelectionPreviewFeatures.length > 0 ? (
{areaSelectionPreviewFeatures.map((feature, index) => ( ))}
Object Klasse Bronreferentie
{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)} {String(feature.properties?.['feature_class'] ?? 'n.v.t.')} {String(feature.properties?.['source_feature_id'] ?? 'n.v.t.')}
) : (

Geen bewaarde vectorobjecten kruisen deze selectie.

)}
) : null}

Geselecteerd object

Selectie en extractie

{selectedMapFeature ? 'gereed' : 'wachten'}
{selectedMapFeature ? ( <> {isBathymetryProfile ? (
Waterloop {String(featureProperties?.['watercourse_name'] ?? 'Onbekende waterloop')}
Profiel {String(featureProperties?.['profile_number'] ?? 'n.v.t.')}
Meetdatum {String(featureProperties?.['measurement_date'] ?? 'Niet geregistreerd')}
Geregistreerde diepte {typeof featureProperties?.['recorded_depth_m'] === 'number' ? `${featureProperties['recorded_depth_m'].toLocaleString('nl-BE')} m` : 'Niet als veld beschikbaar'}
{bathymetryDocumentUrl ? ( Officieel profielblad openen ) : ( Voor dit meetpunt is geen digitaal profielblad gekoppeld. )}

Historisch dwarsprofiel. Dit punt is geen continue actuele bodemkaart en levert zonder gelijktijdig waterpeil geen actueel watervolume.

) : null}
Geometrie {featureGeometrySummary.geometryType}
Coördinaten {featureGeometrySummary.coordinateCount}
Eigenschappen {featureExtractionEntries.length}
BBox EPSG:4326 {featureGeometrySummary.bboxLabel}
{featureExtractionEntries.length > 0 ? (
{featureExtractionEntries.map(([key, value]) => ( ))}
Eigenschap Waarde
{key} {typeof value === 'object' ? JSON.stringify(value) : String(value)}
) : (

Het geselecteerde object heeft geometrie maar geen bewaarde eigenschappen.

)} ) : (
Geen object geselecteerd

Klik op een zichtbaar kaartobject om de eigenschappen en GeoJSON te bekijken.

)}

Objectinspectie

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

Klik op een zichtbaar kaartobject om de eigenschappen te bekijken.

)}
) }