3654 lines
160 KiB
TypeScript
3654 lines
160 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||
import { BoxSelect, MapPinned, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||
import GeoMap from '../GeoMap'
|
||
import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
|
||
import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
|
||
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
|
||
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
||
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
|
||
import { TemporalTrendChart } from './TemporalTrendChart'
|
||
import { terrainImageUrl } from '../../lib/terrainImage'
|
||
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
||
import { thematicRasterImageUrl } from '../../lib/thematicRaster'
|
||
import { bathymetryRasterImageUrl } from '../../lib/bathymetryRaster'
|
||
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
|
||
import {
|
||
MAP_ANALYSIS_BUDGET_MS,
|
||
exceedsPerformanceBudget,
|
||
formatPerformanceDuration,
|
||
} from '../../lib/performanceBudget'
|
||
import {
|
||
bboxToInputState,
|
||
bboxesEqual,
|
||
copyText,
|
||
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>
|
||
)
|
||
}
|