Files
geointel/frontend/src/components/map/MapWorkspace.tsx
T
Codex 0aff8e3b8c
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
feat(scope): make Belgium and North Sea operational default
2026-07-22 02:11:48 +02:00

3707 lines
162 KiB
TypeScript

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,
datasetIntersectsSelection,
deduplicateTemporalSnapshots,
downloadJsonFile,
formatArea,
formatBboxLabel,
formatPercentage,
formatTemporalMetric,
getFeatureBBox,
getFeatureCollectionBBox,
getFeatureGeometrySummary,
isMunicipalityAreaName,
isSelectionBoundedDataset,
normalizeBboxFromCorners,
operationalScopeProjectLabel,
parseBboxInput,
persistedDatasetSupportsSelection,
productCoversZones,
readablePropertyName,
resultCountLabel,
resultMetricLabel,
safeFileStem,
selectedAreaCoverageZones,
selectedFeatureCollection,
selectionAnalysisScale,
selectionAreaSquareMetres,
selectionDimensions,
selectionFeatureLimit,
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: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'bathymetry',
label: 'Waterbodem',
shortLabel: 'Dwarsprofielen',
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
},
{
id: 'flood_hazard',
label: 'Overstroming',
shortLabel: 'Overstroomd oppervlak',
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
},
{
id: 'elevation',
label: 'Hoogte & reliëf',
shortLabel: 'Hoogte',
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
},
{
id: 'accessibility',
label: 'Bereikbaarheid',
shortLabel: 'Knooppuntwaarde',
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
},
{
id: 'services',
label: 'Voorzieningen',
shortLabel: 'Voorzieningenniveau',
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
{
id: 'maritime_planning',
label: 'Maritieme planning',
shortLabel: 'Plan- en gebruikszones',
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
},
{
id: 'marine_environment',
label: 'Mariene rapportagezones',
shortLabel: 'Zeegebieden',
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
},
]
const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
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<CoverageStatus, string> = {
operational: 'Beschikbaar',
partial: 'Gedeeltelijk',
not_configured: 'Niet gekoppeld',
unsupported: 'Niet ondersteund',
}
return labels[status]
}
function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
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<DataThemeId, { fill: string; line: string }> = {
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<string, DatasetCreateResponse[]>()
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())
.map(([key, items]) => {
const ordered = deduplicateTemporalSnapshots(items)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.filter((group) => group.items.length >= 2)
.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<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onSelectOrthophotoProduct: (productKey: string) => void
onClearQualityEvidence?: () => void
onRefreshProjectData: () => Promise<unknown>
onOpenAssistant: () => void
onOpenExports: () => void
}
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<DataThemeId>(() => {
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<DataThemeId>(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<string | null>(null)
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState<number | null>(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<string, DatasetCreateResponse>()
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<DataThemeId, DatasetCreateResponse | null>
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<DataThemeId, DatasetCreateResponse[]>,
[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<Record<CoverageStatus, number>>(
(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} · automatisch 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 · automatisch 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 · automatisch 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 · automatisch 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} · automatisch 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} · automatisch bij selectie`,
attribution: floodProduct.attribution,
limitationMessage: floodProduct.limitation_message,
})
}
return result
}, [
activeScopeProject?.name,
officialMapProducts,
selectedCoverageZones,
selectedDhmvProductKey,
selectedFloodHazardProductKey,
])
const onDemandProductMap = useMemo(() => {
const result = new Map<DataThemeId, OnDemandMapProduct>()
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
}
const dataset = themeDatasetMap[theme.id]
if (!dataset || !persistedDatasetSupportsSelection(dataset, mapSelectionBbox)) {
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 activeThemeSupportsCurrentSelection = selectionRelevantThemes.some((theme) => theme.id === activeTheme.id)
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<DataThemeId, TemporalSeriesGroup[]>,
[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 earlierTemporalOptions = activeTemporalSeries.slice(0, -1)
const selectedEarlierSnapshot = activeTemporalSeries.find((dataset) => dataset.id === earlierDatasetId)
const selectedLaterSnapshot = activeTemporalSeries.find((dataset) => dataset.id === laterDatasetId)
const selectedEarlierTime = new Date(selectedEarlierSnapshot?.observed_at ?? 0).getTime()
const laterTemporalOptions = activeTemporalSeries.filter(
(dataset) => new Date(dataset.observed_at ?? 0).getTime() > selectedEarlierTime,
)
const temporalSelectionValid = Boolean(
selectedEarlierSnapshot
&& selectedLaterSnapshot
&& selectedEarlierSnapshot.id !== selectedLaterSnapshot.id
&& selectedEarlierTime < new Date(selectedLaterSnapshot.observed_at ?? 0).getTime(),
)
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 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`
: 'Automatisch bij selectie'
: 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 selectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Het gekozen thema kan over maximaal ${partitionCount} begrensde bronpartities worden verwerkt; teken een kleiner gebied wanneer een fijnmazige rasterbron buiten het veilige pixelbudget valt.`
}
if (mapSelectionScale === 'overview') {
return `Overzichtsselectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Het gekozen detailthema wordt niet automatisch op deze schaal bevraagd. Teken maximaal 50 x 50 km voor regionale thema's en ongeveer 16 x 16 km voor 5 m-rasters.`
}
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<string, Set<string>>()
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<string>()
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<HTMLButtonElement>,
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 loadSelectedThemeResult = 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<string, PlannedOnDemandMapProduct>()
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<MapThemeQuery<DataThemeId>> = []
const resultFeatureLimit = selectionFeatureLimit(bbox)
for (const theme of [activeTheme]) {
const dataset = themeDatasetMap[theme.id]
const partitioned = Boolean(
dataset
&& regionalScopeSelected
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
)
const coveringPartitions = partitioned
? themePartitionMap[theme.id].filter((partition) => datasetIntersectsSelection(partition, bbox))
: []
const persistedCoverageAvailable = Boolean(
dataset
&& persistedDatasetSupportsSelection(dataset, bbox)
&& (!partitioned || coveringPartitions.length > 0),
)
if (dataset && persistedCoverageAvailable) {
availableThemes.push({
themeId: theme.id,
dataset,
datasetIds: coveringPartitions.map((partition) => partition.id),
featureLimit: resultFeatureLimit,
partitioned,
})
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,
featureLimit: resultFeatureLimit,
})
}
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<Promise<unknown>> = analysisMode === 'current'
? [loadSelectedThemeResult(bbox, areaId)]
: []
const activeDatasetSupportsSelection = !activeThemeDataset
|| persistedDatasetSupportsSelection(activeThemeDataset, bbox)
if (
analysisMode === 'current'
&& advancedMode
&& activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive
&& activeDatasetSupportsSelection
) {
tasks.push(onRunMapSelectionExtract(bbox, areaId))
}
if (analysisMode === 'evolution' && temporalSelectionValid) {
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 || !temporalSelectionValid) {
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 (
<section
className="geo-explorer"
data-testid="map-workspace"
aria-label={`Gebiedsverkenner ${activeScopeLabel}`}
aria-busy={workspaceLoading}
>
<header className="geo-explorer-header">
<div>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
<h2>Gebied analyseren</h2>
<p>Kies een thema, teken een rechthoek en lees de beschikbare gegevens.</p>
</div>
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
<button
id="geo-analysis-tab-current"
className={analysisMode === 'current' ? 'active' : ''}
type="button"
role="tab"
aria-selected={analysisMode === 'current'}
aria-controls="geo-explorer-results"
tabIndex={analysisMode === 'current' ? 0 : -1}
onClick={() => setExplorerMode('current')}
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'current')}
>
Laatste toestand
</button>
<button
id="geo-analysis-tab-evolution"
className={analysisMode === 'evolution' ? 'active' : ''}
type="button"
role="tab"
aria-selected={analysisMode === 'evolution'}
aria-controls="geo-explorer-results"
tabIndex={analysisMode === 'evolution' ? 0 : -1}
onClick={() => setExplorerMode('evolution')}
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'evolution')}
>
Evolutie
</button>
</div>
<button
className="secondary-action geo-explorer-advanced"
type="button"
onClick={() => setAdvancedMode(true)}
aria-expanded={advancedMode}
aria-controls="geo-advanced-workbench"
aria-label="Geavanceerde werkbank"
title="Geavanceerde werkbank"
>
<SlidersHorizontal aria-hidden="true" />
<span>Geavanceerde werkbank</span>
</button>
</header>
{workspaceLoading ? (
<div className="geo-bootstrap-status" role="status" aria-live="polite">
<span className="geo-loading-indicator" aria-hidden="true" />
<div>
<strong>Databronnen worden gecontroleerd</strong>
<small>Beschikbaarheid verschijnt zodra de nationale werkruimte volledig is geladen.</small>
</div>
</div>
) : workspaceError ? (
<div className="geo-bootstrap-status geo-bootstrap-status-error" role="alert">
<div>
<strong>De werkruimte kon niet volledig worden geladen</strong>
<small>{workspaceError}</small>
</div>
</div>
) : null}
<div className="geo-explorer-layout">
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
<div className="geo-panel-heading">
<span>1</span>
<div>
<h3>Thema</h3>
<p>Kies welke gegevens u wilt meten.</p>
</div>
</div>
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
<span>Ingeladen bereik</span>
<strong>{activeScopeLabel}</strong>
<small>
{workspaceLoading
? 'Gebieden en bronnen worden geladen'
: municipalityAreaCount > 0
? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar`
: 'Geen gemeentelijke onderverdeling in deze werkruimte'}
</small>
</div>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const onDemandProduct = onDemandProductMap.get(theme.id)
const partitions = themePartitionMap[theme.id]
const temporalGroups = themeTemporalSeriesMap[theme.id]
const temporalGroup = temporalGroups[0]
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
const available = !workspaceLoading && (analysisMode === 'current'
? Boolean(dataset || onDemandProduct)
: Boolean(dataset) && evolutionAvailable)
const active = activeThemeId === theme.id
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
return (
<button
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
disabled={!available}
key={theme.id}
type="button"
onClick={() => selectDataTheme(theme)}
aria-pressed={active}
>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>
{workspaceLoading
? 'Beschikbaarheid controleren'
: analysisMode === 'evolution'
? evolutionAvailable
? `${temporalGroup.items.length} meetmomenten${temporalRange ? ` · ${temporalRange}` : ''}`
: dataset
? 'Alleen huidige toestand'
: 'Bron nog niet ingeladen'
: dataset
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
: onDemandProduct
? onDemandProduct.availabilityLabel
: 'Bron nog niet ingeladen'}
</small>
</span>
<i>
{workspaceLoading
? 'Laden'
: analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Automatisch' : 'Ontbreekt'}
</i>
</button>
)
})}
</div>
<div className="geo-source-summary">
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
<strong>
{workspaceLoading
? 'Databronnen worden geladen'
: analysisOverlayActive
? mapLayerLabel
: analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
: regionalBathymetryThemeActive
? 'VHA-dwarsprofielen Vlaanderen'
: activeThemeDataset
? getDatasetDisplayName(activeThemeDataset)
: activeOnDemandMapProduct?.displayName ?? 'Geen databron beschikbaar'}
</strong>
<small>
{workspaceLoading
? 'Even geduld; ontbrekende bronnen worden pas na de laadcontrole gemeld.'
: analysisOverlayActive
? `${mapLayerSourceLabel} · AI-resultaat, controle vereist`
: analysisMode === 'evolution'
? activeTemporalSeries.length >= 2
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}`
: 'Voor dit thema is nog geen tweede officieel meetmoment beschikbaar.'
: regionalBathymetryThemeActive
? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd`
: activeThemeDataset
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}${onDemandProductMap.get(activeTheme.id) ? ' · ontbrekende lokale dekking wordt automatisch aangevuld' : ''}`
: activeOnDemandMapProduct
? `${activeOnDemandMapProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen`
: activeTheme.description}
</small>
</div>
{officialMapProductsLoading && selectedProjectId ? (
<p className="geo-data-notice">Beschikbare officiële regionale kaartbronnen worden gecontroleerd</p>
) : null}
{officialMapProductsError && selectedProjectId ? (
<p className="error">{officialMapProductsError}</p>
) : null}
{analysisMode === 'current' && activeTheme.id === 'elevation' && officialMapProducts.dhmv.length > 0 ? (
<label className="geo-scope-select">
Hoogtemodel
<select
aria-label="Hoogtemodel"
value={selectedDhmvProductKey}
onChange={(event) => {
const productKey = event.target.value as 'dtm_1m' | 'dsm_1m'
setSelectedDhmvProductKey(productKey)
clearThemeInsights()
const dataset = availableMapDatasets.find(
(item) =>
item.source_name === 'digitaal_vlaanderen_dhmv'
&& datasetProductKey(item) === productKey
&& datasetCoversSelectedArea(
item,
selectedMapAreaId,
selectedMapArea?.name,
regionalScopeSelected,
),
)
if (dataset) {
onOpenDatasetInMap(dataset)
}
}}
>
{officialMapProducts.dhmv.map((product) => (
<option key={product.key} value={product.key}>{product.display_name}</option>
))}
</select>
<small>DTM meet het maaiveld; DSM bevat ook gebouwen en vegetatie.</small>
</label>
) : null}
{analysisMode === 'current' && activeTheme.id === 'flood_hazard' && officialMapProducts.floodHazard.length > 0 ? (
<label className="geo-scope-select">
Overstromingsscenario
<select
aria-label="Overstromingsscenario"
value={selectedFloodHazardProductKey}
onChange={(event) => {
const productKey = event.target.value
const dataset = floodHazardDatasets.find((item) => datasetProductKey(item) === productKey)
setSelectedFloodHazardProductKey(productKey)
setSelectedFloodHazardDatasetId(dataset?.id ?? '')
clearThemeInsights()
if (dataset) {
onOpenDatasetInMap(dataset)
}
}}
>
{officialMapProducts.floodHazard.map((product) => (
<option key={product.key} value={product.key}>{product.display_name}</option>
))}
</select>
<small>Gemodelleerde maximale waterdiepte voor de gekozen kans en klimaatprojectie; geen actuele waterstand.</small>
</label>
) : analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? (
<label className="geo-scope-select">
Overstromingsscenario
<select
aria-label="Overstromingsscenario"
value={activeThemeDataset?.id ?? ''}
onChange={(event) => {
const dataset = floodHazardDatasets.find((item) => item.id === event.target.value)
setSelectedFloodHazardDatasetId(event.target.value)
clearThemeInsights()
if (dataset) {
onOpenDatasetInMap(dataset)
}
}}
>
{floodHazardDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{floodScenarioLabel(dataset)}</option>
))}
</select>
<small>Elke meting blijft gekoppeld aan deze kans en klimaatprojectie.</small>
</label>
) : null}
{analysisMode === 'evolution' ? (
<>
<div className="geo-time-controls" aria-label="Meetmomenten vergelijken">
{activeTemporalSeriesGroups.length > 1 ? (
<label className="geo-series-control">
Reeks
<select
value={activeTemporalSeriesGroup?.key ?? ''}
onChange={(event) => {
setSelectedTemporalSeriesKey(event.target.value)
clearTemporalComparison()
}}
>
{activeTemporalSeriesGroups.map((group) => (
<option key={group.key} value={group.key}>{group.label}</option>
))}
</select>
</label>
) : null}
<label>
Van
<select
value={earlierDatasetId}
onChange={(event) => {
const nextEarlierId = event.target.value
const nextEarlier = activeTemporalSeries.find((dataset) => dataset.id === nextEarlierId)
setEarlierDatasetId(nextEarlierId)
if (
nextEarlier
&& new Date(selectedLaterSnapshot?.observed_at ?? 0).getTime()
<= new Date(nextEarlier.observed_at ?? 0).getTime()
) {
setLaterDatasetId(activeTemporalSeries[activeTemporalSeries.length - 1]?.id ?? '')
}
clearTemporalComparison()
}}
disabled={earlierTemporalOptions.length === 0}
>
{earlierTemporalOptions.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
))}
</select>
</label>
<label>
Naar
<select value={laterDatasetId} onChange={(event) => { setLaterDatasetId(event.target.value); clearTemporalComparison() }} disabled={laterTemporalOptions.length === 0}>
{laterTemporalOptions.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
))}
</select>
</label>
<button
className="primary-action"
type="button"
disabled={!mapSelectionBbox || !temporalSelectionValid || temporalComparisonLoading}
onClick={runTemporalComparison}
>
{temporalComparisonLoading ? 'Vergelijken…' : 'Vergelijk periode'}
</button>
</div>
{activeSeriesIsDailyGrb ? (
<p className="geo-data-notice">
Dagelijkse GRB-edities tonen wijzigingen in de officiële registratie. Ze bewijzen niet dat een fysieke verandering exact tussen deze twee kalenderdagen plaatsvond.
</p>
) : null}
</>
) : null}
<label className="geo-scope-select">
Snel naar een gemeente (optioneel)
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
))}
</select>
</label>
</aside>
<div className="geo-map-stage">
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
<div className="geo-panel-heading geo-map-step">
<span>2</span>
<div>
<h3>Selecteer een gebied</h3>
<p>
{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.'}
</p>
</div>
</div>
<div className="geo-map-actions">
<button
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
disabled={(!activeThemeAvailable && !coverageSelectionAvailable) || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={startBboxSelection}
>
<BoxSelect aria-hidden="true" />
{bboxSelectionMode ? 'Teken op de kaart…' : 'Teken rechthoek'}
</button>
<button
className="secondary-action"
disabled={(!activeThemeAvailable && !coverageSelectionAvailable) || regionalRasterThemeActive || regionalOnDemandThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
type="button"
title={
regionalRasterThemeActive || regionalOnDemandThemeActive
? 'Teken een begrensde rechthoek voor deze regionale analyse.'
: undefined
}
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
>
<MapPinned aria-hidden="true" />
{regionalRasterThemeActive || regionalOnDemandThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'}
</button>
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
<Trash2 aria-hidden="true" />
Wis selectie
</button>
</div>
</div>
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
<GeoMap
data={explorerMapFeatureCollection}
dataFillColor={activeThemeMapStyle.fill}
dataLineColor={activeThemeMapStyle.line}
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={explorerSelectionFeatureCollection}
imageOverlays={activeImageOverlays}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
fitDataOnChange={fitMapDataOnChange}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
onMapBboxPreview={handleMapBboxPreview}
onMapBboxSelect={handleMapBboxSelect}
onViewportChange={onMapViewportChange}
/>
<div className="geo-map-legend" aria-label="Kaartlegende">
<span><i className="geo-legend-area" /> Werkgebied</span>
{thematicRasterImageOverlays.length > 0 ? (
<span className="geo-legend-thematic">
<i className={`geo-legend-ramp geo-legend-ramp-${activeTheme.id}`} />
<small>{thematicLegendMin} {thematicLegendMax}</small>
</span>
) : activeImageOverlays.length > 0 ? (
<span>
<i className="geo-legend-imagery" /> {activeImageOverlays[0].label}
{activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''}
</span>
) : null}
{analysisOverlayActive ? (
<>
<span><i className="geo-legend-layer geo-legend-layer-buildings" /> AI-kandidaten</span>
<span><i className="geo-legend-selection" /> Selectie</span>
</>
) : analysisMode === 'evolution' && temporalComparison?.object_changes.available ? (
<>
<span><i className="geo-legend-added" /> Nieuw</span>
<span><i className="geo-legend-removed" /> Verdwenen</span>
<span><i className="geo-legend-modified" /> Gewijzigd</span>
</>
) : (
<>
<span><i className={`geo-legend-layer geo-legend-layer-${activeTheme.id}`} /> {activeTheme.shortLabel}</span>
<span><i className="geo-legend-selection" /> Selectie</span>
</>
)}
</div>
{bboxSelectionMode ? (
<div className="geo-draw-instruction" role="status">
<strong>Rechthoek tekenen</strong>
<span>Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los.</span>
</div>
) : null}
{viewportVectorEnabled && viewportVectorStatus ? (
<div className={`geo-viewport-status geo-viewport-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
{viewportVectorStatus}
</div>
) : null}
</div>
</div>
<aside
id="geo-explorer-results"
className="geo-results-panel"
aria-label="Gebiedsanalyse"
aria-live="polite"
>
<div className="geo-panel-heading">
<span>3</span>
<div>
<h3>Inzichten</h3>
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
</div>
</div>
{analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
<strong>{selectedOrthophotoProduct?.supports_detection ? 'Gebouwen herkennen op luchtbeeld' : 'Historisch luchtbeeld bekijken'}</strong>
<small>
{selectedOrthophotoProduct?.supports_detection
? 'Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.'
: 'Officieel historisch mozaïek. Geen vergelijking met de actuele GRB-toestand.'}
</small>
</div>
<label className="geo-orthophoto-product">
<span>Luchtbeeld</span>
<select
value={selectedOrthophotoProductKey}
onChange={(event) => onSelectOrthophotoProduct(event.target.value)}
disabled={orthophotoAnalysisRunning}
>
{orthophotoProducts.map((product) => (
<option key={product.key} value={product.key}>{product.display_name}</option>
))}
</select>
</label>
<button
className="primary-action"
disabled={orthophotoAnalysisRunning}
type="button"
onClick={() => void onRunOrthophotoAnalysis(mapSelectionBbox)}
>
{orthophotoAnalysisStage === 'acquiring'
? 'Luchtbeeld ophalen...'
: orthophotoAnalysisStage === 'detecting'
? 'Gebouwen herkennen...'
: orthophotoAnalysisStage === 'validating'
? 'Controleren...'
: orthophotoAnalysisStage === 'complete'
? selectedOrthophotoProduct?.supports_detection ? 'Opnieuw analyseren' : 'Opnieuw tonen'
: selectedOrthophotoProduct?.supports_detection ? 'Herken gebouwen' : 'Toon luchtbeeld'}
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
{orthophotoAnalysisQuality ? (
<>
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
<div><span>Kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
<div><span>Strikte matches</span><strong>{orthophotoAnalysisQuality.matches.toLocaleString('nl-BE')}</strong></div>
<div><span>Precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
<div><span>Herkenningsgraad</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
<div><span>Fout / gemist</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')} / {orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
</div>
{orthophotoAnalysisQuality.box_to_footprint_diagnostics ? (
<p className="geo-image-quality-context">
Rechthoekcontrole: {orthophotoAnalysisQuality.box_to_footprint_diagnostics.envelope_matches.toLocaleString('nl-BE')} matches,
waarvan {orthophotoAnalysisQuality.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count.toLocaleString('nl-BE')} mogelijke vormverschillen. De kerncijfers hierboven gebruiken strikte GRB-footprints.
</p>
) : null}
</>
) : null}
</div>
) : null}
{!mapSelectionBbox ? (
<div className="geo-results-empty">
<strong>Nog geen gebied geselecteerd</strong>
<p>Teken een rechthoek op de kaart. De analyse start automatisch zodra je loslaat.</p>
</div>
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
<div className="geo-results-loading" role="status">
<span />
<strong>De gekozen officiële bron wordt begrensd geladen en geanalyseerd</strong>
</div>
) : (
<>
{analysisMode === 'evolution' ? (
temporalComparison ? (
<>
<div className="geo-primary-metrics geo-temporal-metrics">
<div>
<span>{formatObservationDate(temporalComparison.earlier.observed_at)}</span>
<strong>{formatTemporalMetric(temporalComparison.metric.earlier_value, temporalComparison.metric.unit)}</strong>
</div>
<div>
<span>{formatObservationDate(temporalComparison.later.observed_at)}</span>
<strong>{formatTemporalMetric(temporalComparison.metric.later_value, temporalComparison.metric.unit)}</strong>
</div>
<div className={temporalComparison.metric.absolute_change >= 0 ? 'positive' : 'negative'}>
<span>Verschil</span>
<strong>
{temporalComparison.metric.absolute_change >= 0 ? '+' : ''}
{formatTemporalMetric(temporalComparison.metric.absolute_change, temporalComparison.metric.unit)}
</strong>
<small>
{temporalComparison.metric.percent_change == null
? 'geen percentage bij nulwaarde'
: `${temporalComparison.metric.percent_change >= 0 ? '+' : ''}${temporalComparison.metric.percent_change.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`}
</small>
</div>
</div>
<div className="geo-temporal-summary">
<div>
<span>Gebied</span>
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
</div>
<div>
<span>Meting</span>
<strong>{temporalComparison.metric.label}</strong>
</div>
<div>
<span>Methode</span>
<strong>{temporalComparison.metric.is_estimate ? 'Ruimtelijke schatting' : 'Exact'}</strong>
</div>
</div>
<TemporalTrendChart
timeline={temporalComparison.timeline ?? []}
metricKey={temporalComparison.metric.metric_key}
/>
{(temporalComparison.metrics ?? []).filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key).length > 0 ? (
<div className="geo-supporting-metrics" aria-label="Aanvullende historische metingen">
{(temporalComparison.metrics ?? [])
.filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key)
.map((metric) => (
<div key={metric.metric_key}>
<span>{metric.label}</span>
<strong>
{metric.absolute_change >= 0 ? '+' : ''}
{formatTemporalMetric(metric.absolute_change, metric.unit)}
</strong>
</div>
))}
</div>
) : null}
{temporalComparison.object_changes.available ? (
<div className="geo-change-counts" aria-label="Objectwijzigingen">
<span><strong>{temporalComparison.object_changes.added_count ?? 0}</strong> nieuw</span>
<span><strong>{temporalComparison.object_changes.removed_count ?? 0}</strong> verdwenen</span>
<span><strong>{temporalComparison.object_changes.modified_count ?? 0}</strong> gewijzigd</span>
</div>
) : null}
{temporalComparison.warnings.map((warning) => (
<p className="geo-data-notice" key={warning}>{warning}</p>
))}
</>
) : (
<div className="geo-results-empty">
<strong>Klaar om te vergelijken</strong>
<p>Kies twee meetmomenten en gebruik Vergelijk periode. Bij een nieuwe rechthoek wordt de vergelijking automatisch herhaald.</p>
</div>
)
) : (
<>
<div className="geo-primary-metrics">
<div>
<span>Oppervlakte selectie</span>
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
</div>
<div>
<span>{activeMetricLabel}</span>
<strong>{activeSelectionResult ? resultMetricLabel(activeSelectionResult) : 'Geen resultaat'}</strong>
</div>
<div>
<span>{activeSecondaryLabel}</span>
<strong>{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}</strong>
</div>
</div>
{activeSupportingMetrics.length > 0 ? (
<div className="geo-supporting-metrics" aria-label="Aanvullende gebiedsmetingen">
{activeSupportingMetrics.map((metric) => (
<div key={metric.metric_key}>
<span>{metric.metric_label}</span>
<strong>{selectionMetricLabel(metric)}</strong>
</div>
))}
</div>
) : null}
<div className="geo-theme-results">
<div className="geo-results-title-row">
<h4>Gekozen thema</h4>
<span>{activeSelectionResult ? 'Uitgelezen' : 'Geen meting'}</span>
</div>
{[activeTheme].flatMap((theme) => {
const dataset = themeDatasetMap[theme.id]
const items = themeResults.filter((result) => result.theme.id === theme.id)
const rows = items.length > 0 ? items : [null]
return rows.map((item, index) => {
const resultDataset = item?.dataset ?? (activeThemeSupportsCurrentSelection ? dataset : undefined)
const unavailableAtScale = !activeThemeSupportsCurrentSelection
return (
<div className="geo-theme-result-row" key={`${theme.id}:${resultDataset?.id ?? index}`}>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>
{resultDataset
? getDatasetSourceDisplayName(resultDataset)
: unavailableAtScale
? 'Niet geschikt voor deze selectieschaal of zone'
: 'Bronanalyse niet voltooid'}
</small>
</span>
<span className="geo-theme-result-value">
<b>{item ? resultMetricLabel(item.result) : unavailableAtScale ? 'Kies kleiner gebied' : 'Geen meting'}</b>
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
</span>
</div>
)
})
})}
{selectionScaleNotice ? (
<p className="geo-data-notice">{selectionScaleNotice}</p>
) : null}
</div>
</>
)}
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
) : null}
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
) : null}
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
<details className="geo-result-details">
<summary>Kenmerken van de gevonden objecten</summary>
<dl>
{selectedResultProperties.map(({ key, values }) => (
<div key={key}>
<dt>{readablePropertyName(key)}</dt>
<dd>{values.join(', ')}</dd>
</div>
))}
</dl>
</details>
) : null}
{analysisMode === 'current' && selectedMapFeature ? (
<div className="geo-selected-feature">
<span>Geselecteerd object</span>
<strong>{String(selectedMapFeature.properties?.['name'] ?? selectedMapFeature.properties?.['source_feature_id'] ?? selectedMapFeature.id ?? 'Object')}</strong>
<small>Klik elders op de kaart om een ander object uit te lezen.</small>
</div>
) : null}
{analysisMode === 'current' ? (
<div className="geo-result-actions">
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeResult}>
{activeResultDataset?.dataset_type === 'raster' ? 'Download analyse' : 'Download GeoJSON'}
</button>
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeResult}>Kopieer gegevens</button>
</div>
) : null}
{analysisMode === 'evolution' && temporalComparison ? (
<div className="geo-result-actions">
<button className="secondary-action" type="button" onClick={downloadTemporalComparison}>Download vergelijking</button>
<button className="secondary-action" type="button" onClick={copyTemporalComparison}>Kopieer vergelijking</button>
</div>
) : null}
{activeSelectionResult || temporalComparison ? (
<div className="geo-result-next-actions" aria-label="Volgende stap">
<span>
<strong>Analyse klaar</strong>
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
</span>
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
<button
className="secondary-action"
type="button"
disabled={selectionExporting}
onClick={() => void persistActiveResultAndOpenDownloads()}
>
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
</button>
</div>
) : null}
</>
)}
{mapAnalysisDurationMs !== null ? (
<p
className={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
? 'geo-performance-status geo-performance-status-warning'
: 'geo-performance-status'}
role={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS) ? 'alert' : 'status'}
>
Selectie geanalyseerd in {formatPerformanceDuration(mapAnalysisDurationMs)}.
{exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
? ' Dit overschrijdt het releasebudget van 15 seconden.'
: ''}
</p>
) : null}
</aside>
</div>
<footer className="geo-explorer-footer">
<span><strong>Werkgebied:</strong> {selectedMapArea?.name ?? 'Geen werkgebied geselecteerd'}</span>
<span>
<strong>Bron:</strong>{' '}
{analysisOverlayActive
? `${mapLayerLabel} · ${mapLayerSourceLabel}`
: analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks'
: regionalBathymetryThemeActive
? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities`
: activeThemeDataset
? getDatasetDisplayName(activeThemeDataset)
: activeOnDemandMapProduct?.displayName
?? 'niet beschikbaar'}
</span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
</footer>
</section>
)
}
return (
<section id="geo-advanced-workbench" className="map-workspace-shell" data-testid="map-workspace">
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
Terug naar gebiedsverkenner
</button>
<div className="panel-title-row">
<div>
<p className="eyebrow">Ruimtelijke controle</p>
<h2>Kaartwerkruimte</h2>
</div>
<span className="count-pill">
{mapFeatureCollection ? `${mapFeatureCount} objecten` : viewportVectorEnabled ? 'zoom in om te laden' : 'geen laag'}
</span>
</div>
<div className="map-control-surface" aria-label="Bediening van de kaartwerkruimte">
{usesDefaultOsmBasemap ? (
<div className="basemap-policy-notice" aria-label="Gebruik van de kaartondergrond">
<strong>Publieke kaartondergrond</strong>
<span>De publieke OpenStreetMap-ondergrond is actief. Configureer voor intensief gebruik een eigen kaartstijl.</span>
</div>
) : null}
<div className="map-toolbar">
<div className="map-layer-mode" aria-label="Soort kaartinhoud">
<span>Kaartinhoud</span>
<div role="group" aria-label="Bron van de kaartinhoud">
<button
type="button"
aria-pressed={mapContentMode === 'dataset'}
onClick={() => onSetMapContentMode('dataset')}
>
Database
</button>
<button
type="button"
aria-pressed={mapContentMode === 'analysis'}
disabled={!analysisLayerAvailable}
onClick={() => onSetMapContentMode('analysis')}
>
Analyseresultaat
</button>
</div>
</div>
<label>
Databaselaag
<select
value={selectedMapDatasetId}
onChange={(event) => openSelectedDatabaseLayer(event.target.value)}
disabled={availableMapDatasets.length === 0}
data-testid="map-database-layer-select"
>
<option value="">Kies een bewaarde vectorlaag</option>
{availableMapDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<label>
Werkgebied
<select
aria-label="Werkgebied"
value={selectedMapAreaId}
onChange={(event) => handleSelectMapArea(event.target.value)}
disabled={areas.length === 0}
data-testid="map-area-select"
>
<option value="">Geen werkgebied</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<div className="layer-control-card">
<label className="checkbox-row">
<input
checked={areaLayerVisible}
disabled={!areaFeatureCollection}
type="checkbox"
onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
data-testid="map-area-visible"
/>
Werkgebied
</label>
<input
aria-label="Dekking van het werkgebied"
disabled={!areaFeatureCollection}
max="0.7"
min="0.05"
step="0.05"
type="range"
value={areaLayerOpacity}
onChange={(event) => onSetAreaLayerOpacity(Number(event.target.value))}
data-testid="map-area-opacity"
/>
</div>
<div className="layer-control-card">
<label className="checkbox-row">
<input
checked={mapLayerVisible}
disabled={!mapFeatureCollection && !viewportVectorEnabled}
type="checkbox"
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
data-testid="map-layer-visible"
/>
Actieve laag
</label>
<input
aria-label="Dekking van de kaartlaag"
disabled={!mapFeatureCollection && !viewportVectorEnabled}
max="1"
min="0.05"
step="0.05"
type="range"
value={mapLayerOpacity}
onChange={(event) => onSetMapLayerOpacity(Number(event.target.value))}
data-testid="map-layer-opacity"
/>
</div>
<div className="map-status">
<strong>{mapLayerLabel}</strong>
<span>{selectedMapDataset ? `Databaselaag: ${selectedMapDataset.name}` : 'Geen databaselaag gekozen'}</span>
<span>{areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Geen werkgebied geladen'}</span>
<span>
{mapFeatureCollection
? `${mapFeatureCount} objecten geladen`
: viewportVectorEnabled
? 'Databaselaag gekozen; zichtbare objecten laden volgens de kaartuitsnede'
: 'Geen vector- of resultaatlaag geladen'}
</span>
{viewportVectorEnabled && viewportVectorStatus ? (
<span className={`viewport-vector-status viewport-vector-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
{viewportVectorStatus}
</span>
) : null}
</div>
</div>
</div>
<div className="map-frame-surface">
<GeoMap
data={mapFeatureCollection}
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={mapSelectionResult?.geojson ?? null}
qaEvidenceData={qualityEvidenceGeoJson}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
fitDataOnChange={fitMapDataOnChange}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
onViewportChange={onMapViewportChange}
/>
</div>
<details className="map-layer-details">
<summary>
<span>Details van de kaartlagen</span>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Kaartuitsnedelaag gekozen' : 'Geen actieve laag'}
</strong>
</summary>
<div className="map-context-summary" aria-label="Status van de kaartlagen">
<div>
<span>Werkgebied</span>
<strong>{selectedMapArea?.name ?? 'Geen gebied geselecteerd'}</strong>
<small>{areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Werkgebiedlaag uitgeschakeld'}</small>
</div>
<div>
<span>Actieve kaartlaag</span>
<strong>{mapLayerLabel}</strong>
<small>{mapLayerSourceLabel}</small>
</div>
<div>
<span>Status kaartobjecten</span>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Wachten op detail van de kaartuitsnede' : 'Geen laag getekend'}
</strong>
<small>{mapLayerProvenance}</small>
</div>
<div>
<span>Kaartbewijs kwaliteitscontrole</span>
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewijsobjecten` : 'Geen bewijslaag'}</strong>
<small>{qualityEvidenceLoading ? 'Bewaard bewijs laden' : 'Overeenkomsten, onterecht gevonden en gemiste objecten'}</small>
</div>
</div>
<div className="layer-provenance-rail" aria-label="Herkomst van de actieve kaartlaag">
<div>
<span>Bron van de kaartlaag</span>
<strong>{mapLayerSourceLabel}</strong>
</div>
<div>
<span>Herkomst</span>
<strong>{mapLayerProvenance}</strong>
</div>
<div>
<span>Weergavestatus</span>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Laden volgens kaartuitsnede actief' : 'Geen actieve vector- of resultaatlaag'}
</strong>
</div>
<div>
<span>Kaartbewijs</span>
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} getekend` : 'uitgeschakeld'}</strong>
</div>
</div>
</details>
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
<div className="qa-evidence-map-status" aria-label="Status van het kaartbewijs">
<div>
<span>Kaartbewijs kwaliteitscontrole</span>
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewaarde objecten` : 'Niet geladen'}</strong>
{qualityEvidenceError ? <p className="error">{qualityEvidenceError}</p> : null}
{qualityEvidenceWarnings.length > 0 ? (
<p className="muted">
{qualityEvidenceWarnings.length} {qualityEvidenceWarnings.length === 1 ? 'bewijsverwijzing kon' : 'bewijsverwijzingen konden'} niet worden teruggevonden.
</p>
) : null}
</div>
<div className="qa-evidence-legend" aria-label="Legenda van het kaartbewijs">
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-candidate" /> Overeenkomst resultaat</span>
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-reference" /> Overeenkomst referentie</span>
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-positive" /> Onterecht gevonden</span>
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-negative" /> Gemist</span>
</div>
{onClearQualityEvidence ? (
<button className="secondary-action" type="button" onClick={onClearQualityEvidence}>
Kaartbewijs wissen
</button>
) : null}
</div>
) : null}
{!mapFeatureCollection && !viewportVectorEnabled ? (
<div className="empty-state map-empty-state">
<strong>Geen actieve vector- of resultaatlaag</strong>
<p>Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen.</p>
{availableMapDatasets.length > 0 ? (
<>
<p className="eyebrow">Open een beschikbare vectorlaag</p>
<div className="map-empty-action-grid">
{availableMapDatasets.map((dataset) => (
<button className="secondary-action" key={dataset.id} type="button" onClick={() => onOpenDatasetInMap(dataset)}>
<span>{dataset.name}</span>
<strong>Open op kaart</strong>
</button>
))}
</div>
</>
) : (
<p className="muted">Nog geen gebruiksklare vectorlagen beschikbaar. Voeg eerst een vectorbestand toe.</p>
)}
</div>
) : null}
{mapSelectionBbox ? (
<section
className="coverage-resolution-surface"
aria-label="Datadekking van de kaartselectie"
aria-busy={coverageLoading}
>
<div className="panel-title-row">
<div>
<p className="eyebrow">Dekking van deze selectie</p>
<h3>{activeTheme.label}</h3>
</div>
{coverageLoading ? <span className="count-pill">controleren</span> : null}
</div>
{coverageError ? <p className="error" role="alert">{coverageError}</p> : null}
{coverageDurationMs !== null ? (
<p
className={coverageBudgetExceeded
? 'geo-performance-status geo-performance-status-warning'
: 'geo-performance-status'}
role={coverageBudgetExceeded ? 'alert' : 'status'}
>
Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}.
{coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''}
</p>
) : null}
{coverage ? (
<>
<div className="coverage-zone-row">
{coverage.intersected_zones.map((zone) => (
<span key={zone}>{coverageZoneLabel(zone)}</span>
))}
{coverage.outside_supported_scope ? <span className="coverage-zone-warning">deels buiten scope</span> : null}
</div>
<div className="coverage-active-theme-grid">
{activeCoverageItems.map((item) => (
<div className={`coverage-status-item coverage-status-${item.status}`} key={`${item.zone}:${item.theme}`}>
<span>{coverageZoneLabel(item.zone)}</span>
<strong>{coverageStatusLabel(item.status)}</strong>
<small>{item.source_names.join(', ') || 'Geen broncontract'}</small>
</div>
))}
{activeCoverageItems.length === 0 ? (
<p className="muted">Deze selectie raakt geen bewaarde Belgische land- of zeezone.</p>
) : null}
</div>
<div className="coverage-summary-row" aria-label="Samenvatting van alle themas">
<span>{coverageCounts.operational} beschikbaar</span>
<span>{coverageCounts.partial} gedeeltelijk</span>
<span>{coverageCounts.not_configured} niet gekoppeld</span>
<span>{coverageCounts.unsupported} niet ondersteund</span>
</div>
{coverage.warnings.map((warning) => <p className="muted" key={warning}>{warning}</p>)}
</>
) : !coverageLoading && !coverageError ? (
<p className="muted">De dekkingsmatrix wordt bepaald zodra de selectie volledig is.</p>
) : null}
</section>
) : null}
<div className="map-inspection-surface">
<div className="gis-test-run-surface" aria-label="Operationele GIS-controle">
<div className="panel-title-row">
<div>
<p className="eyebrow">Operationele GIS-controle</p>
<h3>Databaselaag doorzoeken</h3>
</div>
<span className="count-pill">{mapSelectionResult ? `${mapSelectionResult.feature_count} resultaten` : 'gereed'}</span>
</div>
<p className="muted">
Kies een bewaarde vectorlaag en doorzoek daarna de objecten in PostGIS binnen het werkgebied of de volledige laag.
</p>
<div className="gis-test-run-grid">
<div>
<span>Databaselaag</span>
<strong>{selectedMapDataset?.name ?? 'Kies een laag'}</strong>
</div>
<div>
<span>Begrenzing werkgebied</span>
<strong>{selectedAreaBbox ? 'beschikbaar' : 'ontbreekt'}</strong>
</div>
<div>
<span>Begrenzing kaartlaag</span>
<strong>{activeLayerBbox ? 'beschikbaar' : 'ontbreekt'}</strong>
</div>
<div>
<span>Resultaat</span>
<strong>{mapSelectionResult ? `${mapSelectionResult.feature_count} objecten` : 'nog niet uitgevoerd'}</strong>
</div>
</div>
<div className="feature-extract-actions">
<button
className="primary-action"
disabled={!selectedMapDataset || !mapFeatureCollection || (!selectedAreaBbox && !activeLayerBbox) || mapSelectionLoading}
type="button"
onClick={runQuickAoiExtract}
>
{mapSelectionLoading ? 'Selectie uitvoeren...' : 'Werkgebied of laag doorzoeken'}
</button>
<button
className="secondary-action"
disabled={!selectedAreaBbox}
type="button"
onClick={() => setSelectionBbox(selectedAreaBbox)}
>
Begrenzing werkgebied gebruiken
</button>
<button
className="secondary-action"
disabled={!activeLayerBbox}
type="button"
onClick={() => setSelectionBbox(activeLayerBbox)}
>
Begrenzing kaartlaag gebruiken
</button>
</div>
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
<div className="guided-gis-flow" aria-label="Begeleide operationele GIS-werkstroom">
<div className="guided-gis-steps">
<div className={selectedMapDataset ? 'complete' : ''}>
<span>1</span>
<strong>Kaartlaag</strong>
<small>{selectedMapDataset ? selectedMapDataset.name : 'Kies een databaselaag'}</small>
</div>
<div className={currentSelectionBbox ? 'complete' : ''}>
<span>2</span>
<strong>Begrenzing</strong>
<small>{currentSelectionBbox ? 'Gebiedsbegrenzing gereed' : 'Gebruik het werkgebied of de laagbegrenzing'}</small>
</div>
<div className={mapSelectionResult ? 'complete' : ''}>
<span>3</span>
<strong>Selectie</strong>
<small>{mapSelectionResult ? `${mapSelectionResult.feature_count} bewaarde objecten` : 'Voer de ruimtelijke selectie uit'}</small>
</div>
<div className={latestSelectionDatasetName ? 'complete' : ''}>
<span>4</span>
<strong>Resultaatlaag</strong>
<small>{latestSelectionDatasetName ?? 'Bewaar het selectieresultaat'}</small>
</div>
<div className={mapSelectionQaResult ? 'complete' : ''}>
<span>5</span>
<strong>Kwaliteitscontrole</strong>
<small>{mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n.v.t.'}` : 'Vergelijk met een referentielaag'}</small>
</div>
<div className={latestSelectionExportPath ? 'complete' : ''}>
<span>6</span>
<strong>Download</strong>
<small>{latestSelectionExportPath ? 'GeoJSON-bestand gereed' : 'Bewaar een downloadbestand'}</small>
</div>
</div>
<div className="guided-gis-actions">
<label className="guided-gis-run-mode">
Uitvoermodus
<select
value={fullWorkflowMode}
onChange={(event) => setFullWorkflowMode(event.target.value === 'reuse' ? 'reuse' : 'new')}
disabled={fullWorkflowRunning}
>
<option value="new">Nieuwe resultaatlaag en download maken</option>
<option value="reuse" disabled={!latestSelectionDataset}>
Laatste resultaatlaag opnieuw controleren
</option>
</select>
</label>
<button
className="primary-action guided-gis-full-run"
disabled={
fullWorkflowRunning ||
(fullWorkflowMode === 'new' && (!selectedMapDataset || !mapFeatureCollection || (!currentSelectionBbox && !selectedAreaBbox && !activeLayerBbox))) ||
(fullWorkflowMode === 'reuse' && (!latestSelectionDataset || !selectedMapQaReferenceDatasetId))
}
type="button"
onClick={runFullGisWorkflow}
>
{fullWorkflowRunning ? 'Volledige werkstroom uitvoeren...' : 'Volledige GIS-werkstroom uitvoeren'}
</button>
<div className="guided-gis-batch-status" aria-live="polite">
<strong>Selecteren, bewaren, controleren en downloaden</strong>
<span>{fullWorkflowStatus}</span>
</div>
<button
className="secondary-action"
disabled={!mapSelectionResult || !currentSelectionBbox || selectionDatasetSaving}
type="button"
onClick={saveAreaSelectionDataset}
>
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Resultaatlaag bewaren'}
</button>
<button
className="secondary-action"
disabled={!mapSelectionResult || !currentSelectionBbox || selectionExporting}
type="button"
onClick={saveAreaSelectionExport}
>
{selectionExporting ? 'Download bewaren...' : 'GeoJSON-download bewaren'}
</button>
<label>
Referentielaag
<select
value={selectedMapQaReferenceDatasetId}
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
disabled={!latestSelectionDatasetName || mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
>
<option value="">Kies een referentielaag</option>
{mapQaReferenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<button
className="primary-action"
disabled={!latestSelectionDatasetName || !selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
type="button"
onClick={() => onRunMapSelectionQa()}
>
{mapSelectionQaRunning ? 'Kwaliteit controleren...' : 'Kwaliteit controleren'}
</button>
<button
className="secondary-action"
disabled={!latestMapSelectionQualityCheckId}
type="button"
onClick={onOpenMapSelectionQualityEvidence}
>
Kaartbewijs openen
</button>
</div>
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
{fullWorkflowError ? <p className="error">{fullWorkflowError}</p> : null}
</div>
</div>
<details className="map-advanced-tools">
<summary>
<span>Geavanceerde selectie en inspectie</span>
<strong>Coördinaten, objectextractie en ruwe eigenschappen</strong>
</summary>
<div className="map-advanced-tools-body">
<div className="bbox-select-surface" aria-label="Gebiedsselectie en extractie">
<div className="panel-title-row">
<div>
<p className="eyebrow">Bewaarde vectorobjecten</p>
<h3>Gebiedsselectie</h3>
</div>
<span className="count-pill">
{mapSelectionResult ? `${mapSelectionResult.feature_count} geselecteerd` : bboxSelectionMode ? 'selecteren' : 'gereed'}
</span>
</div>
<div className="bbox-select-status">
<span>{bboxSelectionMode ? (firstSelectionCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'}</span>
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
</div>
<div className="bbox-select-grid" aria-label="Coördinaten van de gebiedsselectie">
<label>
Min lon
<input
inputMode="decimal"
value={bboxInput.min_x}
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_x: event.target.value }))}
/>
</label>
<label>
Min lat
<input
inputMode="decimal"
value={bboxInput.min_y}
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_y: event.target.value }))}
/>
</label>
<label>
Max lon
<input
inputMode="decimal"
value={bboxInput.max_x}
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_x: event.target.value }))}
/>
</label>
<label>
Max lat
<input
inputMode="decimal"
value={bboxInput.max_y}
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_y: event.target.value }))}
/>
</label>
</div>
<div className="bbox-select-actions">
<button className="primary-action" type="button" onClick={startBboxSelection}>
Rechthoek op kaart tekenen
</button>
<button
className="secondary-action"
disabled={!selectedFeatureBbox}
type="button"
onClick={() => setSelectionBbox(selectedFeatureBbox)}
>
Begrenzing van object gebruiken
</button>
<button
className="secondary-action"
disabled={!selectedAreaBbox}
type="button"
onClick={() => setSelectionBbox(selectedAreaBbox)}
>
Begrenzing van werkgebied gebruiken
</button>
<button
className="secondary-action"
disabled={!activeLayerBbox}
type="button"
onClick={() => setSelectionBbox(activeLayerBbox)}
>
Begrenzing van laag gebruiken
</button>
<button className="primary-action" disabled={!currentSelectionBbox || mapSelectionLoading} type="button" onClick={runAreaExtract}>
{mapSelectionLoading ? 'Objecten ophalen...' : 'Objecten in gebied ophalen'}
</button>
<button className="secondary-action" type="button" onClick={clearAreaSelection}>
Gebied wissen
</button>
</div>
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{mapSelectionResult ? (
<div className="bbox-selection-result" aria-label="Resultaat van de gebiedsselectie">
<div className="feature-extract-grid">
<div>
<span>Objecten</span>
<strong>{mapSelectionResult.feature_count}</strong>
</div>
<div>
<span>Limiet</span>
<strong>{mapSelectionResult.limit}</strong>
</div>
<div>
<span>Afgekapt</span>
<strong>{mapSelectionResult.truncated ? 'ja' : 'nee'}</strong>
</div>
<div>
<span>Bron</span>
<strong>Bewaarde databankobjecten</strong>
</div>
</div>
<div className="feature-extract-actions">
<button className="primary-action" type="button" onClick={downloadAreaSelection}>
GeoJSON downloaden
</button>
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
GeoJSON kopiëren
</button>
<button
className="secondary-action"
disabled={!currentSelectionBbox || selectionExporting}
type="button"
onClick={saveAreaSelectionExport}
>
{selectionExporting ? 'Download bewaren...' : 'Gebiedsdownload bewaren'}
</button>
<button
className="secondary-action"
disabled={!currentSelectionBbox || selectionDatasetSaving}
type="button"
onClick={saveAreaSelectionDataset}
>
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Als resultaatlaag bewaren'}
</button>
</div>
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
{latestSelectionExportPath ? (
<p className="muted">De geselecteerde download is bewaard.</p>
) : null}
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
{latestSelectionDatasetName ? (
<p className="muted">Bewaarde afgeleide laag: {latestSelectionDatasetName}</p>
) : null}
{latestSelectionDatasetName ? (
<div className="map-selection-qa-surface" aria-label="Map selection QA shortcut">
<label>
Referentielaag
<select
value={selectedMapQaReferenceDatasetId}
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
disabled={mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
>
<option value="">Kies een referentielaag</option>
{mapQaReferenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<button
className="primary-action"
disabled={!selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
type="button"
onClick={() => onRunMapSelectionQa()}
>
{mapSelectionQaRunning ? 'Kwaliteit controleren...' : 'Bewaarde laag controleren'}
</button>
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
{mapSelectionQaResult ? (
<div className="map-selection-qa-evidence" aria-label="Map selection QA result">
<div className="panel-title-row">
<div>
<p className="eyebrow">Kaartbewijs</p>
<h4>Vergelijking van de bewaarde selectie</h4>
</div>
<button
className="secondary-action"
disabled={!latestMapSelectionQualityCheckId}
type="button"
onClick={onOpenMapSelectionQualityEvidence}
>
Kaartbewijs openen
</button>
</div>
<div className="feature-extract-grid">
<div>
<span>Precisie</span>
<strong>{mapSelectionQaResult.precision ?? 'n.v.t.'}</strong>
</div>
<div>
<span>Herkenningsgraad</span>
<strong>{mapSelectionQaResult.recall ?? 'n.v.t.'}</strong>
</div>
<div>
<span>F1</span>
<strong>{mapSelectionQaResult.f1_score ?? 'n.v.t.'}</strong>
</div>
<div>
<span>Gemiddelde overlap</span>
<strong>{mapSelectionQaResult.mean_iou ?? 'n.v.t.'}</strong>
</div>
<div>
<span>Overeenkomsten</span>
<strong>{mapSelectionQaResult.matches}</strong>
</div>
<div>
<span>Onterecht gevonden</span>
<strong>{mapSelectionQaResult.false_positives}</strong>
</div>
<div>
<span>Gemist</span>
<strong>{mapSelectionQaResult.false_negatives}</strong>
</div>
<div>
<span>Status bewijs</span>
<strong>{latestMapSelectionQualityCheckId ? 'bewaard' : 'niet bewaard'}</strong>
</div>
</div>
{mapSelectionQaResult.warnings.length > 0 ? (
<div className="map-selection-qa-warnings" aria-label="Aandachtspunten bij de kwaliteitscontrole">
<span>Aandachtspunten</span>
<ul>
{mapSelectionQaResult.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</div>
) : null}
{areaSelectionPreviewFeatures.length > 0 ? (
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
<table>
<thead>
<tr>
<th>Object</th>
<th>Klasse</th>
<th>Bronreferentie</th>
</tr>
</thead>
<tbody>
{areaSelectionPreviewFeatures.map((feature, index) => (
<tr key={String(feature.id ?? index)}>
<td>{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}</td>
<td>{String(feature.properties?.['feature_class'] ?? 'n.v.t.')}</td>
<td>{String(feature.properties?.['source_feature_id'] ?? 'n.v.t.')}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="muted">Geen bewaarde vectorobjecten kruisen deze selectie.</p>
)}
</div>
) : null}
</div>
<div className="feature-extract-surface" aria-label="Selectie en objectextractie">
<div className="panel-title-row">
<div>
<p className="eyebrow">Geselecteerd object</p>
<h3>Selectie en extractie</h3>
</div>
<span className="count-pill">{selectedMapFeature ? 'gereed' : 'wachten'}</span>
</div>
{selectedMapFeature ? (
<>
{isBathymetryProfile ? (
<div className="bathymetry-profile-summary" aria-label="Samenvatting VHA-dwarsprofiel">
<div>
<span>Waterloop</span>
<strong>{String(featureProperties?.['watercourse_name'] ?? 'Onbekende waterloop')}</strong>
</div>
<div>
<span>Profiel</span>
<strong>{String(featureProperties?.['profile_number'] ?? 'n.v.t.')}</strong>
</div>
<div>
<span>Meetdatum</span>
<strong>{String(featureProperties?.['measurement_date'] ?? 'Niet geregistreerd')}</strong>
</div>
<div>
<span>Geregistreerde diepte</span>
<strong>
{typeof featureProperties?.['recorded_depth_m'] === 'number'
? `${featureProperties['recorded_depth_m'].toLocaleString('nl-BE')} m`
: 'Niet als veld beschikbaar'}
</strong>
</div>
{bathymetryDocumentUrl ? (
<a href={bathymetryDocumentUrl} target="_blank" rel="noreferrer">
Officieel profielblad openen
</a>
) : (
<small>Voor dit meetpunt is geen digitaal profielblad gekoppeld.</small>
)}
<p>
Historisch dwarsprofiel. Dit punt is geen continue actuele bodemkaart en levert zonder
gelijktijdig waterpeil geen actueel watervolume.
</p>
</div>
) : null}
<div className="feature-extract-grid" aria-label="Geometrie van het geselecteerde object">
<div>
<span>Geometrie</span>
<strong>{featureGeometrySummary.geometryType}</strong>
</div>
<div>
<span>Coördinaten</span>
<strong>{featureGeometrySummary.coordinateCount}</strong>
</div>
<div>
<span>Eigenschappen</span>
<strong>{featureExtractionEntries.length}</strong>
</div>
<div>
<span>BBox EPSG:4326</span>
<strong>{featureGeometrySummary.bboxLabel}</strong>
</div>
</div>
<div className="feature-extract-actions">
<button className="primary-action" type="button" onClick={downloadSelectedMapFeature}>
Geselecteerde GeoJSON downloaden
</button>
<button className="secondary-action" type="button" onClick={copySelectedMapFeatureProperties}>
Eigenschappen kopiëren
</button>
<button className="secondary-action" type="button" onClick={() => onSelectMapFeature(null)}>
Selectie wissen
</button>
</div>
{featureExtractionEntries.length > 0 ? (
<div className="table-scroll feature-property-table" aria-label="Eigenschappen van het geselecteerde object">
<table>
<thead>
<tr>
<th>Eigenschap</th>
<th>Waarde</th>
</tr>
</thead>
<tbody>
{featureExtractionEntries.map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="muted">Het geselecteerde object heeft geometrie maar geen bewaarde eigenschappen.</p>
)}
</>
) : (
<div className="feature-extract-empty">
<strong>Geen object geselecteerd</strong>
<p>Klik op een zichtbaar kaartobject om de eigenschappen en GeoJSON te bekijken.</p>
</div>
)}
</div>
<div className="feature-inspector">
<div className="panel-title-row">
<h3>Objectinspectie</h3>
<span className="count-pill">{selectedMapFeature?.geometry?.type ?? 'geen'}</span>
</div>
{selectedMapFeature ? (
<>
{featureSummaryEntries.length > 0 ? (
<div className="feature-summary-grid" aria-label="Samenvatting van het geselecteerde object">
{featureSummaryEntries.map(([key, value]) => (
<div className="feature-property-chip" key={key}>
<span>{key}</span>
<strong>{String(value)}</strong>
</div>
))}
</div>
) : null}
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
</>
) : (
<p className="muted">Klik op een zichtbaar kaartobject om de eigenschappen te bekijken.</p>
)}
</div>
</div>
</details>
</div>
</section>
)
}