Files
geointel/frontend/src/components/map/MapWorkspace.tsx
T
Codex 20829f1a24
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
fix(map): make area analysis scale-aware
2026-07-21 21:59:56 +02:00

3654 lines
160 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { BoxSelect, MapPinned, SlidersHorizontal, Trash2 } from 'lucide-react'
import GeoMap from '../GeoMap'
import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart'
import { terrainImageUrl } from '../../lib/terrainImage'
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
import { thematicRasterImageUrl } from '../../lib/thematicRaster'
import { bathymetryRasterImageUrl } from '../../lib/bathymetryRaster'
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
import {
MAP_ANALYSIS_BUDGET_MS,
exceedsPerformanceBudget,
formatPerformanceDuration,
} from '../../lib/performanceBudget'
import {
bboxToInputState,
bboxesEqual,
copyText,
downloadJsonFile,
formatArea,
formatBboxLabel,
formatPercentage,
formatTemporalMetric,
getFeatureBBox,
getFeatureCollectionBBox,
getFeatureGeometrySummary,
isMunicipalityAreaName,
isSelectionBoundedDataset,
normalizeBboxFromCorners,
operationalScopeProjectLabel,
parseBboxInput,
productCoversZones,
readablePropertyName,
resultCountLabel,
resultMetricLabel,
safeFileStem,
selectedAreaCoverageZones,
selectedFeatureCollection,
selectionAnalysisScale,
selectionAreaSquareMetres,
selectionDimensions,
selectionMetricLabel,
splitSelectionBbox,
} from './mapWorkspaceUtils'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
type DataThemeId =
| 'administrative'
| 'buildings'
| 'space_occupation'
| 'open_space'
| 'population'
| 'forest'
| 'nature_value'
| 'agriculture'
| 'soil'
| 'water'
| 'bathymetry'
| 'flood_hazard'
| 'elevation'
| 'accessibility'
| 'services'
| 'roads'
| 'parcels'
| 'maritime_planning'
| 'marine_environment'
interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
interface TemporalSeriesGroup {
key: string
label: string
items: DatasetCreateResponse[]
}
interface OnDemandMapProduct extends MapThemeAcquisition {
theme: DataThemeId
availabilityLabel: string
attribution: string
limitationMessage: string
}
interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
acquisitionBboxes: VectorSelectionBBox[]
}
function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return false
const dimensions = selectionDimensions(bbox)
if (product.kind === 'dhmv' || product.kind === 'flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (product.kind === 'thematic_raster') {
return dimensions.widthMetres <= 50_000
&& dimensions.heightMetres <= 50_000
&& dimensions.areaSquareMetres <= 2_800_000_000
}
return true
}
const DATA_THEMES: DataTheme[] = [
{
id: 'administrative',
label: 'Bestuurlijke indeling',
shortLabel: 'Bestuursgebieden',
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
},
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'space_occupation',
label: 'Ruimtebeslag',
shortLabel: 'Ruimtebeslag',
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
},
{
id: 'open_space',
label: 'Open ruimte',
shortLabel: 'Open ruimte',
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
tokens: ['open_space', 'open ruimte', 'openruimte'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'nature_value',
label: 'Natuurwaarde',
shortLabel: 'BWK-oppervlakte',
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
},
{
id: 'agriculture',
label: 'Landbouw',
shortLabel: 'Landbouwgebruik',
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
},
{
id: 'soil',
label: 'Bodem',
shortLabel: 'Bodemkaart',
description: 'Historische DOV-bodemkartering met bodemtype, textuur en drainageklasse voor Mol.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'bathymetry',
label: 'Waterbodem',
shortLabel: 'Dwarsprofielen',
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
},
{
id: 'flood_hazard',
label: 'Overstroming',
shortLabel: 'Overstroomd oppervlak',
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
},
{
id: 'elevation',
label: 'Hoogte & reliëf',
shortLabel: 'Hoogte',
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
},
{
id: 'accessibility',
label: 'Bereikbaarheid',
shortLabel: 'Knooppuntwaarde',
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
},
{
id: 'services',
label: 'Voorzieningen',
shortLabel: 'Voorzieningenniveau',
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
{
id: 'maritime_planning',
label: 'Maritieme planning',
shortLabel: 'Plan- en gebruikszones',
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
},
{
id: 'marine_environment',
label: 'Mariene rapportagezones',
shortLabel: 'Zeegebieden',
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
},
]
const COVERAGE_THEME_BY_MAP_THEME: Record<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())
.filter(([, items]) => items.length >= 2)
.map(([key, items]) => {
const ordered = [...items].sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
}
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
return latest(right) - latest(left)
})
}
function formatObservationDate(value: string | null | undefined): string {
if (!value) {
return 'Geen peildatum'
}
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
const firstValue = items[0]?.observed_at
const lastValue = items[items.length - 1]?.observed_at
if (!firstValue || !lastValue) {
return null
}
const first = new Date(firstValue)
const last = new Date(lastValue)
if (first.getTime() === last.getTime()) {
return formatObservationDate(firstValue)
}
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
}
if (first.getUTCMonth() === last.getUTCMonth()) {
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
}
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
}
function formatDatasetObservation(dataset: DatasetCreateResponse): string {
if (dataset.source_name === 'vmm_flood_hazard') {
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
return firstMeasurement && lastMeasurement
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
: 'historische profielmetingen'
}
if (dataset.source_name === 'spw_bathymetry') {
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
if (Number.isFinite(observationYear)) {
return `referentiejaar ${observationYear}`
}
}
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
}
return formatObservationDate(dataset.observed_at)
}
function floodScenarioLabel(dataset: DatasetCreateResponse): string {
const configured = dataset.source_metadata?.['product_display_name']
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
}
interface MapWorkspaceProps {
selectedProjectId: string | null
projects: ProjectRead[]
areas: AreaRead[]
selectedMapAreaId: string
areaFeatureCollection: GeoJSON.FeatureCollection | null
mapFeatureCollection: GeoJSON.FeatureCollection | null
qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null
qualityEvidenceFeatureCount?: number
qualityEvidenceLoading?: boolean
qualityEvidenceError?: string | null
qualityEvidenceWarnings?: string[]
mapLayerLabel: string
mapLayerSourceLabel: string
mapLayerProvenance: string
mapLayerVisible: boolean
mapLayerOpacity: number
areaLayerVisible: boolean
areaLayerOpacity: number
mapFeatureCount: number
areaFeatureCount: number
viewportVectorEnabled: boolean
viewportVectorStatus: string | null
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
fitMapDataOnChange: boolean
mapContentMode: 'dataset' | 'analysis'
analysisLayerAvailable: boolean
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
mapSelectionResult: VectorSelectionResponse | null
mapSelectionLoading: boolean
mapSelectionError: string | null
coverage: CoverageResolveResponse | null
coverageLoading: boolean
coverageError: string | null
coverageDurationMs: number | null
coverageBudgetExceeded: boolean
workspaceLoading: boolean
workspaceError: string | null
selectionExporting: boolean
selectionExportError: string | null
latestSelectionExportPath: string | null
selectionDatasetSaving: boolean
selectionDatasetError: string | null
latestSelectionDataset: DatasetCreateResponse | null
latestSelectionDatasetName: string | null
mapQaReferenceDatasets: DatasetCreateResponse[]
selectedMapQaReferenceDatasetId: string
mapSelectionQaRunning: boolean
mapSelectionQaError: string | null
mapSelectionQaResult: QaComparisonResult | null
latestMapSelectionQualityCheckId: string | null
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
orthophotoAnalysisQuality: DetectionQaResult | null
orthophotoAnalysisDetectionCount: number | null
orthophotoProducts: OrthophotoProductRead[]
selectedOrthophotoProductKey: string
orthophotoResult: OrthophotoAcquisitionResult | null
orthophotoImageUrl: string | null
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onSetContextSourceLabel: (label: string | null) => void
onSetContextLayerLabel: (label: string | null) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onSetAreaLayerVisible: (visible: boolean) => void
onSetAreaLayerOpacity: (opacity: number) => void
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSetMapContentMode: (mode: 'dataset' | 'analysis') => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<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} · laad bij selectie`,
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
}
for (const product of officialMapProducts.grb) {
result.push({
kind: 'grb',
productKey: product.key,
displayName: product.display_name,
theme: product.key,
availabilityLabel: 'officiële vectorbron · laad bij selectie',
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
}
for (const source of officialMapProducts.bathymetry.filter(
(item) =>
item.key === 'vha_inland_profiles'
&& item.acquisition_supported
&& item.configured,
)) {
result.push({
kind: 'bathymetry_profiles',
productKey: source.key,
displayName: source.display_name,
theme: 'bathymetry',
availabilityLabel: 'historische profielpunten · laad bij selectie',
attribution: source.attribution,
limitationMessage: source.limitation_message,
})
}
}
for (const product of officialMapProducts.officialVector.filter((item) =>
productCoversZones(item.coverage_zones, effectiveZones),
)) {
result.push({
kind: 'official_vector',
productKey: product.key,
displayName: product.display_name,
theme: product.theme,
availabilityLabel: `${product.observation_label} · officiële vectorbron · laad bij selectie`,
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
}
const dhmvProduct = includesFlanders
? officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
: null
if (dhmvProduct) {
result.push({
kind: 'dhmv',
productKey: dhmvProduct.key,
displayName: dhmvProduct.display_name,
theme: 'elevation',
availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · laad bij selectie`,
attribution: dhmvProduct.attribution,
limitationMessage: dhmvProduct.limitation_message,
})
}
const floodProduct = includesFlanders
? officialMapProducts.floodHazard.find(
(product) => product.key === selectedFloodHazardProductKey,
)
: null
if (floodProduct) {
result.push({
kind: 'flood_hazard',
productKey: floodProduct.key,
displayName: floodProduct.display_name,
theme: 'flood_hazard',
availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · laad bij selectie`,
attribution: floodProduct.attribution,
limitationMessage: floodProduct.limitation_message,
})
}
return result
}, [
activeScopeProject?.name,
officialMapProducts,
selectedCoverageZones,
selectedDhmvProductKey,
selectedFloodHazardProductKey,
])
const onDemandProductMap = useMemo(() => {
const result = new Map<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
}
if (!themeDatasetMap[theme.id]) {
return false
}
const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id]
return coverage.items.some(
(item) => item.theme === coverageTheme && item.status === 'operational',
)
})
}, [coverage, mapSelectionBbox, mapSelectionScale, onDemandProductsForZones, themeDatasetMap])
const unavailableSelectionThemeCount = Math.max(DATA_THEMES.length - selectionRelevantThemes.length, 0)
const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id]
? null
: onDemandProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
const orthophotoImageOverlay = useMemo(
() => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4
? {
url: orthophotoImageUrl,
bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number],
label: orthophotoResult.display_name,
opacity: 0.9,
}
: null,
[orthophotoImageUrl, orthophotoResult],
)
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const activeThemePartitions = themePartitionMap[activeTheme.id]
const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset)
const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset)
const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive
const onDemandThemeActive = analysisMode === 'current' && Boolean(activeOnDemandMapProduct)
const regionalOnDemandThemeActive = regionalScopeSelected && onDemandThemeActive
const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThemeActive
const coverageSelectionAvailable = analysisMode === 'current' && Boolean(selectedProjectId && selectedMapArea)
useEffect(() => {
if (
analysisMode !== 'current'
|| activeThemeAvailable
|| (
selectedProjectId
&& officialMapProductsLoading
&& onDemandProductMap.size === 0
&& !officialMapProductsError
)
) {
return
}
const fallbackTheme = DATA_THEMES.find((theme) =>
flandersScopeSelected
&& theme.id === 'space_occupation'
&& Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)),
) ?? DATA_THEMES.find((theme) =>
Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)),
)
if (!fallbackTheme) {
return
}
setActiveThemeId(fallbackTheme.id)
const fallbackDataset = themeDatasetMap[fallbackTheme.id]
if (fallbackDataset) {
onOpenDatasetInMap(fallbackDataset)
}
}, [
activeThemeAvailable,
analysisMode,
flandersScopeSelected,
officialMapProductsError,
officialMapProductsLoading,
onDemandProductMap,
onOpenDatasetInMap,
selectedProjectId,
themeDatasetMap,
])
const terrainImageOverlays = useMemo(
() =>
activeTheme.id === 'elevation' && selectedProjectId
? activeThemePartitions.flatMap((dataset) => {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
return dataset.source_name === 'digitaal_vlaanderen_dhmv'
&& Array.isArray(bounds)
&& bounds.length === 4
? [{
url: terrainImageUrl(selectedProjectId, dataset.id),
bbox: bounds.map(Number) as [number, number, number, number],
label: getDatasetDisplayName(dataset),
opacity: 0.82,
}]
: []
})
: [],
[activeTheme.id, activeThemePartitions, selectedProjectId],
)
const floodHazardImageOverlays = useMemo(
() =>
activeTheme.id === 'flood_hazard' && selectedProjectId
? activeThemePartitions.flatMap((dataset) => {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
return dataset.source_name === 'vmm_flood_hazard'
&& Array.isArray(bounds)
&& bounds.length === 4
? [{
url: floodHazardImageUrl(selectedProjectId, dataset.id),
bbox: bounds.map(Number) as [number, number, number, number],
label: floodScenarioLabel(dataset),
opacity: 0.82,
}]
: []
})
: [],
[activeTheme.id, activeThemePartitions, selectedProjectId],
)
const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster'
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
: null
const thematicRasterImageOverlays = useMemo(
() => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4
? [{
url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id),
bbox: thematicRasterBounds.map(Number) as [number, number, number, number],
label: getDatasetDisplayName(activeThemeDataset),
opacity: 0.78,
}]
: [],
[activeThemeDataset, selectedProjectId, thematicRasterBounds],
)
const bathymetryRasterBounds = activeThemeDataset?.source_name === 'spw_bathymetry'
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
: null
const bathymetryRasterImageOverlays = useMemo(
() => activeThemeDataset?.source_name === 'spw_bathymetry' && selectedProjectId && Array.isArray(bathymetryRasterBounds) && bathymetryRasterBounds.length === 4
? [{
url: bathymetryRasterImageUrl(selectedProjectId, activeThemeDataset.id),
bbox: bathymetryRasterBounds.map(Number) as [number, number, number, number],
label: 'Waterbodemhoogte in mDNG',
opacity: 0.86,
}]
: [],
[activeThemeDataset, bathymetryRasterBounds, selectedProjectId],
)
const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde')
const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde')
const activeImageOverlays = useMemo(
() => bathymetryRasterImageOverlays.length > 0
? bathymetryRasterImageOverlays
: thematicRasterImageOverlays.length > 0
? thematicRasterImageOverlays
: floodHazardImageOverlays.length > 0
? floodHazardImageOverlays
: terrainImageOverlays.length > 0
? terrainImageOverlays
: orthophotoImageOverlay ? [orthophotoImageOverlay] : [],
[bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays],
)
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
const themeTemporalSeriesMap = useMemo(
() =>
Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme)]),
) as Record<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 activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2
&& activeTemporalSeries.every((dataset) => dataset.source_name === 'grb')
&& new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime()
- new Date(activeTemporalSeries[0].observed_at ?? 0).getTime() <= 7 * 24 * 60 * 60 * 1000
useEffect(() => {
const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null
: regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen'
: activeOnDemandMapProduct?.displayName ?? null
onSetContextSourceLabel(contextSourceLabel)
return () => onSetContextSourceLabel(null)
}, [
activeTemporalSeriesGroup?.label,
activeOnDemandMapProduct?.displayName,
analysisMode,
onSetContextSourceLabel,
regionalBathymetryThemeActive,
])
const themeResults = useMemo(
() =>
themeInsights.flatMap((insight) => {
const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId)
return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : []
}),
[themeInsights],
)
const readSelectionThemeCount = useMemo(
() => new Set(themeResults.map((result) => result.theme.id)).size,
[themeResults],
)
const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId)
const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset
const activeSelectionResult = activeThemeInsight?.result
?? (!regionalPartitionedThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null)
useEffect(() => {
const contextLayerLabel = analysisMode === 'current' && activeOnDemandMapProduct
? activeSelectionResult
? `${(
activeSelectionResult.total_feature_count
?? activeSelectionResult.feature_count
).toLocaleString('nl-BE')} objecten gemeten`
: 'Op aanvraag'
: null
onSetContextLayerLabel(contextLayerLabel)
return () => onSetContextLayerLabel(null)
}, [
activeOnDemandMapProduct,
activeSelectionResult,
analysisMode,
onSetContextLayerLabel,
])
const explorerMapFeatureCollection = regionalBathymetryThemeActive
? null
: analysisMode === 'evolution' && temporalComparison?.geojson.features.length
? temporalComparison.geojson
: onDemandThemeActive
? null
: mapFeatureCollection
const explorerSelectionFeatureCollection = analysisMode === 'current'
? activeSelectionResult?.geojson ?? null
: null
const selectedAreaSquareMetres = useMemo(
() =>
bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2
? selectedMapArea.area_m2
: selectionAreaSquareMetres(mapSelectionBbox),
[mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2],
)
const selectionScaleNotice = useMemo(() => {
if (!mapSelectionBbox || !mapSelectionScale) return null
const dimensions = selectionDimensions(mapSelectionBbox)
const widthKm = dimensions.widthMetres / 1000
const heightKm = dimensions.heightMetres / 1000
if (mapSelectionScale === 'regional') {
const partitionCount = splitSelectionBbox(mapSelectionBbox).length
return `Regionale analyse van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} × ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Geschikte detailbronnen worden automatisch over ${partitionCount} begrensde bronpartities verwerkt; 5 m-rasters worden alleen meegenomen wanneer het veilige pixelbudget volstaat.`
}
if (mapSelectionScale === 'overview') {
return `Overzichtsanalyse van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} × ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Alleen landelijke en vooraf ingeladen bronnen die deze schaal betrouwbaar ondersteunen worden bevraagd. Teken maximaal 50 × 50 km voor regionale thema's of 20 × 20 km voor alle detailbronnen.`
}
return null
}, [mapSelectionBbox, mapSelectionScale])
const selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
: null
const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal
const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten'
const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel
const activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter(
(metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key,
)
const terrainReliefMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'relief_m')
const populationDensityMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'population_density_mean_per_ha')
const scoreMedianMetric = activeSupportingMetrics.find((metric) => metric.metric_key.endsWith('_median'))
const activeSecondaryMetric = activeMetricUnit === 'm TAW'
? terrainReliefMetric ? `${terrainReliefMetric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits: 2 })} m reliëf` : null
: activeMetricUnit === 'inwoners'
? populationDensityMetric ? selectionMetricLabel(populationDensityMetric) : null
: activeMetricUnit.startsWith('score')
? scoreMedianMetric ? selectionMetricLabel(scoreMedianMetric) : null
: selectedAreaSquareMetres && selectedAreaSquareMetres > 0
? activeMetricUnit === 'ha'
? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking`
: `${(activeMetricValue / (selectedAreaSquareMetres / 1_000_000)).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${activeMetricUnit} / km2`
: null
const activeSecondaryLabel = activeMetricUnit === 'ha'
? 'Aandeel selectie'
: activeMetricUnit === 'm TAW'
? 'Reliëf'
: activeMetricUnit === 'inwoners'
? 'Gemiddelde dichtheid'
: activeMetricUnit.startsWith('score')
? 'Mediaan'
: 'Dichtheid'
const selectedResultProperties = useMemo(() => {
const keys = new Map<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 loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
let resolvedZones = selectedCoverageZones
const scale = selectionAnalysisScale(bbox)
if (analysisMode === 'current' && selectedProjectId) {
const resolvedCoverage = await resolveCoverage({
minx: bbox.min_x,
miny: bbox.min_y,
maxx: bbox.max_x,
maxy: bbox.max_y,
})
if (!resolvedCoverage) {
clearThemeInsights()
return
}
resolvedZones = resolvedCoverage.intersected_zones
}
let resolvedProducts: PlannedOnDemandMapProduct[] = []
if (analysisMode === 'current' && scale !== 'overview') {
const zoneProducts = resolvedZones
? onDemandProductsForZones(resolvedZones)
: []
if (scale === 'detail' || !selectedProjectId) {
resolvedProducts = zoneProducts
.filter((product) => productSupportsSelection(product, bbox))
.map((product) => ({
...product,
acquisitionBboxes: [bbox],
}))
} else {
const detailTiles = splitSelectionBbox(bbox)
const tileCoverage = await resolveCoveragePartitions(detailTiles)
if (!tileCoverage) {
clearThemeInsights()
return
}
const grouped = new Map<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>> = []
for (const theme of DATA_THEMES) {
const dataset = themeDatasetMap[theme.id]
if (dataset) {
availableThemes.push({
themeId: theme.id,
dataset,
partitioned: regionalScopeSelected
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
})
continue
}
const onDemandProducts = resolvedProducts.filter((product) => product.theme === theme.id)
if (onDemandProducts.length > 0) {
for (const onDemandProduct of onDemandProducts) {
availableThemes.push({
themeId: theme.id,
acquisition: {
kind: onDemandProduct.kind,
productKey: onDemandProduct.productKey,
displayName: onDemandProduct.displayName,
},
acquisitionBboxes: onDemandProduct.acquisitionBboxes,
})
}
continue
}
}
await loadThemeInsights(bbox, availableThemes, areaId)
}
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
const requestId = mapAnalysisRequestSequence.current + 1
mapAnalysisRequestSequence.current = requestId
const startedAt = Date.now()
setMapAnalysisDurationMs(null)
setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
if (activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive) {
tasks.push(onRunMapSelectionExtract(bbox, areaId))
}
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
}
try {
await Promise.all(tasks)
} finally {
if (mapAnalysisRequestSequence.current === requestId) {
setMapAnalysisDurationMs(Date.now() - startedAt)
}
}
}
const runTemporalComparison = () => {
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
return
}
void compareTemporalSnapshots(
earlierDatasetId,
laterDatasetId,
mapSelectionBbox,
areaIdForSelection(mapSelectionBbox),
)
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
setSelectionBbox(bbox)
}
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox, areaIdForSelection(bbox))
}
const runQuickAoiExtract = () => {
const bbox = selectedAreaBbox ?? activeLayerBbox
if (!bbox) {
return
}
setSelectionBbox(bbox)
void analyzeSelection(bbox, areaIdForSelection(bbox))
}
const runFullGisWorkflow = async () => {
const bbox = currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox
if (fullWorkflowMode === 'reuse') {
if (!latestSelectionDataset) {
setFullWorkflowError('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.')
return
}
if (!selectedMapQaReferenceDatasetId) {
setFullWorkflowError('Kies eerst een referentielaag voor de kwaliteitscontrole.')
return
}
setFullWorkflowRunning(true)
setFullWorkflowError(null)
try {
setFullWorkflowStatus('Laatste bewaarde resultaatlaag opnieuw controleren...')
const qaResult = await onRunMapSelectionQa(latestSelectionDataset)
setFullWorkflowStatus(qaResult ? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.' : 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.')
} catch (error) {
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
setFullWorkflowStatus('De werkstroom is gestopt.')
} finally {
setFullWorkflowRunning(false)
}
return
}
if (!selectedMapDataset || !bbox) {
setFullWorkflowError('Kies een kaartlaag en een werkgebied of laagbegrenzing.')
return
}
setFullWorkflowRunning(true)
setFullWorkflowError(null)
try {
setFullWorkflowStatus('1/4 Bewaarde kaartobjecten selecteren...')
setSelectionBbox(bbox)
const selectionAreaId = areaIdForSelection(bbox)
const selection = await onRunMapSelectionExtract(bbox, selectionAreaId)
if (!selection) {
setFullWorkflowError('De ruimtelijke selectie kon niet worden afgerond.')
setFullWorkflowStatus('Stopped at query.')
return
}
setFullWorkflowStatus('2/4 Saving derived result dataset...')
const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId)
if (!derived) {
setFullWorkflowError('De afgeleide resultaatlaag kon niet worden aangemaakt.')
setFullWorkflowStatus('Stopped at dataset save.')
return
}
setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...')
await onExportMapSelection(bbox, selectionAreaId)
if (selectedMapQaReferenceDatasetId) {
setFullWorkflowStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...')
const qaResult = await onRunMapSelectionQa(derived)
setFullWorkflowStatus(qaResult ? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.' : 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.')
} else {
setFullWorkflowStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.')
}
} catch (error) {
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
setFullWorkflowStatus('De werkstroom is gestopt.')
} finally {
setFullWorkflowRunning(false)
}
}
if (!advancedMode) {
return (
<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
? onDemandProduct.availabilityLabel
: 'Bron nog niet ingeladen'}
</small>
</span>
<i>
{workspaceLoading
? 'Laden'
: analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Op aanvraag' : '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)}`
: 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) => { setEarlierDatasetId(event.target.value); clearTemporalComparison() }} disabled={activeTemporalSeries.length < 2}>
{activeTemporalSeries.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={activeTemporalSeries.length < 2}>
{activeTemporalSeries.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
))}
</select>
</label>
<button
className="primary-action"
type="button"
disabled={!mapSelectionBbox || !earlierDatasetId || !laterDatasetId || 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>Officiële bronnen worden 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>Alle relevante thema’s</h4>
<span>{readSelectionThemeCount} van {selectionRelevantThemes.length} uitgelezen</span>
</div>
{selectionRelevantThemes.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 ?? dataset
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) : 'Officiële bron kon niet worden geladen'}</small>
</span>
<span className="geo-theme-result-value">
<b>{item ? resultMetricLabel(item.result) : 'Inladen mislukt'}</b>
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
</span>
</div>
)
})
})}
{selectionScaleNotice ? (
<p className="geo-data-notice">{selectionScaleNotice}</p>
) : null}
{unavailableSelectionThemeCount > 0 ? (
<p className="geo-data-notice">
{mapSelectionScale === 'overview'
? `${unavailableSelectionThemeCount} detailthema's zijn op deze overzichtsschaal bewust niet bevraagd.`
: `${unavailableSelectionThemeCount} thema's zijn voor deze zone niet van toepassing, niet operationeel gekoppeld of te fijnmazig voor deze selectieschaal.`}
</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>
)
}