3707 lines
162 KiB
TypeScript
3707 lines
162 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
import { BoxSelect, MapPinned, SlidersHorizontal, Trash2 } from 'lucide-react'
|
|
import GeoMap from '../GeoMap'
|
|
import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
|
|
import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
|
|
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
|
|
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
|
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
|
|
import { TemporalTrendChart } from './TemporalTrendChart'
|
|
import { terrainImageUrl } from '../../lib/terrainImage'
|
|
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
|
import { thematicRasterImageUrl } from '../../lib/thematicRaster'
|
|
import { bathymetryRasterImageUrl } from '../../lib/bathymetryRaster'
|
|
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
|
|
import {
|
|
MAP_ANALYSIS_BUDGET_MS,
|
|
exceedsPerformanceBudget,
|
|
formatPerformanceDuration,
|
|
} from '../../lib/performanceBudget'
|
|
import {
|
|
bboxToInputState,
|
|
bboxesEqual,
|
|
copyText,
|
|
datasetIntersectsSelection,
|
|
deduplicateTemporalSnapshots,
|
|
downloadJsonFile,
|
|
formatArea,
|
|
formatBboxLabel,
|
|
formatPercentage,
|
|
formatTemporalMetric,
|
|
getFeatureBBox,
|
|
getFeatureCollectionBBox,
|
|
getFeatureGeometrySummary,
|
|
isMunicipalityAreaName,
|
|
isSelectionBoundedDataset,
|
|
normalizeBboxFromCorners,
|
|
operationalScopeProjectLabel,
|
|
parseBboxInput,
|
|
persistedDatasetSupportsSelection,
|
|
productCoversZones,
|
|
readablePropertyName,
|
|
resultCountLabel,
|
|
resultMetricLabel,
|
|
safeFileStem,
|
|
selectedAreaCoverageZones,
|
|
selectedFeatureCollection,
|
|
selectionAnalysisScale,
|
|
selectionAreaSquareMetres,
|
|
selectionDimensions,
|
|
selectionFeatureLimit,
|
|
selectionMetricLabel,
|
|
splitSelectionBbox,
|
|
} from './mapWorkspaceUtils'
|
|
|
|
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
|
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
|
|
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
|
|
|
|
type DataThemeId =
|
|
| 'administrative'
|
|
| 'buildings'
|
|
| 'space_occupation'
|
|
| 'open_space'
|
|
| 'population'
|
|
| 'forest'
|
|
| 'nature_value'
|
|
| 'agriculture'
|
|
| 'soil'
|
|
| 'water'
|
|
| 'bathymetry'
|
|
| 'flood_hazard'
|
|
| 'elevation'
|
|
| 'accessibility'
|
|
| 'services'
|
|
| 'roads'
|
|
| 'parcels'
|
|
| 'maritime_planning'
|
|
| 'marine_environment'
|
|
|
|
interface DataTheme {
|
|
id: DataThemeId
|
|
label: string
|
|
shortLabel: string
|
|
description: string
|
|
tokens: string[]
|
|
}
|
|
|
|
interface TemporalSeriesGroup {
|
|
key: string
|
|
label: string
|
|
items: DatasetCreateResponse[]
|
|
}
|
|
|
|
interface OnDemandMapProduct extends MapThemeAcquisition {
|
|
theme: DataThemeId
|
|
availabilityLabel: string
|
|
attribution: string
|
|
limitationMessage: string
|
|
}
|
|
|
|
interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
|
|
acquisitionBboxes: VectorSelectionBBox[]
|
|
}
|
|
|
|
function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
|
|
const scale = selectionAnalysisScale(bbox)
|
|
if (scale === 'overview') return false
|
|
const dimensions = selectionDimensions(bbox)
|
|
if (product.kind === 'dhmv' || product.kind === 'flood_hazard') {
|
|
return dimensions.areaSquareMetres <= 280_000_000
|
|
}
|
|
if (product.kind === 'thematic_raster') {
|
|
return dimensions.widthMetres <= 50_000
|
|
&& dimensions.heightMetres <= 50_000
|
|
&& dimensions.areaSquareMetres <= 2_800_000_000
|
|
}
|
|
return true
|
|
}
|
|
|
|
const DATA_THEMES: DataTheme[] = [
|
|
{
|
|
id: 'administrative',
|
|
label: 'Bestuurlijke indeling',
|
|
shortLabel: 'Bestuursgebieden',
|
|
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
|
|
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
|
|
},
|
|
{
|
|
id: 'buildings',
|
|
label: 'Bebouwing',
|
|
shortLabel: 'Gebouwen',
|
|
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
|
|
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
|
|
},
|
|
{
|
|
id: 'space_occupation',
|
|
label: 'Ruimtebeslag',
|
|
shortLabel: 'Ruimtebeslag',
|
|
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
|
|
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
|
|
},
|
|
{
|
|
id: 'open_space',
|
|
label: 'Open ruimte',
|
|
shortLabel: 'Open ruimte',
|
|
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
|
|
tokens: ['open_space', 'open ruimte', 'openruimte'],
|
|
},
|
|
{
|
|
id: 'population',
|
|
label: 'Bevolking',
|
|
shortLabel: 'Inwoners',
|
|
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
|
|
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
|
|
},
|
|
{
|
|
id: 'forest',
|
|
label: 'Bos & groen',
|
|
shortLabel: 'Bos',
|
|
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
|
|
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
|
|
},
|
|
{
|
|
id: 'nature_value',
|
|
label: 'Natuurwaarde',
|
|
shortLabel: 'BWK-oppervlakte',
|
|
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
|
|
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
|
|
},
|
|
{
|
|
id: 'agriculture',
|
|
label: 'Landbouw',
|
|
shortLabel: 'Landbouwgebruik',
|
|
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
|
|
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
|
|
},
|
|
{
|
|
id: 'soil',
|
|
label: 'Bodem',
|
|
shortLabel: 'Bodemkaart',
|
|
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
|
|
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
|
|
},
|
|
{
|
|
id: 'water',
|
|
label: 'Water',
|
|
shortLabel: 'Water',
|
|
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
|
|
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
|
|
},
|
|
{
|
|
id: 'bathymetry',
|
|
label: 'Waterbodem',
|
|
shortLabel: 'Dwarsprofielen',
|
|
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
|
|
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
|
|
},
|
|
{
|
|
id: 'flood_hazard',
|
|
label: 'Overstroming',
|
|
shortLabel: 'Overstroomd oppervlak',
|
|
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
|
|
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
|
|
},
|
|
{
|
|
id: 'elevation',
|
|
label: 'Hoogte & reliëf',
|
|
shortLabel: 'Hoogte',
|
|
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
|
|
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
|
|
},
|
|
{
|
|
id: 'accessibility',
|
|
label: 'Bereikbaarheid',
|
|
shortLabel: 'Knooppuntwaarde',
|
|
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
|
|
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
|
|
},
|
|
{
|
|
id: 'services',
|
|
label: 'Voorzieningen',
|
|
shortLabel: 'Voorzieningenniveau',
|
|
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
|
|
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
|
|
},
|
|
{
|
|
id: 'roads',
|
|
label: 'Wegen',
|
|
shortLabel: 'Wegen',
|
|
description: 'Wegen en wegsegmenten uit een persistente bron.',
|
|
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
|
|
},
|
|
{
|
|
id: 'parcels',
|
|
label: 'Percelen',
|
|
shortLabel: 'Percelen',
|
|
description: 'Kadastrale of administratieve perceelcontouren.',
|
|
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
|
|
},
|
|
{
|
|
id: 'maritime_planning',
|
|
label: 'Maritieme planning',
|
|
shortLabel: 'Plan- en gebruikszones',
|
|
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
|
|
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
|
|
},
|
|
{
|
|
id: 'marine_environment',
|
|
label: 'Mariene rapportagezones',
|
|
shortLabel: 'Zeegebieden',
|
|
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
|
|
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
|
|
},
|
|
]
|
|
|
|
const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
|
|
administrative: 'admin',
|
|
buildings: 'buildings',
|
|
space_occupation: 'land_cover_use',
|
|
open_space: 'land_cover_use',
|
|
population: 'population',
|
|
forest: 'land_cover_use',
|
|
nature_value: 'nature',
|
|
agriculture: 'land_cover_use',
|
|
soil: 'soil',
|
|
water: 'surface_water',
|
|
bathymetry: 'bathymetry',
|
|
flood_hazard: 'flood_climate',
|
|
elevation: 'elevation',
|
|
accessibility: 'roads',
|
|
services: 'population',
|
|
roads: 'roads',
|
|
parcels: 'parcels',
|
|
maritime_planning: 'maritime_planning',
|
|
marine_environment: 'marine_environment',
|
|
}
|
|
|
|
function coverageStatusLabel(status: CoverageStatus): string {
|
|
const labels: Record<CoverageStatus, string> = {
|
|
operational: 'Beschikbaar',
|
|
partial: 'Gedeeltelijk',
|
|
not_configured: 'Niet gekoppeld',
|
|
unsupported: 'Niet ondersteund',
|
|
}
|
|
return labels[status]
|
|
}
|
|
|
|
function coverageZoneLabel(zone: string): string {
|
|
const labels: Record<string, string> = {
|
|
belgium: 'Belgie',
|
|
flanders: 'Vlaanderen',
|
|
wallonia: 'Wallonie',
|
|
brussels: 'Brussel',
|
|
belgian_north_sea: 'Belgische Noordzee',
|
|
territorial_sea: 'Territoriale zee',
|
|
exclusive_economic_zone: 'EEZ',
|
|
continental_shelf: 'Continentaal plat',
|
|
}
|
|
return labels[zone] ?? zone
|
|
}
|
|
|
|
const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
|
|
administrative: { fill: '#5f6f7f', line: '#344554' },
|
|
buildings: { fill: '#d45f3d', line: '#9f3e24' },
|
|
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
|
|
open_space: { fill: '#267a46', line: '#175c32' },
|
|
population: { fill: '#7559a6', line: '#5b3f88' },
|
|
forest: { fill: '#347950', line: '#225f3b' },
|
|
nature_value: { fill: '#9a4f64', line: '#74364a' },
|
|
agriculture: { fill: '#7b8f32', line: '#53671d' },
|
|
soil: { fill: '#9a7040', line: '#6f4c27' },
|
|
water: { fill: '#2676a8', line: '#155b85' },
|
|
bathymetry: { fill: '#0e7490', line: '#164e63' },
|
|
flood_hazard: { fill: '#1597c2', line: '#075985' },
|
|
elevation: { fill: '#a57a4b', line: '#315f59' },
|
|
accessibility: { fill: '#0f766e', line: '#115e59' },
|
|
services: { fill: '#b66d16', line: '#854d0e' },
|
|
roads: { fill: '#6b7280', line: '#4b5563' },
|
|
parcels: { fill: '#a7792f', line: '#7d571f' },
|
|
maritime_planning: { fill: '#2f7f8f', line: '#145d6a' },
|
|
marine_environment: { fill: '#3475a3', line: '#1c557d' },
|
|
}
|
|
|
|
function datasetAvailabilityLabel(
|
|
dataset: DatasetCreateResponse,
|
|
partitions: DatasetCreateResponse[] = [dataset],
|
|
): string {
|
|
const partitionCount = partitions.length
|
|
const regionalSuffix = partitionCount > 1 ? ` · ${partitionCount} gemeenten` : ''
|
|
if (dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv') {
|
|
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
|
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid${regionalSuffix}`
|
|
}
|
|
if (dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard') {
|
|
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
|
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} overstromingsscenario${regionalSuffix}`
|
|
}
|
|
if (dataset.dataset_type === 'raster' && dataset.source_name === 'spw_bathymetry') {
|
|
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
|
const period = String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')
|
|
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} waterbodemhoogte · ${period}`
|
|
}
|
|
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
|
|
const profiles = partitions.reduce(
|
|
(total, item) => total + (item.feature_count ?? item.vector_summary?.feature_count ?? 0),
|
|
0,
|
|
)
|
|
const documents = partitions.reduce(
|
|
(total, item) => total + Number(item.source_metadata?.['document_count'] ?? 0),
|
|
0,
|
|
)
|
|
return `${profiles.toLocaleString('nl-BE')} profielen · ${documents.toLocaleString('nl-BE')} bronbladen${regionalSuffix}`
|
|
}
|
|
if (dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster') {
|
|
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
|
const year = Number(dataset.source_metadata?.['observation_year'])
|
|
const resolutionLabel = Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'
|
|
return `${resolutionLabel} officiële bron${Number.isFinite(year) ? ` · ${year}` : ''}`
|
|
}
|
|
if (dataset.source_name === 'ngi_adminvector') {
|
|
return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële bestuursgebieden`
|
|
}
|
|
if (dataset.source_name === 'rbins_msp_2026') {
|
|
return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële planobjecten · 2026-2034`
|
|
}
|
|
if (dataset.source_name === 'rbins_marine_reporting_units') {
|
|
return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële zeegebieden`
|
|
}
|
|
return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar`
|
|
}
|
|
|
|
function datasetSearchText(dataset: DatasetCreateResponse): string {
|
|
return [
|
|
dataset.name,
|
|
dataset.original_filename,
|
|
dataset.source,
|
|
dataset.source_name,
|
|
dataset.reference_layer_name,
|
|
dataset.metadata_json?.['layer_name'],
|
|
dataset.source_metadata?.['layer_name'],
|
|
dataset.source_metadata?.['theme'],
|
|
dataset.source_metadata?.['product_display_name'],
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase()
|
|
}
|
|
|
|
function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
|
|
// Governed raster products have one unambiguous semantic theme. Matching
|
|
// them by generic substrings (for example "water" in "waterdiepte") would
|
|
// make a flood scenario replace the permanent surface-water layer.
|
|
if (dataset.source_name === 'vmm_flood_hazard') {
|
|
return theme.id === 'flood_hazard'
|
|
}
|
|
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
|
|
return theme.id === 'bathymetry'
|
|
}
|
|
if (dataset.source_name === 'spw_bathymetry') {
|
|
return theme.id === 'bathymetry'
|
|
}
|
|
if (dataset.source_name === 'digitaal_vlaanderen_dhmv') {
|
|
return theme.id === 'elevation'
|
|
}
|
|
if (dataset.source_name === 'department_omgeving_thematic_raster') {
|
|
return dataset.source_metadata?.['theme'] === theme.id
|
|
}
|
|
if (dataset.source_name === 'dov_soil_map') {
|
|
return theme.id === 'soil'
|
|
}
|
|
if (dataset.source_name === 'ngi_adminvector') {
|
|
return theme.id === 'administrative'
|
|
}
|
|
if (dataset.source_name === 'rbins_msp_2026') {
|
|
return theme.id === 'maritime_planning'
|
|
}
|
|
if (dataset.source_name === 'rbins_marine_reporting_units') {
|
|
return theme.id === 'marine_environment'
|
|
}
|
|
const searchText = datasetSearchText(dataset)
|
|
return theme.tokens.some((token) => searchText.includes(token))
|
|
}
|
|
|
|
function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
|
|
return Boolean(
|
|
dataset?.dataset_type === 'raster'
|
|
&& ['digitaal_vlaanderen_dhmv', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
|
|
)
|
|
}
|
|
|
|
function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
|
|
return Boolean(
|
|
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
|
|
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
|
|
)
|
|
}
|
|
|
|
function datasetProductKey(dataset: DatasetCreateResponse): string {
|
|
return String(dataset.source_metadata?.['product_key'] ?? '')
|
|
}
|
|
|
|
function datasetCoversSelectedArea(
|
|
dataset: DatasetCreateResponse,
|
|
selectedAreaId: string | null,
|
|
selectedAreaName: string | null | undefined,
|
|
regionalScope = false,
|
|
): boolean {
|
|
if (isSelectionBoundedDataset(dataset.source_metadata)) {
|
|
return false
|
|
}
|
|
const selectedZones = selectedAreaCoverageZones(selectedAreaName)
|
|
const configuredZones = dataset.source_metadata?.['coverage_zones']
|
|
if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) {
|
|
const datasetZones = configuredZones.map((zone) => String(zone))
|
|
if (!selectedZones.some((zone) => datasetZones.includes(zone))) {
|
|
return false
|
|
}
|
|
}
|
|
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
|
|
if (isPartitionedBathymetry(dataset)) {
|
|
return regionalScope
|
|
? true
|
|
: Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
|
|
}
|
|
if (coverageScope !== 'municipality' || !dataset.area_id) {
|
|
return true
|
|
}
|
|
if (regionalScope) {
|
|
return true
|
|
}
|
|
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
|
|
}
|
|
|
|
function rasterPartitionsForDataset(
|
|
datasets: DatasetCreateResponse[],
|
|
representative: DatasetCreateResponse | null,
|
|
selectedAreaId: string | null,
|
|
selectedAreaName: string | null | undefined,
|
|
regionalScope: boolean,
|
|
): DatasetCreateResponse[] {
|
|
if (!representative) {
|
|
return []
|
|
}
|
|
if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) {
|
|
return [representative]
|
|
}
|
|
const productKey = datasetProductKey(representative)
|
|
const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '')
|
|
return datasets
|
|
.filter(
|
|
(dataset) =>
|
|
dataset.source_name === representative.source_name
|
|
&& (
|
|
isPartitionedBathymetry(representative)
|
|
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
|
|
: datasetProductKey(dataset) === productKey
|
|
)
|
|
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
|
|
)
|
|
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
|
|
}
|
|
|
|
function pickThemeDataset(
|
|
datasets: DatasetCreateResponse[],
|
|
theme: DataTheme,
|
|
selectedAreaId: string | null,
|
|
selectedAreaName: string | null | undefined,
|
|
regionalScope = false,
|
|
): DatasetCreateResponse | null {
|
|
const candidates = datasets.filter(
|
|
(dataset) =>
|
|
datasetMatchesTheme(dataset, theme)
|
|
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
|
|
)
|
|
candidates.sort((left, right) => {
|
|
const priorityScore = (dataset: DatasetCreateResponse) =>
|
|
(dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) +
|
|
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
|
|
(dataset.source_name === 'grb' ? 100_000 : 0) +
|
|
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
|
|
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
|
|
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
|
|
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
|
|
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
|
|
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
|
|
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
|
|
(dataset.source_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) +
|
|
(dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) +
|
|
(dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) +
|
|
(dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) +
|
|
(dataset.dataset_role === 'reference' ? 10_000 : 0)
|
|
const priorityDifference = priorityScore(right) - priorityScore(left)
|
|
if (priorityDifference !== 0) return priorityDifference
|
|
|
|
const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime()
|
|
if (observedAtDifference !== 0) return observedAtDifference
|
|
|
|
const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime()
|
|
if (importedAtDifference !== 0) return importedAtDifference
|
|
|
|
return (right.feature_count ?? right.vector_summary?.feature_count ?? 0)
|
|
- (left.feature_count ?? left.vector_summary?.feature_count ?? 0)
|
|
})
|
|
return candidates[0] ?? null
|
|
}
|
|
|
|
function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
|
|
return dataset
|
|
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
|
|
: null
|
|
}
|
|
|
|
function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
|
|
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
|
|
?.source_metadata?.['temporal_series_label']
|
|
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
|
|
return configuredLabel
|
|
}
|
|
const first = items[0]
|
|
const source = first ? getDatasetDisplayName(first) : 'Tijdreeks'
|
|
const range = temporalRangeLabel(items)
|
|
return range ? `${source} (${range})` : source
|
|
}
|
|
|
|
function listThemeTemporalSeries(datasets: DatasetCreateResponse[], theme: DataTheme): TemporalSeriesGroup[] {
|
|
const groups = new Map<string, DatasetCreateResponse[]>()
|
|
for (const dataset of datasets) {
|
|
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
|
|
continue
|
|
}
|
|
const items = groups.get(dataset.temporal_series_key) ?? []
|
|
items.push(dataset)
|
|
groups.set(dataset.temporal_series_key, items)
|
|
}
|
|
return Array.from(groups.entries())
|
|
.map(([key, items]) => {
|
|
const ordered = deduplicateTemporalSnapshots(items)
|
|
return { key, label: temporalSeriesLabel(ordered), items: ordered }
|
|
})
|
|
.filter((group) => group.items.length >= 2)
|
|
.sort((left, right) => {
|
|
if (right.items.length !== left.items.length) {
|
|
return right.items.length - left.items.length
|
|
}
|
|
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
|
|
return latest(right) - latest(left)
|
|
})
|
|
}
|
|
|
|
function formatObservationDate(value: string | null | undefined): string {
|
|
if (!value) {
|
|
return 'Geen peildatum'
|
|
}
|
|
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
|
|
}
|
|
|
|
function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
|
|
const firstValue = items[0]?.observed_at
|
|
const lastValue = items[items.length - 1]?.observed_at
|
|
if (!firstValue || !lastValue) {
|
|
return null
|
|
}
|
|
const first = new Date(firstValue)
|
|
const last = new Date(lastValue)
|
|
if (first.getTime() === last.getTime()) {
|
|
return formatObservationDate(firstValue)
|
|
}
|
|
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
|
|
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
|
|
}
|
|
if (first.getUTCMonth() === last.getUTCMonth()) {
|
|
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
|
|
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
|
|
}
|
|
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
|
|
}
|
|
|
|
function formatDatasetObservation(dataset: DatasetCreateResponse): string {
|
|
if (dataset.source_name === 'vmm_flood_hazard') {
|
|
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
|
|
}
|
|
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
|
|
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
|
|
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
|
|
return firstMeasurement && lastMeasurement
|
|
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
|
|
: 'historische profielmetingen'
|
|
}
|
|
if (dataset.source_name === 'spw_bathymetry') {
|
|
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
|
|
}
|
|
if (dataset.source_name === 'department_omgeving_thematic_raster') {
|
|
const observationYear = Number(dataset.source_metadata?.['observation_year'])
|
|
if (Number.isFinite(observationYear)) {
|
|
return `referentiejaar ${observationYear}`
|
|
}
|
|
}
|
|
const period = dataset.source_metadata?.['acquisition_period']
|
|
if (typeof period === 'string' && period.trim()) {
|
|
return `opnameperiode ${period}`
|
|
}
|
|
return formatObservationDate(dataset.observed_at)
|
|
}
|
|
|
|
function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
|
const configured = dataset.source_metadata?.['product_display_name']
|
|
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
|
|
}
|
|
|
|
interface MapWorkspaceProps {
|
|
selectedProjectId: string | null
|
|
projects: ProjectRead[]
|
|
areas: AreaRead[]
|
|
selectedMapAreaId: string
|
|
areaFeatureCollection: GeoJSON.FeatureCollection | null
|
|
mapFeatureCollection: GeoJSON.FeatureCollection | null
|
|
qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null
|
|
qualityEvidenceFeatureCount?: number
|
|
qualityEvidenceLoading?: boolean
|
|
qualityEvidenceError?: string | null
|
|
qualityEvidenceWarnings?: string[]
|
|
mapLayerLabel: string
|
|
mapLayerSourceLabel: string
|
|
mapLayerProvenance: string
|
|
mapLayerVisible: boolean
|
|
mapLayerOpacity: number
|
|
areaLayerVisible: boolean
|
|
areaLayerOpacity: number
|
|
mapFeatureCount: number
|
|
areaFeatureCount: number
|
|
viewportVectorEnabled: boolean
|
|
viewportVectorStatus: string | null
|
|
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
|
|
fitMapDataOnChange: boolean
|
|
mapContentMode: 'dataset' | 'analysis'
|
|
analysisLayerAvailable: boolean
|
|
selectedMapFeature: GeoJSON.Feature | null
|
|
selectedFeature?: GeoJSON.Feature | null
|
|
mapSelectionBbox: VectorSelectionBBox | null
|
|
mapSelectionResult: VectorSelectionResponse | null
|
|
mapSelectionLoading: boolean
|
|
mapSelectionError: string | null
|
|
coverage: CoverageResolveResponse | null
|
|
coverageLoading: boolean
|
|
coverageError: string | null
|
|
coverageDurationMs: number | null
|
|
coverageBudgetExceeded: boolean
|
|
workspaceLoading: boolean
|
|
workspaceError: string | null
|
|
selectionExporting: boolean
|
|
selectionExportError: string | null
|
|
latestSelectionExportPath: string | null
|
|
selectionDatasetSaving: boolean
|
|
selectionDatasetError: string | null
|
|
latestSelectionDataset: DatasetCreateResponse | null
|
|
latestSelectionDatasetName: string | null
|
|
mapQaReferenceDatasets: DatasetCreateResponse[]
|
|
selectedMapQaReferenceDatasetId: string
|
|
mapSelectionQaRunning: boolean
|
|
mapSelectionQaError: string | null
|
|
mapSelectionQaResult: QaComparisonResult | null
|
|
latestMapSelectionQualityCheckId: string | null
|
|
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
|
|
orthophotoAnalysisStatus: string
|
|
orthophotoAnalysisError: string | null
|
|
orthophotoAnalysisRunning: boolean
|
|
orthophotoAnalysisQuality: DetectionQaResult | null
|
|
orthophotoAnalysisDetectionCount: number | null
|
|
orthophotoProducts: OrthophotoProductRead[]
|
|
selectedOrthophotoProductKey: string
|
|
orthophotoResult: OrthophotoAcquisitionResult | null
|
|
orthophotoImageUrl: string | null
|
|
availableMapDatasets: DatasetCreateResponse[]
|
|
selectedMapDatasetId: string
|
|
onSelectMapArea: (areaId: string) => void
|
|
onSetContextSourceLabel: (label: string | null) => void
|
|
onSetContextLayerLabel: (label: string | null) => void
|
|
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
|
onSetAreaLayerVisible: (visible: boolean) => void
|
|
onSetAreaLayerOpacity: (opacity: number) => void
|
|
onSetMapLayerVisible: (visible: boolean) => void
|
|
onSetMapLayerOpacity: (opacity: number) => void
|
|
onSetMapContentMode: (mode: 'dataset' | 'analysis') => void
|
|
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
|
|
onMapViewportChange: (viewport: MapViewportState) => void
|
|
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
|
|
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
|
|
onClearMapSelectionExtract: () => void
|
|
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
|
|
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
|
|
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
|
|
onSelectMapQaReferenceDataset: (datasetId: string) => void
|
|
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
|
|
onOpenMapSelectionQualityEvidence: () => void
|
|
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
|
|
onSelectOrthophotoProduct: (productKey: string) => void
|
|
onClearQualityEvidence?: () => void
|
|
onRefreshProjectData: () => Promise<unknown>
|
|
onOpenAssistant: () => void
|
|
onOpenExports: () => void
|
|
}
|
|
|
|
export function MapWorkspace({
|
|
selectedProjectId,
|
|
projects,
|
|
areas,
|
|
selectedMapAreaId,
|
|
areaFeatureCollection,
|
|
mapFeatureCollection,
|
|
qualityEvidenceGeoJson = null,
|
|
qualityEvidenceFeatureCount = 0,
|
|
qualityEvidenceLoading = false,
|
|
qualityEvidenceError = null,
|
|
qualityEvidenceWarnings = [],
|
|
mapLayerLabel,
|
|
mapLayerSourceLabel,
|
|
mapLayerProvenance,
|
|
mapLayerVisible,
|
|
mapLayerOpacity,
|
|
areaLayerVisible,
|
|
areaLayerOpacity,
|
|
mapFeatureCount,
|
|
areaFeatureCount,
|
|
viewportVectorEnabled,
|
|
viewportVectorStatus,
|
|
viewportVectorTone,
|
|
fitMapDataOnChange,
|
|
mapContentMode,
|
|
analysisLayerAvailable,
|
|
selectedMapFeature,
|
|
selectedFeature = selectedMapFeature,
|
|
mapSelectionBbox,
|
|
mapSelectionResult,
|
|
mapSelectionLoading,
|
|
mapSelectionError,
|
|
coverage,
|
|
coverageLoading,
|
|
coverageError,
|
|
coverageDurationMs,
|
|
coverageBudgetExceeded,
|
|
workspaceLoading,
|
|
workspaceError,
|
|
selectionExporting,
|
|
selectionExportError,
|
|
latestSelectionExportPath,
|
|
selectionDatasetSaving,
|
|
selectionDatasetError,
|
|
latestSelectionDataset,
|
|
latestSelectionDatasetName,
|
|
mapQaReferenceDatasets,
|
|
selectedMapQaReferenceDatasetId,
|
|
mapSelectionQaRunning,
|
|
mapSelectionQaError,
|
|
mapSelectionQaResult,
|
|
latestMapSelectionQualityCheckId,
|
|
orthophotoAnalysisStage,
|
|
orthophotoAnalysisStatus,
|
|
orthophotoAnalysisError,
|
|
orthophotoAnalysisRunning,
|
|
orthophotoAnalysisQuality,
|
|
orthophotoAnalysisDetectionCount,
|
|
orthophotoProducts,
|
|
selectedOrthophotoProductKey,
|
|
orthophotoResult,
|
|
orthophotoImageUrl,
|
|
availableMapDatasets,
|
|
selectedMapDatasetId,
|
|
onSelectMapArea,
|
|
onSetContextSourceLabel,
|
|
onSetContextLayerLabel,
|
|
onOpenDatasetInMap,
|
|
onSetAreaLayerVisible,
|
|
onSetAreaLayerOpacity,
|
|
onSetMapLayerVisible,
|
|
onSetMapLayerOpacity,
|
|
onSetMapContentMode,
|
|
onSelectMapFeature,
|
|
onMapViewportChange,
|
|
onSetMapSelectionBbox,
|
|
onRunMapSelectionExtract,
|
|
onClearMapSelectionExtract,
|
|
onExportMapSelection,
|
|
onPersistMapResult,
|
|
onDeriveMapSelectionDataset,
|
|
onSelectMapQaReferenceDataset,
|
|
onRunMapSelectionQa,
|
|
onOpenMapSelectionQualityEvidence,
|
|
onRunOrthophotoAnalysis,
|
|
onSelectOrthophotoProduct,
|
|
onClearQualityEvidence,
|
|
onRefreshProjectData,
|
|
onOpenAssistant,
|
|
onOpenExports,
|
|
}: MapWorkspaceProps): JSX.Element {
|
|
const [advancedMode, setAdvancedMode] = useState(false)
|
|
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => {
|
|
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
|
return themeIdForDataset(selectedDataset) ?? 'buildings'
|
|
})
|
|
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
|
|
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
|
|
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
|
|
const selectedCoverageZones = useMemo(
|
|
() => selectedAreaCoverageZones(selectedMapArea?.name),
|
|
[selectedMapArea?.name],
|
|
)
|
|
const flandersScopeSelected = Boolean(
|
|
selectedCoverageZones?.includes('flanders')
|
|
|| (!selectedCoverageZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME),
|
|
)
|
|
const {
|
|
themeInsights,
|
|
themeInsightsLoading: themeResultsLoading,
|
|
themeInsightsError: themeResultsError,
|
|
loadThemeInsights,
|
|
clearThemeInsights,
|
|
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId, onRefreshProjectData)
|
|
const {
|
|
products: officialMapProducts,
|
|
loading: officialMapProductsLoading,
|
|
error: officialMapProductsError,
|
|
resolveCoverage,
|
|
resolveCoveragePartitions,
|
|
} = useOfficialMapProducts(selectedProjectId)
|
|
const {
|
|
temporalComparison,
|
|
temporalComparisonLoading,
|
|
temporalComparisonError,
|
|
compareTemporalSnapshots,
|
|
clearTemporalComparison,
|
|
} = useTemporalComparison(selectedProjectId)
|
|
const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current')
|
|
const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('')
|
|
const [selectedDhmvProductKey, setSelectedDhmvProductKey] = useState<'dtm_1m' | 'dsm_1m'>('dtm_1m')
|
|
const [selectedFloodHazardProductKey, setSelectedFloodHazardProductKey] = useState('pluviaal_current_t100')
|
|
const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('')
|
|
const [earlierDatasetId, setEarlierDatasetId] = useState('')
|
|
const [laterDatasetId, setLaterDatasetId] = useState('')
|
|
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
|
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
|
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
|
|
const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false)
|
|
const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.')
|
|
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
|
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
|
|
const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState<number | null>(null)
|
|
const mapAnalysisRequestSequence = useRef(0)
|
|
const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name))
|
|
const featureProperties = selectedMapFeature?.properties ?? null
|
|
const isBathymetryProfile = featureProperties?.['provider'] === 'vmm_vha_bathymetry_profiles'
|
|
|| featureProperties?.['measurement_semantics'] === 'historical_cross_section_profile_point'
|
|
const bathymetryDocumentUrl = typeof featureProperties?.['source_document_url'] === 'string'
|
|
&& featureProperties['source_document_url'].startsWith('https://vha.waterinfo.be/')
|
|
? featureProperties['source_document_url']
|
|
: null
|
|
const featureSummaryEntries = featureProperties
|
|
? Object.entries(featureProperties)
|
|
.filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
|
|
.slice(0, 6)
|
|
: []
|
|
const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : []
|
|
const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature)
|
|
const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null
|
|
const selectedFeatureBbox = useMemo(() => getFeatureBBox(selectedMapFeature), [selectedMapFeature])
|
|
const activeLayerBbox = useMemo(() => getFeatureCollectionBBox(mapFeatureCollection), [mapFeatureCollection])
|
|
const selectedAreaBbox = useMemo(() => getFeatureCollectionBBox(areaFeatureCollection), [areaFeatureCollection])
|
|
const currentSelectionBbox = parseBboxInput(bboxInput)
|
|
const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? []
|
|
const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12)
|
|
const selectedFeatureStem = safeFileStem(
|
|
featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature',
|
|
)
|
|
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
|
|
const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
|
const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL
|
|
const floodHazardDatasets = useMemo(
|
|
() => {
|
|
const scoped = availableMapDatasets
|
|
.filter(
|
|
(dataset) =>
|
|
dataset.source_name === 'vmm_flood_hazard'
|
|
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected),
|
|
)
|
|
.sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl'))
|
|
if (!regionalScopeSelected) {
|
|
return scoped
|
|
}
|
|
const products = new Map<string, DatasetCreateResponse>()
|
|
for (const dataset of scoped) {
|
|
const key = datasetProductKey(dataset)
|
|
if (key && !products.has(key)) {
|
|
products.set(key, dataset)
|
|
}
|
|
}
|
|
return Array.from(products.values())
|
|
},
|
|
[availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId],
|
|
)
|
|
const themeDatasetMap = useMemo(() => {
|
|
const result = Object.fromEntries(
|
|
DATA_THEMES.map((theme) => [
|
|
theme.id,
|
|
pickThemeDataset(
|
|
availableMapDatasets,
|
|
theme,
|
|
selectedMapAreaId,
|
|
selectedMapArea?.name,
|
|
regionalScopeSelected,
|
|
),
|
|
]),
|
|
) as Record<DataThemeId, DatasetCreateResponse | null>
|
|
const selectedFloodHazard = floodHazardDatasets.find(
|
|
(dataset) =>
|
|
dataset.id === selectedFloodHazardDatasetId
|
|
|| (flandersScopeSelected && datasetProductKey(dataset) === selectedFloodHazardProductKey),
|
|
)
|
|
if (selectedFloodHazard) {
|
|
result.flood_hazard = selectedFloodHazard
|
|
} else if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) {
|
|
result.flood_hazard = null
|
|
}
|
|
if (flandersScopeSelected && officialMapProducts.dhmv.length > 0) {
|
|
result.elevation = availableMapDatasets.find(
|
|
(dataset) =>
|
|
dataset.source_name === 'digitaal_vlaanderen_dhmv'
|
|
&& datasetProductKey(dataset) === selectedDhmvProductKey
|
|
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected),
|
|
) ?? null
|
|
}
|
|
if (flandersScopeSelected && officialMapProducts.thematic.length > 0) {
|
|
for (const product of officialMapProducts.thematic) {
|
|
if (!result[product.theme]) {
|
|
result[product.theme] = null
|
|
}
|
|
}
|
|
}
|
|
if (flandersScopeSelected && officialMapProducts.grb.length > 0) {
|
|
for (const product of officialMapProducts.grb) {
|
|
if (!result[product.key]) {
|
|
result[product.key] = null
|
|
}
|
|
}
|
|
}
|
|
if (officialMapProducts.officialVector.length > 0) {
|
|
for (const product of officialMapProducts.officialVector.filter((item) =>
|
|
productCoversZones(item.coverage_zones, selectedCoverageZones),
|
|
)) {
|
|
if (!result[product.theme]) {
|
|
result[product.theme] = null
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}, [
|
|
availableMapDatasets,
|
|
flandersScopeSelected,
|
|
floodHazardDatasets,
|
|
officialMapProducts.dhmv.length,
|
|
officialMapProducts.floodHazard.length,
|
|
officialMapProducts.grb,
|
|
officialMapProducts.officialVector,
|
|
officialMapProducts.thematic,
|
|
regionalScopeSelected,
|
|
selectedDhmvProductKey,
|
|
selectedFloodHazardDatasetId,
|
|
selectedFloodHazardProductKey,
|
|
selectedMapArea?.name,
|
|
selectedMapAreaId,
|
|
selectedCoverageZones,
|
|
])
|
|
const themePartitionMap = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
DATA_THEMES.map((theme) => [
|
|
theme.id,
|
|
rasterPartitionsForDataset(
|
|
availableMapDatasets,
|
|
themeDatasetMap[theme.id],
|
|
selectedMapAreaId,
|
|
selectedMapArea?.name,
|
|
regionalScopeSelected,
|
|
),
|
|
]),
|
|
) as Record<DataThemeId, DatasetCreateResponse[]>,
|
|
[availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId, themeDatasetMap],
|
|
)
|
|
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
|
|
const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id]
|
|
const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? []
|
|
const coverageCounts = useMemo(
|
|
() => coverage?.items.reduce<Record<CoverageStatus, number>>(
|
|
(counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }),
|
|
{ operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
|
|
) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
|
|
[coverage],
|
|
)
|
|
const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => {
|
|
const result: OnDemandMapProduct[] = []
|
|
const effectiveZones = zones ?? selectedCoverageZones
|
|
const includesFlanders = Boolean(
|
|
effectiveZones?.includes('flanders')
|
|
|| (!effectiveZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME),
|
|
)
|
|
if (includesFlanders) {
|
|
for (const product of officialMapProducts.thematic) {
|
|
result.push({
|
|
kind: 'thematic_raster',
|
|
productKey: product.key,
|
|
displayName: product.display_name,
|
|
theme: product.theme,
|
|
availabilityLabel: `${product.native_resolution_m} m · ${product.observation_year} · automatisch bij selectie`,
|
|
attribution: product.attribution,
|
|
limitationMessage: product.limitation_message,
|
|
})
|
|
}
|
|
for (const product of officialMapProducts.grb) {
|
|
result.push({
|
|
kind: 'grb',
|
|
productKey: product.key,
|
|
displayName: product.display_name,
|
|
theme: product.key,
|
|
availabilityLabel: 'officiële vectorbron · automatisch bij selectie',
|
|
attribution: product.attribution,
|
|
limitationMessage: product.limitation_message,
|
|
})
|
|
}
|
|
for (const source of officialMapProducts.bathymetry.filter(
|
|
(item) =>
|
|
item.key === 'vha_inland_profiles'
|
|
&& item.acquisition_supported
|
|
&& item.configured,
|
|
)) {
|
|
result.push({
|
|
kind: 'bathymetry_profiles',
|
|
productKey: source.key,
|
|
displayName: source.display_name,
|
|
theme: 'bathymetry',
|
|
availabilityLabel: 'historische profielpunten · automatisch bij selectie',
|
|
attribution: source.attribution,
|
|
limitationMessage: source.limitation_message,
|
|
})
|
|
}
|
|
}
|
|
for (const product of officialMapProducts.officialVector.filter((item) =>
|
|
productCoversZones(item.coverage_zones, effectiveZones),
|
|
)) {
|
|
result.push({
|
|
kind: 'official_vector',
|
|
productKey: product.key,
|
|
displayName: product.display_name,
|
|
theme: product.theme,
|
|
availabilityLabel: `${product.observation_label} · officiële vectorbron · automatisch bij selectie`,
|
|
attribution: product.attribution,
|
|
limitationMessage: product.limitation_message,
|
|
})
|
|
}
|
|
const dhmvProduct = includesFlanders
|
|
? officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
|
|
: null
|
|
if (dhmvProduct) {
|
|
result.push({
|
|
kind: 'dhmv',
|
|
productKey: dhmvProduct.key,
|
|
displayName: dhmvProduct.display_name,
|
|
theme: 'elevation',
|
|
availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · automatisch bij selectie`,
|
|
attribution: dhmvProduct.attribution,
|
|
limitationMessage: dhmvProduct.limitation_message,
|
|
})
|
|
}
|
|
const floodProduct = includesFlanders
|
|
? officialMapProducts.floodHazard.find(
|
|
(product) => product.key === selectedFloodHazardProductKey,
|
|
)
|
|
: null
|
|
if (floodProduct) {
|
|
result.push({
|
|
kind: 'flood_hazard',
|
|
productKey: floodProduct.key,
|
|
displayName: floodProduct.display_name,
|
|
theme: 'flood_hazard',
|
|
availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · automatisch bij selectie`,
|
|
attribution: floodProduct.attribution,
|
|
limitationMessage: floodProduct.limitation_message,
|
|
})
|
|
}
|
|
return result
|
|
}, [
|
|
activeScopeProject?.name,
|
|
officialMapProducts,
|
|
selectedCoverageZones,
|
|
selectedDhmvProductKey,
|
|
selectedFloodHazardProductKey,
|
|
])
|
|
const onDemandProductMap = useMemo(() => {
|
|
const result = new Map<DataThemeId, OnDemandMapProduct>()
|
|
for (const product of onDemandProductsForZones(selectedCoverageZones)) {
|
|
result.set(product.theme, product)
|
|
}
|
|
return result
|
|
}, [onDemandProductsForZones, selectedCoverageZones])
|
|
const mapSelectionScale = mapSelectionBbox ? selectionAnalysisScale(mapSelectionBbox) : null
|
|
const selectionRelevantThemes = useMemo(() => {
|
|
if (!mapSelectionBbox || !coverage) {
|
|
return DATA_THEMES
|
|
}
|
|
const boundedThemes = new Set(
|
|
(mapSelectionBbox
|
|
? onDemandProductsForZones(coverage.intersected_zones).filter(
|
|
(product) => productSupportsSelection(product, mapSelectionBbox),
|
|
)
|
|
: [])
|
|
.map((product) => product.theme),
|
|
)
|
|
return DATA_THEMES.filter((theme) => {
|
|
if (boundedThemes.has(theme.id)) {
|
|
return true
|
|
}
|
|
const dataset = themeDatasetMap[theme.id]
|
|
if (!dataset || !persistedDatasetSupportsSelection(dataset, mapSelectionBbox)) {
|
|
return false
|
|
}
|
|
const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id]
|
|
return coverage.items.some(
|
|
(item) => item.theme === coverageTheme && item.status === 'operational',
|
|
)
|
|
})
|
|
}, [coverage, mapSelectionBbox, mapSelectionScale, onDemandProductsForZones, themeDatasetMap])
|
|
const activeThemeSupportsCurrentSelection = selectionRelevantThemes.some((theme) => theme.id === activeTheme.id)
|
|
const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id]
|
|
? null
|
|
: onDemandProductMap.get(activeTheme.id) ?? null
|
|
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
|
|
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
|
|
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
|
|
const orthophotoImageOverlay = useMemo(
|
|
() => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4
|
|
? {
|
|
url: orthophotoImageUrl,
|
|
bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number],
|
|
label: orthophotoResult.display_name,
|
|
opacity: 0.9,
|
|
}
|
|
: null,
|
|
[orthophotoImageUrl, orthophotoResult],
|
|
)
|
|
const activeThemeDataset = themeDatasetMap[activeTheme.id]
|
|
const activeThemePartitions = themePartitionMap[activeTheme.id]
|
|
const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset)
|
|
const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset)
|
|
const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive
|
|
const onDemandThemeActive = analysisMode === 'current' && Boolean(activeOnDemandMapProduct)
|
|
const regionalOnDemandThemeActive = regionalScopeSelected && onDemandThemeActive
|
|
const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThemeActive
|
|
const coverageSelectionAvailable = analysisMode === 'current' && Boolean(selectedProjectId && selectedMapArea)
|
|
|
|
useEffect(() => {
|
|
if (
|
|
analysisMode !== 'current'
|
|
|| activeThemeAvailable
|
|
|| (
|
|
selectedProjectId
|
|
&& officialMapProductsLoading
|
|
&& onDemandProductMap.size === 0
|
|
&& !officialMapProductsError
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
const fallbackTheme = DATA_THEMES.find((theme) =>
|
|
flandersScopeSelected
|
|
&& theme.id === 'space_occupation'
|
|
&& Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)),
|
|
) ?? DATA_THEMES.find((theme) =>
|
|
Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)),
|
|
)
|
|
if (!fallbackTheme) {
|
|
return
|
|
}
|
|
setActiveThemeId(fallbackTheme.id)
|
|
const fallbackDataset = themeDatasetMap[fallbackTheme.id]
|
|
if (fallbackDataset) {
|
|
onOpenDatasetInMap(fallbackDataset)
|
|
}
|
|
}, [
|
|
activeThemeAvailable,
|
|
analysisMode,
|
|
flandersScopeSelected,
|
|
officialMapProductsError,
|
|
officialMapProductsLoading,
|
|
onDemandProductMap,
|
|
onOpenDatasetInMap,
|
|
selectedProjectId,
|
|
themeDatasetMap,
|
|
])
|
|
|
|
const terrainImageOverlays = useMemo(
|
|
() =>
|
|
activeTheme.id === 'elevation' && selectedProjectId
|
|
? activeThemePartitions.flatMap((dataset) => {
|
|
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
|
return dataset.source_name === 'digitaal_vlaanderen_dhmv'
|
|
&& Array.isArray(bounds)
|
|
&& bounds.length === 4
|
|
? [{
|
|
url: terrainImageUrl(selectedProjectId, dataset.id),
|
|
bbox: bounds.map(Number) as [number, number, number, number],
|
|
label: getDatasetDisplayName(dataset),
|
|
opacity: 0.82,
|
|
}]
|
|
: []
|
|
})
|
|
: [],
|
|
[activeTheme.id, activeThemePartitions, selectedProjectId],
|
|
)
|
|
const floodHazardImageOverlays = useMemo(
|
|
() =>
|
|
activeTheme.id === 'flood_hazard' && selectedProjectId
|
|
? activeThemePartitions.flatMap((dataset) => {
|
|
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
|
return dataset.source_name === 'vmm_flood_hazard'
|
|
&& Array.isArray(bounds)
|
|
&& bounds.length === 4
|
|
? [{
|
|
url: floodHazardImageUrl(selectedProjectId, dataset.id),
|
|
bbox: bounds.map(Number) as [number, number, number, number],
|
|
label: floodScenarioLabel(dataset),
|
|
opacity: 0.82,
|
|
}]
|
|
: []
|
|
})
|
|
: [],
|
|
[activeTheme.id, activeThemePartitions, selectedProjectId],
|
|
)
|
|
const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster'
|
|
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
|
: null
|
|
const thematicRasterImageOverlays = useMemo(
|
|
() => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4
|
|
? [{
|
|
url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
|
bbox: thematicRasterBounds.map(Number) as [number, number, number, number],
|
|
label: getDatasetDisplayName(activeThemeDataset),
|
|
opacity: 0.78,
|
|
}]
|
|
: [],
|
|
[activeThemeDataset, selectedProjectId, thematicRasterBounds],
|
|
)
|
|
const bathymetryRasterBounds = activeThemeDataset?.source_name === 'spw_bathymetry'
|
|
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
|
: null
|
|
const bathymetryRasterImageOverlays = useMemo(
|
|
() => activeThemeDataset?.source_name === 'spw_bathymetry' && selectedProjectId && Array.isArray(bathymetryRasterBounds) && bathymetryRasterBounds.length === 4
|
|
? [{
|
|
url: bathymetryRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
|
bbox: bathymetryRasterBounds.map(Number) as [number, number, number, number],
|
|
label: 'Waterbodemhoogte in mDNG',
|
|
opacity: 0.86,
|
|
}]
|
|
: [],
|
|
[activeThemeDataset, bathymetryRasterBounds, selectedProjectId],
|
|
)
|
|
const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde')
|
|
const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde')
|
|
const activeImageOverlays = useMemo(
|
|
() => bathymetryRasterImageOverlays.length > 0
|
|
? bathymetryRasterImageOverlays
|
|
: thematicRasterImageOverlays.length > 0
|
|
? thematicRasterImageOverlays
|
|
: floodHazardImageOverlays.length > 0
|
|
? floodHazardImageOverlays
|
|
: terrainImageOverlays.length > 0
|
|
? terrainImageOverlays
|
|
: orthophotoImageOverlay ? [orthophotoImageOverlay] : [],
|
|
[bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays],
|
|
)
|
|
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
|
|
const themeTemporalSeriesMap = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme)]),
|
|
) as Record<DataThemeId, TemporalSeriesGroup[]>,
|
|
[availableMapDatasets],
|
|
)
|
|
const activeTemporalSeriesGroups = themeTemporalSeriesMap[activeTheme.id]
|
|
const availableEvolutionThemes = DATA_THEMES.filter((theme) =>
|
|
themeTemporalSeriesMap[theme.id].some((group) => group.items.length >= 2),
|
|
)
|
|
const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey)
|
|
?? activeTemporalSeriesGroups[0]
|
|
const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES
|
|
const earlierTemporalOptions = activeTemporalSeries.slice(0, -1)
|
|
const selectedEarlierSnapshot = activeTemporalSeries.find((dataset) => dataset.id === earlierDatasetId)
|
|
const selectedLaterSnapshot = activeTemporalSeries.find((dataset) => dataset.id === laterDatasetId)
|
|
const selectedEarlierTime = new Date(selectedEarlierSnapshot?.observed_at ?? 0).getTime()
|
|
const laterTemporalOptions = activeTemporalSeries.filter(
|
|
(dataset) => new Date(dataset.observed_at ?? 0).getTime() > selectedEarlierTime,
|
|
)
|
|
const temporalSelectionValid = Boolean(
|
|
selectedEarlierSnapshot
|
|
&& selectedLaterSnapshot
|
|
&& selectedEarlierSnapshot.id !== selectedLaterSnapshot.id
|
|
&& selectedEarlierTime < new Date(selectedLaterSnapshot.observed_at ?? 0).getTime(),
|
|
)
|
|
const activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2
|
|
&& activeTemporalSeries.every((dataset) => dataset.source_name === 'grb')
|
|
&& new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime()
|
|
- new Date(activeTemporalSeries[0].observed_at ?? 0).getTime() <= 7 * 24 * 60 * 60 * 1000
|
|
|
|
useEffect(() => {
|
|
const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null
|
|
: regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen'
|
|
: activeOnDemandMapProduct?.displayName ?? null
|
|
onSetContextSourceLabel(contextSourceLabel)
|
|
return () => onSetContextSourceLabel(null)
|
|
}, [
|
|
activeTemporalSeriesGroup?.label,
|
|
activeOnDemandMapProduct?.displayName,
|
|
analysisMode,
|
|
onSetContextSourceLabel,
|
|
regionalBathymetryThemeActive,
|
|
])
|
|
|
|
const themeResults = useMemo(
|
|
() =>
|
|
themeInsights.flatMap((insight) => {
|
|
const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId)
|
|
return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : []
|
|
}),
|
|
[themeInsights],
|
|
)
|
|
const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId)
|
|
const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset
|
|
const activeSelectionResult = activeThemeInsight?.result
|
|
?? (!regionalPartitionedThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null)
|
|
useEffect(() => {
|
|
const contextLayerLabel = analysisMode === 'current' && activeOnDemandMapProduct
|
|
? activeSelectionResult
|
|
? `${(
|
|
activeSelectionResult.total_feature_count
|
|
?? activeSelectionResult.feature_count
|
|
).toLocaleString('nl-BE')} objecten gemeten`
|
|
: 'Automatisch bij selectie'
|
|
: null
|
|
onSetContextLayerLabel(contextLayerLabel)
|
|
return () => onSetContextLayerLabel(null)
|
|
}, [
|
|
activeOnDemandMapProduct,
|
|
activeSelectionResult,
|
|
analysisMode,
|
|
onSetContextLayerLabel,
|
|
])
|
|
const explorerMapFeatureCollection = regionalBathymetryThemeActive
|
|
? null
|
|
: analysisMode === 'evolution' && temporalComparison?.geojson.features.length
|
|
? temporalComparison.geojson
|
|
: onDemandThemeActive
|
|
? null
|
|
: mapFeatureCollection
|
|
const explorerSelectionFeatureCollection = analysisMode === 'current'
|
|
? activeSelectionResult?.geojson ?? null
|
|
: null
|
|
const selectedAreaSquareMetres = useMemo(
|
|
() =>
|
|
bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2
|
|
? selectedMapArea.area_m2
|
|
: selectionAreaSquareMetres(mapSelectionBbox),
|
|
[mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2],
|
|
)
|
|
const selectionScaleNotice = useMemo(() => {
|
|
if (!mapSelectionBbox || !mapSelectionScale) return null
|
|
const dimensions = selectionDimensions(mapSelectionBbox)
|
|
const widthKm = dimensions.widthMetres / 1000
|
|
const heightKm = dimensions.heightMetres / 1000
|
|
if (mapSelectionScale === 'regional') {
|
|
const partitionCount = splitSelectionBbox(mapSelectionBbox).length
|
|
return `Regionale selectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Het gekozen thema kan over maximaal ${partitionCount} begrensde bronpartities worden verwerkt; teken een kleiner gebied wanneer een fijnmazige rasterbron buiten het veilige pixelbudget valt.`
|
|
}
|
|
if (mapSelectionScale === 'overview') {
|
|
return `Overzichtsselectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Het gekozen detailthema wordt niet automatisch op deze schaal bevraagd. Teken maximaal 50 x 50 km voor regionale thema's en ongeveer 16 x 16 km voor 5 m-rasters.`
|
|
}
|
|
return null
|
|
}, [mapSelectionBbox, mapSelectionScale])
|
|
const selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0
|
|
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
|
|
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
|
|
: null
|
|
const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal
|
|
const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten'
|
|
const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel
|
|
const activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter(
|
|
(metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key,
|
|
)
|
|
const terrainReliefMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'relief_m')
|
|
const populationDensityMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'population_density_mean_per_ha')
|
|
const scoreMedianMetric = activeSupportingMetrics.find((metric) => metric.metric_key.endsWith('_median'))
|
|
const activeSecondaryMetric = activeMetricUnit === 'm TAW'
|
|
? terrainReliefMetric ? `${terrainReliefMetric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits: 2 })} m reliëf` : null
|
|
: activeMetricUnit === 'inwoners'
|
|
? populationDensityMetric ? selectionMetricLabel(populationDensityMetric) : null
|
|
: activeMetricUnit.startsWith('score')
|
|
? scoreMedianMetric ? selectionMetricLabel(scoreMedianMetric) : null
|
|
: selectedAreaSquareMetres && selectedAreaSquareMetres > 0
|
|
? activeMetricUnit === 'ha'
|
|
? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking`
|
|
: `${(activeMetricValue / (selectedAreaSquareMetres / 1_000_000)).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${activeMetricUnit} / km2`
|
|
: null
|
|
const activeSecondaryLabel = activeMetricUnit === 'ha'
|
|
? 'Aandeel selectie'
|
|
: activeMetricUnit === 'm TAW'
|
|
? 'Reliëf'
|
|
: activeMetricUnit === 'inwoners'
|
|
? 'Gemiddelde dichtheid'
|
|
: activeMetricUnit.startsWith('score')
|
|
? 'Mediaan'
|
|
: 'Dichtheid'
|
|
const selectedResultProperties = useMemo(() => {
|
|
const keys = new Map<string, Set<string>>()
|
|
for (const feature of activeSelectionResult?.geojson.features ?? []) {
|
|
for (const [key, value] of Object.entries(feature.properties ?? {})) {
|
|
if (value === null || value === undefined || typeof value === 'object' || key.endsWith('_id')) {
|
|
continue
|
|
}
|
|
const values = keys.get(key) ?? new Set<string>()
|
|
if (values.size < 4) {
|
|
values.add(String(value))
|
|
}
|
|
keys.set(key, values)
|
|
}
|
|
}
|
|
return Array.from(keys.entries())
|
|
.filter(([, values]) => values.size > 0)
|
|
.slice(0, 8)
|
|
.map(([key, values]) => ({ key, values: Array.from(values) }))
|
|
}, [activeSelectionResult])
|
|
|
|
useEffect(() => {
|
|
setBboxInput(bboxToInputState(mapSelectionBbox))
|
|
}, [mapSelectionBbox])
|
|
|
|
useEffect(() => {
|
|
setSelectedTemporalSeriesKey((current) =>
|
|
activeTemporalSeriesGroups.some((group) => group.key === current)
|
|
? current
|
|
: activeTemporalSeriesGroups[0]?.key ?? '',
|
|
)
|
|
}, [activeTemporalSeriesGroups])
|
|
|
|
useEffect(() => {
|
|
const selected = floodHazardDatasets.find(
|
|
(dataset) => datasetProductKey(dataset) === selectedFloodHazardProductKey,
|
|
)
|
|
if (selected) {
|
|
if (selected.id !== selectedFloodHazardDatasetId) {
|
|
setSelectedFloodHazardDatasetId(selected.id)
|
|
}
|
|
return
|
|
}
|
|
if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) {
|
|
setSelectedFloodHazardDatasetId('')
|
|
return
|
|
}
|
|
const preferred = floodHazardDatasets.find(
|
|
(dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100',
|
|
) ?? floodHazardDatasets[0]
|
|
setSelectedFloodHazardDatasetId(preferred?.id ?? '')
|
|
if (preferred && datasetProductKey(preferred)) {
|
|
setSelectedFloodHazardProductKey(datasetProductKey(preferred))
|
|
}
|
|
}, [
|
|
flandersScopeSelected,
|
|
floodHazardDatasets,
|
|
officialMapProducts.floodHazard.length,
|
|
selectedFloodHazardDatasetId,
|
|
selectedFloodHazardProductKey,
|
|
])
|
|
|
|
useEffect(() => {
|
|
const first = activeTemporalSeries[0]
|
|
const last = activeTemporalSeries[activeTemporalSeries.length - 1]
|
|
setEarlierDatasetId(first?.id ?? '')
|
|
setLaterDatasetId(last?.id ?? '')
|
|
clearTemporalComparison()
|
|
}, [activeTemporalSeries])
|
|
|
|
useEffect(() => {
|
|
if (advancedMode || !activeThemeDataset || selectedMapDataset?.id === activeThemeDataset.id) {
|
|
return
|
|
}
|
|
onOpenDatasetInMap(activeThemeDataset)
|
|
}, [activeTheme, activeThemeDataset, advancedMode, onOpenDatasetInMap, selectedMapDataset])
|
|
|
|
useEffect(() => {
|
|
if (!selectedMapDataset) {
|
|
return
|
|
}
|
|
const selectedProductKey = datasetProductKey(selectedMapDataset)
|
|
if (
|
|
selectedMapDataset.source_name === 'digitaal_vlaanderen_dhmv'
|
|
&& (selectedProductKey === 'dtm_1m' || selectedProductKey === 'dsm_1m')
|
|
) {
|
|
setSelectedDhmvProductKey(selectedProductKey)
|
|
}
|
|
if (selectedMapDataset.source_name === 'vmm_flood_hazard' && selectedProductKey) {
|
|
setSelectedFloodHazardProductKey(selectedProductKey)
|
|
setSelectedFloodHazardDatasetId(selectedMapDataset.id)
|
|
}
|
|
const matchingThemeId = themeIdForDataset(selectedMapDataset)
|
|
if (matchingThemeId) {
|
|
setActiveThemeId(matchingThemeId)
|
|
}
|
|
}, [selectedMapDataset])
|
|
|
|
const downloadSelectedMapFeature = () => {
|
|
if (!selectedFeatureGeoJson) {
|
|
return
|
|
}
|
|
downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson, 'application/geo+json')
|
|
}
|
|
|
|
const copySelectedMapFeatureProperties = () => {
|
|
copyText(JSON.stringify(featureProperties ?? {}, null, 2))
|
|
}
|
|
|
|
const setSelectionBbox = (bbox: VectorSelectionBBox | null) => {
|
|
onSetMapSelectionBbox(bbox)
|
|
setBboxInput(bboxToInputState(bbox))
|
|
}
|
|
|
|
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
|
|
bbox && selectedMapArea ? selectedMapArea.id : undefined
|
|
)
|
|
|
|
const startBboxSelection = () => {
|
|
setFirstSelectionCorner(null)
|
|
clearThemeInsights()
|
|
clearTemporalComparison()
|
|
setBboxSelectionMode(true)
|
|
}
|
|
|
|
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
|
|
if (!firstSelectionCorner) {
|
|
setFirstSelectionCorner(coordinate)
|
|
return
|
|
}
|
|
const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate)
|
|
setSelectionBbox(bbox)
|
|
setFirstSelectionCorner(null)
|
|
setBboxSelectionMode(false)
|
|
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
|
}
|
|
|
|
const runAreaExtract = () => {
|
|
const bbox = parseBboxInput(bboxInput)
|
|
if (!bbox) {
|
|
return
|
|
}
|
|
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
|
}
|
|
|
|
const clearAreaSelection = () => {
|
|
mapAnalysisRequestSequence.current += 1
|
|
setMapAnalysisDurationMs(null)
|
|
setBboxSelectionMode(false)
|
|
setFirstSelectionCorner(null)
|
|
setBboxInput(bboxToInputState(null))
|
|
clearThemeInsights()
|
|
clearTemporalComparison()
|
|
onClearMapSelectionExtract()
|
|
}
|
|
|
|
const handleSelectMapArea = (areaId: string) => {
|
|
clearAreaSelection()
|
|
onSelectMapArea(areaId)
|
|
}
|
|
|
|
const downloadAreaSelection = () => {
|
|
if (!mapSelectionResult) {
|
|
return
|
|
}
|
|
downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson, 'application/geo+json')
|
|
}
|
|
|
|
const copyAreaSelection = () => {
|
|
copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
|
|
}
|
|
|
|
const downloadActiveThemeResult = () => {
|
|
if (!activeSelectionResult) {
|
|
return
|
|
}
|
|
if (activeResultDataset?.dataset_type === 'raster') {
|
|
downloadJsonFile(`${activeTheme.id}-analysis.json`, {
|
|
project_id: selectedProjectId,
|
|
area_id: areaIdForSelection(mapSelectionBbox) ?? null,
|
|
area_name: selectedMapArea?.name ?? null,
|
|
theme: activeTheme,
|
|
dataset_id: activeResultDataset.id,
|
|
dataset_name: activeResultDataset.name,
|
|
source_name: activeResultDataset.source_name,
|
|
result: activeSelectionResult,
|
|
})
|
|
return
|
|
}
|
|
downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson, 'application/geo+json')
|
|
}
|
|
|
|
const copyActiveThemeResult = () => {
|
|
copyText(JSON.stringify(activeSelectionResult ?? {}, null, 2))
|
|
}
|
|
|
|
const downloadTemporalComparison = () => {
|
|
if (!temporalComparison) {
|
|
return
|
|
}
|
|
downloadJsonFile(`${activeTheme.id}-evolution-${temporalComparison.earlier.observed_at.slice(0, 10)}-${temporalComparison.later.observed_at.slice(0, 10)}.json`, temporalComparison)
|
|
}
|
|
|
|
const copyTemporalComparison = () => {
|
|
copyText(JSON.stringify(temporalComparison ?? {}, null, 2))
|
|
}
|
|
|
|
const persistActiveResultAndOpenDownloads = async () => {
|
|
if (!selectedProjectId || !mapSelectionBbox) {
|
|
onOpenExports()
|
|
return
|
|
}
|
|
const areaId = areaIdForSelection(mapSelectionBbox)
|
|
const payload: MapResultExportRequest | null = analysisMode === 'evolution'
|
|
? temporalComparison && earlierDatasetId && laterDatasetId
|
|
? {
|
|
project_id: selectedProjectId,
|
|
mode: 'evolution',
|
|
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
|
|
earlier_dataset_id: earlierDatasetId,
|
|
later_dataset_id: laterDatasetId,
|
|
area_id: areaId,
|
|
theme_id: activeTheme.id,
|
|
name: `${activeTheme.id}-evolution`,
|
|
}
|
|
: null
|
|
: activeSelectionResult && activeResultDataset
|
|
? {
|
|
project_id: selectedProjectId,
|
|
mode: 'current',
|
|
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
|
|
dataset_id: activeResultDataset.id,
|
|
area_id: areaId,
|
|
partitioned: regionalPartitionedThemeActive,
|
|
product_key: regionalRasterThemeActive
|
|
? String(activeResultDataset.source_metadata?.['product_key'] ?? '') || undefined
|
|
: undefined,
|
|
partition_scope_key: regionalBathymetryThemeActive
|
|
? String(activeResultDataset.source_metadata?.['partition_scope_key'] ?? 'flanders')
|
|
: undefined,
|
|
theme_id: activeTheme.id,
|
|
name: `${activeTheme.id}-analysis`,
|
|
}
|
|
: null
|
|
if (!payload) {
|
|
onOpenExports()
|
|
return
|
|
}
|
|
const persisted = await onPersistMapResult(payload)
|
|
if (persisted) {
|
|
onOpenExports()
|
|
}
|
|
}
|
|
|
|
const saveAreaSelectionExport = () => {
|
|
const bbox = parseBboxInput(bboxInput)
|
|
if (!bbox) {
|
|
return
|
|
}
|
|
onExportMapSelection(bbox, areaIdForSelection(bbox))
|
|
}
|
|
|
|
const saveAreaSelectionDataset = () => {
|
|
const bbox = parseBboxInput(bboxInput)
|
|
if (!bbox) {
|
|
return
|
|
}
|
|
onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))
|
|
}
|
|
|
|
const openSelectedDatabaseLayer = (datasetId: string) => {
|
|
const dataset = availableMapDatasets.find((item) => item.id === datasetId)
|
|
if (dataset) {
|
|
onOpenDatasetInMap(dataset)
|
|
}
|
|
}
|
|
|
|
const selectDataTheme = (theme: DataTheme) => {
|
|
const temporalGroup = themeTemporalSeriesMap[theme.id][0]
|
|
const dataset = analysisMode === 'evolution'
|
|
? temporalGroup?.items[temporalGroup.items.length - 1] ?? null
|
|
: themeDatasetMap[theme.id]
|
|
const onDemandProduct = analysisMode === 'current'
|
|
? onDemandProductMap.get(theme.id)
|
|
: null
|
|
if (!dataset && !onDemandProduct) {
|
|
return
|
|
}
|
|
setActiveThemeId(theme.id)
|
|
clearTemporalComparison()
|
|
if (dataset) {
|
|
onOpenDatasetInMap(dataset)
|
|
}
|
|
}
|
|
|
|
const setExplorerMode = (mode: 'current' | 'evolution') => {
|
|
setAnalysisMode(mode)
|
|
clearTemporalComparison()
|
|
if (mode !== 'evolution' || activeTemporalSeriesGroups.length > 0) {
|
|
return
|
|
}
|
|
const fallbackTheme = availableEvolutionThemes[0]
|
|
const fallbackGroup = fallbackTheme ? themeTemporalSeriesMap[fallbackTheme.id][0] : null
|
|
const fallbackDataset = fallbackGroup?.items[fallbackGroup.items.length - 1]
|
|
if (fallbackTheme && fallbackDataset) {
|
|
setActiveThemeId(fallbackTheme.id)
|
|
onOpenDatasetInMap(fallbackDataset)
|
|
}
|
|
}
|
|
|
|
const handleAnalysisModeKeyDown = (
|
|
event: KeyboardEvent<HTMLButtonElement>,
|
|
mode: 'current' | 'evolution',
|
|
) => {
|
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
event.preventDefault()
|
|
const nextMode = event.key === 'ArrowLeft' || event.key === 'Home'
|
|
? 'current'
|
|
: event.key === 'ArrowRight' || event.key === 'End'
|
|
? 'evolution'
|
|
: mode
|
|
setExplorerMode(nextMode)
|
|
window.requestAnimationFrame(() => {
|
|
document.getElementById(`geo-analysis-tab-${nextMode}`)?.focus()
|
|
})
|
|
}
|
|
|
|
const loadSelectedThemeResult = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
|
let resolvedZones = selectedCoverageZones
|
|
const scale = selectionAnalysisScale(bbox)
|
|
if (analysisMode === 'current' && selectedProjectId) {
|
|
const resolvedCoverage = await resolveCoverage({
|
|
minx: bbox.min_x,
|
|
miny: bbox.min_y,
|
|
maxx: bbox.max_x,
|
|
maxy: bbox.max_y,
|
|
})
|
|
if (!resolvedCoverage) {
|
|
clearThemeInsights()
|
|
return
|
|
}
|
|
resolvedZones = resolvedCoverage.intersected_zones
|
|
}
|
|
let resolvedProducts: PlannedOnDemandMapProduct[] = []
|
|
if (analysisMode === 'current' && scale !== 'overview') {
|
|
const zoneProducts = resolvedZones
|
|
? onDemandProductsForZones(resolvedZones)
|
|
: []
|
|
if (scale === 'detail' || !selectedProjectId) {
|
|
resolvedProducts = zoneProducts
|
|
.filter((product) => productSupportsSelection(product, bbox))
|
|
.map((product) => ({
|
|
...product,
|
|
acquisitionBboxes: [bbox],
|
|
}))
|
|
} else {
|
|
const detailTiles = splitSelectionBbox(bbox)
|
|
const tileCoverage = await resolveCoveragePartitions(detailTiles)
|
|
if (!tileCoverage) {
|
|
clearThemeInsights()
|
|
return
|
|
}
|
|
const grouped = new Map<string, PlannedOnDemandMapProduct>()
|
|
for (const item of tileCoverage) {
|
|
for (const product of onDemandProductsForZones(item.coverage.intersected_zones)) {
|
|
if (product.kind === 'thematic_raster' || !productSupportsSelection(product, bbox)) continue
|
|
const key = `${product.kind}:${product.productKey}`
|
|
const existing = grouped.get(key)
|
|
if (existing) {
|
|
existing.acquisitionBboxes.push(item.bbox)
|
|
} else {
|
|
grouped.set(key, { ...product, acquisitionBboxes: [item.bbox] })
|
|
}
|
|
}
|
|
}
|
|
for (const product of zoneProducts.filter(
|
|
(candidate) => candidate.kind === 'thematic_raster' && productSupportsSelection(candidate, bbox),
|
|
)) {
|
|
grouped.set(`${product.kind}:${product.productKey}`, {
|
|
...product,
|
|
acquisitionBboxes: [bbox],
|
|
})
|
|
}
|
|
resolvedProducts = [...grouped.values()]
|
|
}
|
|
}
|
|
const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
|
|
const resultFeatureLimit = selectionFeatureLimit(bbox)
|
|
for (const theme of [activeTheme]) {
|
|
const dataset = themeDatasetMap[theme.id]
|
|
const partitioned = Boolean(
|
|
dataset
|
|
&& regionalScopeSelected
|
|
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
|
|
)
|
|
const coveringPartitions = partitioned
|
|
? themePartitionMap[theme.id].filter((partition) => datasetIntersectsSelection(partition, bbox))
|
|
: []
|
|
const persistedCoverageAvailable = Boolean(
|
|
dataset
|
|
&& persistedDatasetSupportsSelection(dataset, bbox)
|
|
&& (!partitioned || coveringPartitions.length > 0),
|
|
)
|
|
if (dataset && persistedCoverageAvailable) {
|
|
availableThemes.push({
|
|
themeId: theme.id,
|
|
dataset,
|
|
datasetIds: coveringPartitions.map((partition) => partition.id),
|
|
featureLimit: resultFeatureLimit,
|
|
partitioned,
|
|
})
|
|
continue
|
|
}
|
|
const onDemandProducts = resolvedProducts.filter((product) => product.theme === theme.id)
|
|
if (onDemandProducts.length > 0) {
|
|
for (const onDemandProduct of onDemandProducts) {
|
|
availableThemes.push({
|
|
themeId: theme.id,
|
|
acquisition: {
|
|
kind: onDemandProduct.kind,
|
|
productKey: onDemandProduct.productKey,
|
|
displayName: onDemandProduct.displayName,
|
|
},
|
|
acquisitionBboxes: onDemandProduct.acquisitionBboxes,
|
|
featureLimit: resultFeatureLimit,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
await loadThemeInsights(bbox, availableThemes, areaId)
|
|
}
|
|
|
|
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
|
const requestId = mapAnalysisRequestSequence.current + 1
|
|
mapAnalysisRequestSequence.current = requestId
|
|
const startedAt = Date.now()
|
|
setMapAnalysisDurationMs(null)
|
|
setSelectionBbox(bbox)
|
|
const tasks: Array<Promise<unknown>> = analysisMode === 'current'
|
|
? [loadSelectedThemeResult(bbox, areaId)]
|
|
: []
|
|
const activeDatasetSupportsSelection = !activeThemeDataset
|
|
|| persistedDatasetSupportsSelection(activeThemeDataset, bbox)
|
|
if (
|
|
analysisMode === 'current'
|
|
&& advancedMode
|
|
&& activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive
|
|
&& activeDatasetSupportsSelection
|
|
) {
|
|
tasks.push(onRunMapSelectionExtract(bbox, areaId))
|
|
}
|
|
if (analysisMode === 'evolution' && temporalSelectionValid) {
|
|
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
|
|
}
|
|
try {
|
|
await Promise.all(tasks)
|
|
} finally {
|
|
if (mapAnalysisRequestSequence.current === requestId) {
|
|
setMapAnalysisDurationMs(Date.now() - startedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
const runTemporalComparison = () => {
|
|
if (!mapSelectionBbox || !temporalSelectionValid) {
|
|
return
|
|
}
|
|
void compareTemporalSnapshots(
|
|
earlierDatasetId,
|
|
laterDatasetId,
|
|
mapSelectionBbox,
|
|
areaIdForSelection(mapSelectionBbox),
|
|
)
|
|
}
|
|
|
|
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
|
|
setSelectionBbox(bbox)
|
|
}
|
|
|
|
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
|
|
setFirstSelectionCorner(null)
|
|
setBboxSelectionMode(false)
|
|
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
|
}
|
|
|
|
const runQuickAoiExtract = () => {
|
|
const bbox = selectedAreaBbox ?? activeLayerBbox
|
|
if (!bbox) {
|
|
return
|
|
}
|
|
setSelectionBbox(bbox)
|
|
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
|
}
|
|
|
|
const runFullGisWorkflow = async () => {
|
|
const bbox = currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox
|
|
if (fullWorkflowMode === 'reuse') {
|
|
if (!latestSelectionDataset) {
|
|
setFullWorkflowError('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.')
|
|
return
|
|
}
|
|
if (!selectedMapQaReferenceDatasetId) {
|
|
setFullWorkflowError('Kies eerst een referentielaag voor de kwaliteitscontrole.')
|
|
return
|
|
}
|
|
setFullWorkflowRunning(true)
|
|
setFullWorkflowError(null)
|
|
try {
|
|
setFullWorkflowStatus('Laatste bewaarde resultaatlaag opnieuw controleren...')
|
|
const qaResult = await onRunMapSelectionQa(latestSelectionDataset)
|
|
setFullWorkflowStatus(qaResult ? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.' : 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.')
|
|
} catch (error) {
|
|
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
|
|
setFullWorkflowStatus('De werkstroom is gestopt.')
|
|
} finally {
|
|
setFullWorkflowRunning(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!selectedMapDataset || !bbox) {
|
|
setFullWorkflowError('Kies een kaartlaag en een werkgebied of laagbegrenzing.')
|
|
return
|
|
}
|
|
|
|
setFullWorkflowRunning(true)
|
|
setFullWorkflowError(null)
|
|
try {
|
|
setFullWorkflowStatus('1/4 Bewaarde kaartobjecten selecteren...')
|
|
setSelectionBbox(bbox)
|
|
const selectionAreaId = areaIdForSelection(bbox)
|
|
const selection = await onRunMapSelectionExtract(bbox, selectionAreaId)
|
|
if (!selection) {
|
|
setFullWorkflowError('De ruimtelijke selectie kon niet worden afgerond.')
|
|
setFullWorkflowStatus('Stopped at query.')
|
|
return
|
|
}
|
|
|
|
setFullWorkflowStatus('2/4 Saving derived result dataset...')
|
|
const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId)
|
|
if (!derived) {
|
|
setFullWorkflowError('De afgeleide resultaatlaag kon niet worden aangemaakt.')
|
|
setFullWorkflowStatus('Stopped at dataset save.')
|
|
return
|
|
}
|
|
|
|
setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...')
|
|
await onExportMapSelection(bbox, selectionAreaId)
|
|
|
|
if (selectedMapQaReferenceDatasetId) {
|
|
setFullWorkflowStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...')
|
|
const qaResult = await onRunMapSelectionQa(derived)
|
|
setFullWorkflowStatus(qaResult ? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.' : 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.')
|
|
} else {
|
|
setFullWorkflowStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.')
|
|
}
|
|
} catch (error) {
|
|
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
|
|
setFullWorkflowStatus('De werkstroom is gestopt.')
|
|
} finally {
|
|
setFullWorkflowRunning(false)
|
|
}
|
|
}
|
|
|
|
if (!advancedMode) {
|
|
return (
|
|
<section
|
|
className="geo-explorer"
|
|
data-testid="map-workspace"
|
|
aria-label={`Gebiedsverkenner ${activeScopeLabel}`}
|
|
aria-busy={workspaceLoading}
|
|
>
|
|
<header className="geo-explorer-header">
|
|
<div>
|
|
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
|
<h2>Gebied analyseren</h2>
|
|
<p>Kies een thema, teken een rechthoek en lees de beschikbare gegevens.</p>
|
|
</div>
|
|
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
|
<button
|
|
id="geo-analysis-tab-current"
|
|
className={analysisMode === 'current' ? 'active' : ''}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={analysisMode === 'current'}
|
|
aria-controls="geo-explorer-results"
|
|
tabIndex={analysisMode === 'current' ? 0 : -1}
|
|
onClick={() => setExplorerMode('current')}
|
|
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'current')}
|
|
>
|
|
Laatste toestand
|
|
</button>
|
|
<button
|
|
id="geo-analysis-tab-evolution"
|
|
className={analysisMode === 'evolution' ? 'active' : ''}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={analysisMode === 'evolution'}
|
|
aria-controls="geo-explorer-results"
|
|
tabIndex={analysisMode === 'evolution' ? 0 : -1}
|
|
onClick={() => setExplorerMode('evolution')}
|
|
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'evolution')}
|
|
>
|
|
Evolutie
|
|
</button>
|
|
</div>
|
|
<button
|
|
className="secondary-action geo-explorer-advanced"
|
|
type="button"
|
|
onClick={() => setAdvancedMode(true)}
|
|
aria-expanded={advancedMode}
|
|
aria-controls="geo-advanced-workbench"
|
|
aria-label="Geavanceerde werkbank"
|
|
title="Geavanceerde werkbank"
|
|
>
|
|
<SlidersHorizontal aria-hidden="true" />
|
|
<span>Geavanceerde werkbank</span>
|
|
</button>
|
|
</header>
|
|
|
|
{workspaceLoading ? (
|
|
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
|
<span className="geo-loading-indicator" aria-hidden="true" />
|
|
<div>
|
|
<strong>Databronnen worden gecontroleerd</strong>
|
|
<small>Beschikbaarheid verschijnt zodra de nationale werkruimte volledig is geladen.</small>
|
|
</div>
|
|
</div>
|
|
) : workspaceError ? (
|
|
<div className="geo-bootstrap-status geo-bootstrap-status-error" role="alert">
|
|
<div>
|
|
<strong>De werkruimte kon niet volledig worden geladen</strong>
|
|
<small>{workspaceError}</small>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="geo-explorer-layout">
|
|
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
|
<div className="geo-panel-heading">
|
|
<span>1</span>
|
|
<div>
|
|
<h3>Thema</h3>
|
|
<p>Kies welke gegevens u wilt meten.</p>
|
|
</div>
|
|
</div>
|
|
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
|
<span>Ingeladen bereik</span>
|
|
<strong>{activeScopeLabel}</strong>
|
|
<small>
|
|
{workspaceLoading
|
|
? 'Gebieden en bronnen worden geladen'
|
|
: municipalityAreaCount > 0
|
|
? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar`
|
|
: 'Geen gemeentelijke onderverdeling in deze werkruimte'}
|
|
</small>
|
|
</div>
|
|
<div className="geo-theme-list">
|
|
{DATA_THEMES.map((theme) => {
|
|
const dataset = themeDatasetMap[theme.id]
|
|
const onDemandProduct = onDemandProductMap.get(theme.id)
|
|
const partitions = themePartitionMap[theme.id]
|
|
const temporalGroups = themeTemporalSeriesMap[theme.id]
|
|
const temporalGroup = temporalGroups[0]
|
|
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
|
const available = !workspaceLoading && (analysisMode === 'current'
|
|
? Boolean(dataset || onDemandProduct)
|
|
: Boolean(dataset) && evolutionAvailable)
|
|
const active = activeThemeId === theme.id
|
|
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
|
|
return (
|
|
<button
|
|
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
|
|
disabled={!available}
|
|
key={theme.id}
|
|
type="button"
|
|
onClick={() => selectDataTheme(theme)}
|
|
aria-pressed={active}
|
|
>
|
|
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
|
<span>
|
|
<strong>{theme.label}</strong>
|
|
<small>
|
|
{workspaceLoading
|
|
? 'Beschikbaarheid controleren'
|
|
: analysisMode === 'evolution'
|
|
? evolutionAvailable
|
|
? `${temporalGroup.items.length} meetmomenten${temporalRange ? ` · ${temporalRange}` : ''}`
|
|
: dataset
|
|
? 'Alleen huidige toestand'
|
|
: 'Bron nog niet ingeladen'
|
|
: dataset
|
|
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
|
|
: onDemandProduct
|
|
? onDemandProduct.availabilityLabel
|
|
: 'Bron nog niet ingeladen'}
|
|
</small>
|
|
</span>
|
|
<i>
|
|
{workspaceLoading
|
|
? 'Laden'
|
|
: analysisMode === 'evolution'
|
|
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
|
|
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Automatisch' : 'Ontbreekt'}
|
|
</i>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="geo-source-summary">
|
|
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
|
|
<strong>
|
|
{workspaceLoading
|
|
? 'Databronnen worden geladen'
|
|
: analysisOverlayActive
|
|
? mapLayerLabel
|
|
: analysisMode === 'evolution'
|
|
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
|
|
: regionalBathymetryThemeActive
|
|
? 'VHA-dwarsprofielen Vlaanderen'
|
|
: activeThemeDataset
|
|
? getDatasetDisplayName(activeThemeDataset)
|
|
: activeOnDemandMapProduct?.displayName ?? 'Geen databron beschikbaar'}
|
|
</strong>
|
|
<small>
|
|
{workspaceLoading
|
|
? 'Even geduld; ontbrekende bronnen worden pas na de laadcontrole gemeld.'
|
|
: analysisOverlayActive
|
|
? `${mapLayerSourceLabel} · AI-resultaat, controle vereist`
|
|
: analysisMode === 'evolution'
|
|
? activeTemporalSeries.length >= 2
|
|
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}`
|
|
: 'Voor dit thema is nog geen tweede officieel meetmoment beschikbaar.'
|
|
: regionalBathymetryThemeActive
|
|
? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd`
|
|
: activeThemeDataset
|
|
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}${onDemandProductMap.get(activeTheme.id) ? ' · ontbrekende lokale dekking wordt automatisch aangevuld' : ''}`
|
|
: activeOnDemandMapProduct
|
|
? `${activeOnDemandMapProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen`
|
|
: activeTheme.description}
|
|
</small>
|
|
</div>
|
|
{officialMapProductsLoading && selectedProjectId ? (
|
|
<p className="geo-data-notice">Beschikbare officiële regionale kaartbronnen worden gecontroleerd…</p>
|
|
) : null}
|
|
{officialMapProductsError && selectedProjectId ? (
|
|
<p className="error">{officialMapProductsError}</p>
|
|
) : null}
|
|
|
|
{analysisMode === 'current' && activeTheme.id === 'elevation' && officialMapProducts.dhmv.length > 0 ? (
|
|
<label className="geo-scope-select">
|
|
Hoogtemodel
|
|
<select
|
|
aria-label="Hoogtemodel"
|
|
value={selectedDhmvProductKey}
|
|
onChange={(event) => {
|
|
const productKey = event.target.value as 'dtm_1m' | 'dsm_1m'
|
|
setSelectedDhmvProductKey(productKey)
|
|
clearThemeInsights()
|
|
const dataset = availableMapDatasets.find(
|
|
(item) =>
|
|
item.source_name === 'digitaal_vlaanderen_dhmv'
|
|
&& datasetProductKey(item) === productKey
|
|
&& datasetCoversSelectedArea(
|
|
item,
|
|
selectedMapAreaId,
|
|
selectedMapArea?.name,
|
|
regionalScopeSelected,
|
|
),
|
|
)
|
|
if (dataset) {
|
|
onOpenDatasetInMap(dataset)
|
|
}
|
|
}}
|
|
>
|
|
{officialMapProducts.dhmv.map((product) => (
|
|
<option key={product.key} value={product.key}>{product.display_name}</option>
|
|
))}
|
|
</select>
|
|
<small>DTM meet het maaiveld; DSM bevat ook gebouwen en vegetatie.</small>
|
|
</label>
|
|
) : null}
|
|
|
|
{analysisMode === 'current' && activeTheme.id === 'flood_hazard' && officialMapProducts.floodHazard.length > 0 ? (
|
|
<label className="geo-scope-select">
|
|
Overstromingsscenario
|
|
<select
|
|
aria-label="Overstromingsscenario"
|
|
value={selectedFloodHazardProductKey}
|
|
onChange={(event) => {
|
|
const productKey = event.target.value
|
|
const dataset = floodHazardDatasets.find((item) => datasetProductKey(item) === productKey)
|
|
setSelectedFloodHazardProductKey(productKey)
|
|
setSelectedFloodHazardDatasetId(dataset?.id ?? '')
|
|
clearThemeInsights()
|
|
if (dataset) {
|
|
onOpenDatasetInMap(dataset)
|
|
}
|
|
}}
|
|
>
|
|
{officialMapProducts.floodHazard.map((product) => (
|
|
<option key={product.key} value={product.key}>{product.display_name}</option>
|
|
))}
|
|
</select>
|
|
<small>Gemodelleerde maximale waterdiepte voor de gekozen kans en klimaatprojectie; geen actuele waterstand.</small>
|
|
</label>
|
|
) : analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? (
|
|
<label className="geo-scope-select">
|
|
Overstromingsscenario
|
|
<select
|
|
aria-label="Overstromingsscenario"
|
|
value={activeThemeDataset?.id ?? ''}
|
|
onChange={(event) => {
|
|
const dataset = floodHazardDatasets.find((item) => item.id === event.target.value)
|
|
setSelectedFloodHazardDatasetId(event.target.value)
|
|
clearThemeInsights()
|
|
if (dataset) {
|
|
onOpenDatasetInMap(dataset)
|
|
}
|
|
}}
|
|
>
|
|
{floodHazardDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>{floodScenarioLabel(dataset)}</option>
|
|
))}
|
|
</select>
|
|
<small>Elke meting blijft gekoppeld aan deze kans en klimaatprojectie.</small>
|
|
</label>
|
|
) : null}
|
|
|
|
{analysisMode === 'evolution' ? (
|
|
<>
|
|
<div className="geo-time-controls" aria-label="Meetmomenten vergelijken">
|
|
{activeTemporalSeriesGroups.length > 1 ? (
|
|
<label className="geo-series-control">
|
|
Reeks
|
|
<select
|
|
value={activeTemporalSeriesGroup?.key ?? ''}
|
|
onChange={(event) => {
|
|
setSelectedTemporalSeriesKey(event.target.value)
|
|
clearTemporalComparison()
|
|
}}
|
|
>
|
|
{activeTemporalSeriesGroups.map((group) => (
|
|
<option key={group.key} value={group.key}>{group.label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
<label>
|
|
Van
|
|
<select
|
|
value={earlierDatasetId}
|
|
onChange={(event) => {
|
|
const nextEarlierId = event.target.value
|
|
const nextEarlier = activeTemporalSeries.find((dataset) => dataset.id === nextEarlierId)
|
|
setEarlierDatasetId(nextEarlierId)
|
|
if (
|
|
nextEarlier
|
|
&& new Date(selectedLaterSnapshot?.observed_at ?? 0).getTime()
|
|
<= new Date(nextEarlier.observed_at ?? 0).getTime()
|
|
) {
|
|
setLaterDatasetId(activeTemporalSeries[activeTemporalSeries.length - 1]?.id ?? '')
|
|
}
|
|
clearTemporalComparison()
|
|
}}
|
|
disabled={earlierTemporalOptions.length === 0}
|
|
>
|
|
{earlierTemporalOptions.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Naar
|
|
<select value={laterDatasetId} onChange={(event) => { setLaterDatasetId(event.target.value); clearTemporalComparison() }} disabled={laterTemporalOptions.length === 0}>
|
|
{laterTemporalOptions.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="primary-action"
|
|
type="button"
|
|
disabled={!mapSelectionBbox || !temporalSelectionValid || temporalComparisonLoading}
|
|
onClick={runTemporalComparison}
|
|
>
|
|
{temporalComparisonLoading ? 'Vergelijken…' : 'Vergelijk periode'}
|
|
</button>
|
|
</div>
|
|
{activeSeriesIsDailyGrb ? (
|
|
<p className="geo-data-notice">
|
|
Dagelijkse GRB-edities tonen wijzigingen in de officiële registratie. Ze bewijzen niet dat een fysieke verandering exact tussen deze twee kalenderdagen plaatsvond.
|
|
</p>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
|
|
<label className="geo-scope-select">
|
|
Snel naar een gemeente (optioneel)
|
|
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
|
|
{areas.map((area) => (
|
|
<option key={area.id} value={area.id}>{area.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</aside>
|
|
|
|
<div className="geo-map-stage">
|
|
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
|
|
<div className="geo-panel-heading geo-map-step">
|
|
<span>2</span>
|
|
<div>
|
|
<h3>Selecteer een gebied</h3>
|
|
<p>
|
|
{bboxSelectionMode
|
|
? 'Sleep nu een rechthoek op de kaart.'
|
|
: !activeThemeAvailable
|
|
? 'Teken een rechthoek om de databeschikbaarheid voor dit gebied te controleren.'
|
|
: regionalRasterThemeActive
|
|
? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.'
|
|
: onDemandThemeActive
|
|
? 'Teken een rechthoek; officiële regionale kaartbronnen worden begrensd opgehaald, bewaard en hergebruikt.'
|
|
: regionalBathymetryThemeActive
|
|
? 'Teken een rechthoek of analyseer Vlaanderen; alleen overlappende VHA-partities worden samengevoegd.'
|
|
: 'Sleep een rechthoek of analyseer het volledige werkgebied.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="geo-map-actions">
|
|
<button
|
|
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
|
|
disabled={(!activeThemeAvailable && !coverageSelectionAvailable) || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || mapSelectionLoading || themeResultsLoading}
|
|
type="button"
|
|
onClick={startBboxSelection}
|
|
>
|
|
<BoxSelect aria-hidden="true" />
|
|
{bboxSelectionMode ? 'Teken op de kaart…' : 'Teken rechthoek'}
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={(!activeThemeAvailable && !coverageSelectionAvailable) || regionalRasterThemeActive || regionalOnDemandThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
|
|
type="button"
|
|
title={
|
|
regionalRasterThemeActive || regionalOnDemandThemeActive
|
|
? 'Teken een begrensde rechthoek voor deze regionale analyse.'
|
|
: undefined
|
|
}
|
|
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
|
|
>
|
|
<MapPinned aria-hidden="true" />
|
|
{regionalRasterThemeActive || regionalOnDemandThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'}
|
|
</button>
|
|
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
|
|
<Trash2 aria-hidden="true" />
|
|
Wis selectie
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
|
|
<GeoMap
|
|
data={explorerMapFeatureCollection}
|
|
dataFillColor={activeThemeMapStyle.fill}
|
|
dataLineColor={activeThemeMapStyle.line}
|
|
areaData={areaFeatureCollection}
|
|
selectedFeature={selectedFeature}
|
|
selectionData={explorerSelectionFeatureCollection}
|
|
imageOverlays={activeImageOverlays}
|
|
selectionBbox={mapSelectionBbox}
|
|
bboxSelectionMode={bboxSelectionMode}
|
|
visible={mapLayerVisible}
|
|
opacity={mapLayerOpacity}
|
|
areaVisible={areaLayerVisible}
|
|
areaOpacity={areaLayerOpacity}
|
|
fitDataOnChange={fitMapDataOnChange}
|
|
onFeatureSelect={onSelectMapFeature}
|
|
onMapCoordinateSelect={handleMapCoordinateSelect}
|
|
onMapBboxPreview={handleMapBboxPreview}
|
|
onMapBboxSelect={handleMapBboxSelect}
|
|
onViewportChange={onMapViewportChange}
|
|
/>
|
|
<div className="geo-map-legend" aria-label="Kaartlegende">
|
|
<span><i className="geo-legend-area" /> Werkgebied</span>
|
|
{thematicRasterImageOverlays.length > 0 ? (
|
|
<span className="geo-legend-thematic">
|
|
<i className={`geo-legend-ramp geo-legend-ramp-${activeTheme.id}`} />
|
|
<small>{thematicLegendMin} → {thematicLegendMax}</small>
|
|
</span>
|
|
) : activeImageOverlays.length > 0 ? (
|
|
<span>
|
|
<i className="geo-legend-imagery" /> {activeImageOverlays[0].label}
|
|
{activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''}
|
|
</span>
|
|
) : null}
|
|
{analysisOverlayActive ? (
|
|
<>
|
|
<span><i className="geo-legend-layer geo-legend-layer-buildings" /> AI-kandidaten</span>
|
|
<span><i className="geo-legend-selection" /> Selectie</span>
|
|
</>
|
|
) : analysisMode === 'evolution' && temporalComparison?.object_changes.available ? (
|
|
<>
|
|
<span><i className="geo-legend-added" /> Nieuw</span>
|
|
<span><i className="geo-legend-removed" /> Verdwenen</span>
|
|
<span><i className="geo-legend-modified" /> Gewijzigd</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span><i className={`geo-legend-layer geo-legend-layer-${activeTheme.id}`} /> {activeTheme.shortLabel}</span>
|
|
<span><i className="geo-legend-selection" /> Selectie</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{bboxSelectionMode ? (
|
|
<div className="geo-draw-instruction" role="status">
|
|
<strong>Rechthoek tekenen</strong>
|
|
<span>Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los.</span>
|
|
</div>
|
|
) : null}
|
|
{viewportVectorEnabled && viewportVectorStatus ? (
|
|
<div className={`geo-viewport-status geo-viewport-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
|
|
{viewportVectorStatus}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<aside
|
|
id="geo-explorer-results"
|
|
className="geo-results-panel"
|
|
aria-label="Gebiedsanalyse"
|
|
aria-live="polite"
|
|
>
|
|
<div className="geo-panel-heading">
|
|
<span>3</span>
|
|
<div>
|
|
<h3>Inzichten</h3>
|
|
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
|
|
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
|
|
<div>
|
|
<span>Beeldanalyse</span>
|
|
<strong>{selectedOrthophotoProduct?.supports_detection ? 'Gebouwen herkennen op luchtbeeld' : 'Historisch luchtbeeld bekijken'}</strong>
|
|
<small>
|
|
{selectedOrthophotoProduct?.supports_detection
|
|
? 'Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.'
|
|
: 'Officieel historisch mozaïek. Geen vergelijking met de actuele GRB-toestand.'}
|
|
</small>
|
|
</div>
|
|
<label className="geo-orthophoto-product">
|
|
<span>Luchtbeeld</span>
|
|
<select
|
|
value={selectedOrthophotoProductKey}
|
|
onChange={(event) => onSelectOrthophotoProduct(event.target.value)}
|
|
disabled={orthophotoAnalysisRunning}
|
|
>
|
|
{orthophotoProducts.map((product) => (
|
|
<option key={product.key} value={product.key}>{product.display_name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="primary-action"
|
|
disabled={orthophotoAnalysisRunning}
|
|
type="button"
|
|
onClick={() => void onRunOrthophotoAnalysis(mapSelectionBbox)}
|
|
>
|
|
{orthophotoAnalysisStage === 'acquiring'
|
|
? 'Luchtbeeld ophalen...'
|
|
: orthophotoAnalysisStage === 'detecting'
|
|
? 'Gebouwen herkennen...'
|
|
: orthophotoAnalysisStage === 'validating'
|
|
? 'Controleren...'
|
|
: orthophotoAnalysisStage === 'complete'
|
|
? selectedOrthophotoProduct?.supports_detection ? 'Opnieuw analyseren' : 'Opnieuw tonen'
|
|
: selectedOrthophotoProduct?.supports_detection ? 'Herken gebouwen' : 'Toon luchtbeeld'}
|
|
</button>
|
|
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
|
|
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
|
|
{orthophotoAnalysisQuality ? (
|
|
<>
|
|
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
|
|
<div><span>Kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
|
|
<div><span>Strikte matches</span><strong>{orthophotoAnalysisQuality.matches.toLocaleString('nl-BE')}</strong></div>
|
|
<div><span>Precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
|
|
<div><span>Herkenningsgraad</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
|
|
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
|
|
<div><span>Fout / gemist</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')} / {orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
|
|
</div>
|
|
{orthophotoAnalysisQuality.box_to_footprint_diagnostics ? (
|
|
<p className="geo-image-quality-context">
|
|
Rechthoekcontrole: {orthophotoAnalysisQuality.box_to_footprint_diagnostics.envelope_matches.toLocaleString('nl-BE')} matches,
|
|
waarvan {orthophotoAnalysisQuality.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count.toLocaleString('nl-BE')} mogelijke vormverschillen. De kerncijfers hierboven gebruiken strikte GRB-footprints.
|
|
</p>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!mapSelectionBbox ? (
|
|
<div className="geo-results-empty">
|
|
<strong>Nog geen gebied geselecteerd</strong>
|
|
<p>Teken een rechthoek op de kaart. De analyse start automatisch zodra je loslaat.</p>
|
|
</div>
|
|
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
|
|
<div className="geo-results-loading" role="status">
|
|
<span />
|
|
<strong>De gekozen officiële bron wordt begrensd geladen en geanalyseerd…</strong>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{analysisMode === 'evolution' ? (
|
|
temporalComparison ? (
|
|
<>
|
|
<div className="geo-primary-metrics geo-temporal-metrics">
|
|
<div>
|
|
<span>{formatObservationDate(temporalComparison.earlier.observed_at)}</span>
|
|
<strong>{formatTemporalMetric(temporalComparison.metric.earlier_value, temporalComparison.metric.unit)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>{formatObservationDate(temporalComparison.later.observed_at)}</span>
|
|
<strong>{formatTemporalMetric(temporalComparison.metric.later_value, temporalComparison.metric.unit)}</strong>
|
|
</div>
|
|
<div className={temporalComparison.metric.absolute_change >= 0 ? 'positive' : 'negative'}>
|
|
<span>Verschil</span>
|
|
<strong>
|
|
{temporalComparison.metric.absolute_change >= 0 ? '+' : ''}
|
|
{formatTemporalMetric(temporalComparison.metric.absolute_change, temporalComparison.metric.unit)}
|
|
</strong>
|
|
<small>
|
|
{temporalComparison.metric.percent_change == null
|
|
? 'geen percentage bij nulwaarde'
|
|
: `${temporalComparison.metric.percent_change >= 0 ? '+' : ''}${temporalComparison.metric.percent_change.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<div className="geo-temporal-summary">
|
|
<div>
|
|
<span>Gebied</span>
|
|
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Meting</span>
|
|
<strong>{temporalComparison.metric.label}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Methode</span>
|
|
<strong>{temporalComparison.metric.is_estimate ? 'Ruimtelijke schatting' : 'Exact'}</strong>
|
|
</div>
|
|
</div>
|
|
<TemporalTrendChart
|
|
timeline={temporalComparison.timeline ?? []}
|
|
metricKey={temporalComparison.metric.metric_key}
|
|
/>
|
|
{(temporalComparison.metrics ?? []).filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key).length > 0 ? (
|
|
<div className="geo-supporting-metrics" aria-label="Aanvullende historische metingen">
|
|
{(temporalComparison.metrics ?? [])
|
|
.filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key)
|
|
.map((metric) => (
|
|
<div key={metric.metric_key}>
|
|
<span>{metric.label}</span>
|
|
<strong>
|
|
{metric.absolute_change >= 0 ? '+' : ''}
|
|
{formatTemporalMetric(metric.absolute_change, metric.unit)}
|
|
</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{temporalComparison.object_changes.available ? (
|
|
<div className="geo-change-counts" aria-label="Objectwijzigingen">
|
|
<span><strong>{temporalComparison.object_changes.added_count ?? 0}</strong> nieuw</span>
|
|
<span><strong>{temporalComparison.object_changes.removed_count ?? 0}</strong> verdwenen</span>
|
|
<span><strong>{temporalComparison.object_changes.modified_count ?? 0}</strong> gewijzigd</span>
|
|
</div>
|
|
) : null}
|
|
{temporalComparison.warnings.map((warning) => (
|
|
<p className="geo-data-notice" key={warning}>{warning}</p>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="geo-results-empty">
|
|
<strong>Klaar om te vergelijken</strong>
|
|
<p>Kies twee meetmomenten en gebruik “Vergelijk periode”. Bij een nieuwe rechthoek wordt de vergelijking automatisch herhaald.</p>
|
|
</div>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="geo-primary-metrics">
|
|
<div>
|
|
<span>Oppervlakte selectie</span>
|
|
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>{activeMetricLabel}</span>
|
|
<strong>{activeSelectionResult ? resultMetricLabel(activeSelectionResult) : 'Geen resultaat'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>{activeSecondaryLabel}</span>
|
|
<strong>{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
{activeSupportingMetrics.length > 0 ? (
|
|
<div className="geo-supporting-metrics" aria-label="Aanvullende gebiedsmetingen">
|
|
{activeSupportingMetrics.map((metric) => (
|
|
<div key={metric.metric_key}>
|
|
<span>{metric.metric_label}</span>
|
|
<strong>{selectionMetricLabel(metric)}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="geo-theme-results">
|
|
<div className="geo-results-title-row">
|
|
<h4>Gekozen thema</h4>
|
|
<span>{activeSelectionResult ? 'Uitgelezen' : 'Geen meting'}</span>
|
|
</div>
|
|
{[activeTheme].flatMap((theme) => {
|
|
const dataset = themeDatasetMap[theme.id]
|
|
const items = themeResults.filter((result) => result.theme.id === theme.id)
|
|
const rows = items.length > 0 ? items : [null]
|
|
return rows.map((item, index) => {
|
|
const resultDataset = item?.dataset ?? (activeThemeSupportsCurrentSelection ? dataset : undefined)
|
|
const unavailableAtScale = !activeThemeSupportsCurrentSelection
|
|
return (
|
|
<div className="geo-theme-result-row" key={`${theme.id}:${resultDataset?.id ?? index}`}>
|
|
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
|
<span>
|
|
<strong>{theme.label}</strong>
|
|
<small>
|
|
{resultDataset
|
|
? getDatasetSourceDisplayName(resultDataset)
|
|
: unavailableAtScale
|
|
? 'Niet geschikt voor deze selectieschaal of zone'
|
|
: 'Bronanalyse niet voltooid'}
|
|
</small>
|
|
</span>
|
|
<span className="geo-theme-result-value">
|
|
<b>{item ? resultMetricLabel(item.result) : unavailableAtScale ? 'Kies kleiner gebied' : 'Geen meting'}</b>
|
|
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
|
|
</span>
|
|
</div>
|
|
)
|
|
})
|
|
})}
|
|
{selectionScaleNotice ? (
|
|
<p className="geo-data-notice">{selectionScaleNotice}</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
|
|
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
|
|
) : null}
|
|
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
|
|
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
|
|
) : null}
|
|
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
|
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
|
|
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
|
|
|
|
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
|
|
<details className="geo-result-details">
|
|
<summary>Kenmerken van de gevonden objecten</summary>
|
|
<dl>
|
|
{selectedResultProperties.map(({ key, values }) => (
|
|
<div key={key}>
|
|
<dt>{readablePropertyName(key)}</dt>
|
|
<dd>{values.join(', ')}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</details>
|
|
) : null}
|
|
|
|
{analysisMode === 'current' && selectedMapFeature ? (
|
|
<div className="geo-selected-feature">
|
|
<span>Geselecteerd object</span>
|
|
<strong>{String(selectedMapFeature.properties?.['name'] ?? selectedMapFeature.properties?.['source_feature_id'] ?? selectedMapFeature.id ?? 'Object')}</strong>
|
|
<small>Klik elders op de kaart om een ander object uit te lezen.</small>
|
|
</div>
|
|
) : null}
|
|
|
|
{analysisMode === 'current' ? (
|
|
<div className="geo-result-actions">
|
|
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeResult}>
|
|
{activeResultDataset?.dataset_type === 'raster' ? 'Download analyse' : 'Download GeoJSON'}
|
|
</button>
|
|
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeResult}>Kopieer gegevens</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{analysisMode === 'evolution' && temporalComparison ? (
|
|
<div className="geo-result-actions">
|
|
<button className="secondary-action" type="button" onClick={downloadTemporalComparison}>Download vergelijking</button>
|
|
<button className="secondary-action" type="button" onClick={copyTemporalComparison}>Kopieer vergelijking</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeSelectionResult || temporalComparison ? (
|
|
<div className="geo-result-next-actions" aria-label="Volgende stap">
|
|
<span>
|
|
<strong>Analyse klaar</strong>
|
|
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
|
|
</span>
|
|
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
|
|
<button
|
|
className="secondary-action"
|
|
type="button"
|
|
disabled={selectionExporting}
|
|
onClick={() => void persistActiveResultAndOpenDownloads()}
|
|
>
|
|
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
{mapAnalysisDurationMs !== null ? (
|
|
<p
|
|
className={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
|
|
? 'geo-performance-status geo-performance-status-warning'
|
|
: 'geo-performance-status'}
|
|
role={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS) ? 'alert' : 'status'}
|
|
>
|
|
Selectie geanalyseerd in {formatPerformanceDuration(mapAnalysisDurationMs)}.
|
|
{exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
|
|
? ' Dit overschrijdt het releasebudget van 15 seconden.'
|
|
: ''}
|
|
</p>
|
|
) : null}
|
|
</aside>
|
|
</div>
|
|
|
|
<footer className="geo-explorer-footer">
|
|
<span><strong>Werkgebied:</strong> {selectedMapArea?.name ?? 'Geen werkgebied geselecteerd'}</span>
|
|
<span>
|
|
<strong>Bron:</strong>{' '}
|
|
{analysisOverlayActive
|
|
? `${mapLayerLabel} · ${mapLayerSourceLabel}`
|
|
: analysisMode === 'evolution'
|
|
? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks'
|
|
: regionalBathymetryThemeActive
|
|
? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities`
|
|
: activeThemeDataset
|
|
? getDatasetDisplayName(activeThemeDataset)
|
|
: activeOnDemandMapProduct?.displayName
|
|
?? 'niet beschikbaar'}
|
|
</span>
|
|
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
|
|
</footer>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<section id="geo-advanced-workbench" className="map-workspace-shell" data-testid="map-workspace">
|
|
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
|
|
Terug naar gebiedsverkenner
|
|
</button>
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Ruimtelijke controle</p>
|
|
<h2>Kaartwerkruimte</h2>
|
|
</div>
|
|
<span className="count-pill">
|
|
{mapFeatureCollection ? `${mapFeatureCount} objecten` : viewportVectorEnabled ? 'zoom in om te laden' : 'geen laag'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="map-control-surface" aria-label="Bediening van de kaartwerkruimte">
|
|
{usesDefaultOsmBasemap ? (
|
|
<div className="basemap-policy-notice" aria-label="Gebruik van de kaartondergrond">
|
|
<strong>Publieke kaartondergrond</strong>
|
|
<span>De publieke OpenStreetMap-ondergrond is actief. Configureer voor intensief gebruik een eigen kaartstijl.</span>
|
|
</div>
|
|
) : null}
|
|
<div className="map-toolbar">
|
|
<div className="map-layer-mode" aria-label="Soort kaartinhoud">
|
|
<span>Kaartinhoud</span>
|
|
<div role="group" aria-label="Bron van de kaartinhoud">
|
|
<button
|
|
type="button"
|
|
aria-pressed={mapContentMode === 'dataset'}
|
|
onClick={() => onSetMapContentMode('dataset')}
|
|
>
|
|
Database
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-pressed={mapContentMode === 'analysis'}
|
|
disabled={!analysisLayerAvailable}
|
|
onClick={() => onSetMapContentMode('analysis')}
|
|
>
|
|
Analyseresultaat
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<label>
|
|
Databaselaag
|
|
<select
|
|
value={selectedMapDatasetId}
|
|
onChange={(event) => openSelectedDatabaseLayer(event.target.value)}
|
|
disabled={availableMapDatasets.length === 0}
|
|
data-testid="map-database-layer-select"
|
|
>
|
|
<option value="">Kies een bewaarde vectorlaag</option>
|
|
{availableMapDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Werkgebied
|
|
<select
|
|
aria-label="Werkgebied"
|
|
value={selectedMapAreaId}
|
|
onChange={(event) => handleSelectMapArea(event.target.value)}
|
|
disabled={areas.length === 0}
|
|
data-testid="map-area-select"
|
|
>
|
|
<option value="">Geen werkgebied</option>
|
|
{areas.map((area) => (
|
|
<option key={area.id} value={area.id}>
|
|
{area.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="layer-control-card">
|
|
<label className="checkbox-row">
|
|
<input
|
|
checked={areaLayerVisible}
|
|
disabled={!areaFeatureCollection}
|
|
type="checkbox"
|
|
onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
|
|
data-testid="map-area-visible"
|
|
/>
|
|
Werkgebied
|
|
</label>
|
|
<input
|
|
aria-label="Dekking van het werkgebied"
|
|
disabled={!areaFeatureCollection}
|
|
max="0.7"
|
|
min="0.05"
|
|
step="0.05"
|
|
type="range"
|
|
value={areaLayerOpacity}
|
|
onChange={(event) => onSetAreaLayerOpacity(Number(event.target.value))}
|
|
data-testid="map-area-opacity"
|
|
/>
|
|
</div>
|
|
<div className="layer-control-card">
|
|
<label className="checkbox-row">
|
|
<input
|
|
checked={mapLayerVisible}
|
|
disabled={!mapFeatureCollection && !viewportVectorEnabled}
|
|
type="checkbox"
|
|
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
|
|
data-testid="map-layer-visible"
|
|
/>
|
|
Actieve laag
|
|
</label>
|
|
<input
|
|
aria-label="Dekking van de kaartlaag"
|
|
disabled={!mapFeatureCollection && !viewportVectorEnabled}
|
|
max="1"
|
|
min="0.05"
|
|
step="0.05"
|
|
type="range"
|
|
value={mapLayerOpacity}
|
|
onChange={(event) => onSetMapLayerOpacity(Number(event.target.value))}
|
|
data-testid="map-layer-opacity"
|
|
/>
|
|
</div>
|
|
<div className="map-status">
|
|
<strong>{mapLayerLabel}</strong>
|
|
<span>{selectedMapDataset ? `Databaselaag: ${selectedMapDataset.name}` : 'Geen databaselaag gekozen'}</span>
|
|
<span>{areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Geen werkgebied geladen'}</span>
|
|
<span>
|
|
{mapFeatureCollection
|
|
? `${mapFeatureCount} objecten geladen`
|
|
: viewportVectorEnabled
|
|
? 'Databaselaag gekozen; zichtbare objecten laden volgens de kaartuitsnede'
|
|
: 'Geen vector- of resultaatlaag geladen'}
|
|
</span>
|
|
{viewportVectorEnabled && viewportVectorStatus ? (
|
|
<span className={`viewport-vector-status viewport-vector-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
|
|
{viewportVectorStatus}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="map-frame-surface">
|
|
<GeoMap
|
|
data={mapFeatureCollection}
|
|
areaData={areaFeatureCollection}
|
|
selectedFeature={selectedFeature}
|
|
selectionData={mapSelectionResult?.geojson ?? null}
|
|
qaEvidenceData={qualityEvidenceGeoJson}
|
|
selectionBbox={mapSelectionBbox}
|
|
bboxSelectionMode={bboxSelectionMode}
|
|
visible={mapLayerVisible}
|
|
opacity={mapLayerOpacity}
|
|
areaVisible={areaLayerVisible}
|
|
areaOpacity={areaLayerOpacity}
|
|
fitDataOnChange={fitMapDataOnChange}
|
|
onFeatureSelect={onSelectMapFeature}
|
|
onMapCoordinateSelect={handleMapCoordinateSelect}
|
|
onViewportChange={onMapViewportChange}
|
|
/>
|
|
</div>
|
|
|
|
<details className="map-layer-details">
|
|
<summary>
|
|
<span>Details van de kaartlagen</span>
|
|
<strong>
|
|
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Kaartuitsnedelaag gekozen' : 'Geen actieve laag'}
|
|
</strong>
|
|
</summary>
|
|
<div className="map-context-summary" aria-label="Status van de kaartlagen">
|
|
<div>
|
|
<span>Werkgebied</span>
|
|
<strong>{selectedMapArea?.name ?? 'Geen gebied geselecteerd'}</strong>
|
|
<small>{areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Werkgebiedlaag uitgeschakeld'}</small>
|
|
</div>
|
|
<div>
|
|
<span>Actieve kaartlaag</span>
|
|
<strong>{mapLayerLabel}</strong>
|
|
<small>{mapLayerSourceLabel}</small>
|
|
</div>
|
|
<div>
|
|
<span>Status kaartobjecten</span>
|
|
<strong>
|
|
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Wachten op detail van de kaartuitsnede' : 'Geen laag getekend'}
|
|
</strong>
|
|
<small>{mapLayerProvenance}</small>
|
|
</div>
|
|
<div>
|
|
<span>Kaartbewijs kwaliteitscontrole</span>
|
|
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewijsobjecten` : 'Geen bewijslaag'}</strong>
|
|
<small>{qualityEvidenceLoading ? 'Bewaard bewijs laden' : 'Overeenkomsten, onterecht gevonden en gemiste objecten'}</small>
|
|
</div>
|
|
</div>
|
|
<div className="layer-provenance-rail" aria-label="Herkomst van de actieve kaartlaag">
|
|
<div>
|
|
<span>Bron van de kaartlaag</span>
|
|
<strong>{mapLayerSourceLabel}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Herkomst</span>
|
|
<strong>{mapLayerProvenance}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Weergavestatus</span>
|
|
<strong>
|
|
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Laden volgens kaartuitsnede actief' : 'Geen actieve vector- of resultaatlaag'}
|
|
</strong>
|
|
</div>
|
|
<div>
|
|
<span>Kaartbewijs</span>
|
|
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} getekend` : 'uitgeschakeld'}</strong>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
|
|
<div className="qa-evidence-map-status" aria-label="Status van het kaartbewijs">
|
|
<div>
|
|
<span>Kaartbewijs kwaliteitscontrole</span>
|
|
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewaarde objecten` : 'Niet geladen'}</strong>
|
|
{qualityEvidenceError ? <p className="error">{qualityEvidenceError}</p> : null}
|
|
{qualityEvidenceWarnings.length > 0 ? (
|
|
<p className="muted">
|
|
{qualityEvidenceWarnings.length} {qualityEvidenceWarnings.length === 1 ? 'bewijsverwijzing kon' : 'bewijsverwijzingen konden'} niet worden teruggevonden.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="qa-evidence-legend" aria-label="Legenda van het kaartbewijs">
|
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-candidate" /> Overeenkomst resultaat</span>
|
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-reference" /> Overeenkomst referentie</span>
|
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-positive" /> Onterecht gevonden</span>
|
|
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-negative" /> Gemist</span>
|
|
</div>
|
|
{onClearQualityEvidence ? (
|
|
<button className="secondary-action" type="button" onClick={onClearQualityEvidence}>
|
|
Kaartbewijs wissen
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!mapFeatureCollection && !viewportVectorEnabled ? (
|
|
<div className="empty-state map-empty-state">
|
|
<strong>Geen actieve vector- of resultaatlaag</strong>
|
|
<p>Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen.</p>
|
|
{availableMapDatasets.length > 0 ? (
|
|
<>
|
|
<p className="eyebrow">Open een beschikbare vectorlaag</p>
|
|
<div className="map-empty-action-grid">
|
|
{availableMapDatasets.map((dataset) => (
|
|
<button className="secondary-action" key={dataset.id} type="button" onClick={() => onOpenDatasetInMap(dataset)}>
|
|
<span>{dataset.name}</span>
|
|
<strong>Open op kaart</strong>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="muted">Nog geen gebruiksklare vectorlagen beschikbaar. Voeg eerst een vectorbestand toe.</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{mapSelectionBbox ? (
|
|
<section
|
|
className="coverage-resolution-surface"
|
|
aria-label="Datadekking van de kaartselectie"
|
|
aria-busy={coverageLoading}
|
|
>
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Dekking van deze selectie</p>
|
|
<h3>{activeTheme.label}</h3>
|
|
</div>
|
|
{coverageLoading ? <span className="count-pill">controleren</span> : null}
|
|
</div>
|
|
{coverageError ? <p className="error" role="alert">{coverageError}</p> : null}
|
|
{coverageDurationMs !== null ? (
|
|
<p
|
|
className={coverageBudgetExceeded
|
|
? 'geo-performance-status geo-performance-status-warning'
|
|
: 'geo-performance-status'}
|
|
role={coverageBudgetExceeded ? 'alert' : 'status'}
|
|
>
|
|
Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}.
|
|
{coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''}
|
|
</p>
|
|
) : null}
|
|
{coverage ? (
|
|
<>
|
|
<div className="coverage-zone-row">
|
|
{coverage.intersected_zones.map((zone) => (
|
|
<span key={zone}>{coverageZoneLabel(zone)}</span>
|
|
))}
|
|
{coverage.outside_supported_scope ? <span className="coverage-zone-warning">deels buiten scope</span> : null}
|
|
</div>
|
|
<div className="coverage-active-theme-grid">
|
|
{activeCoverageItems.map((item) => (
|
|
<div className={`coverage-status-item coverage-status-${item.status}`} key={`${item.zone}:${item.theme}`}>
|
|
<span>{coverageZoneLabel(item.zone)}</span>
|
|
<strong>{coverageStatusLabel(item.status)}</strong>
|
|
<small>{item.source_names.join(', ') || 'Geen broncontract'}</small>
|
|
</div>
|
|
))}
|
|
{activeCoverageItems.length === 0 ? (
|
|
<p className="muted">Deze selectie raakt geen bewaarde Belgische land- of zeezone.</p>
|
|
) : null}
|
|
</div>
|
|
<div className="coverage-summary-row" aria-label="Samenvatting van alle themas">
|
|
<span>{coverageCounts.operational} beschikbaar</span>
|
|
<span>{coverageCounts.partial} gedeeltelijk</span>
|
|
<span>{coverageCounts.not_configured} niet gekoppeld</span>
|
|
<span>{coverageCounts.unsupported} niet ondersteund</span>
|
|
</div>
|
|
{coverage.warnings.map((warning) => <p className="muted" key={warning}>{warning}</p>)}
|
|
</>
|
|
) : !coverageLoading && !coverageError ? (
|
|
<p className="muted">De dekkingsmatrix wordt bepaald zodra de selectie volledig is.</p>
|
|
) : null}
|
|
</section>
|
|
) : null}
|
|
|
|
<div className="map-inspection-surface">
|
|
<div className="gis-test-run-surface" aria-label="Operationele GIS-controle">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Operationele GIS-controle</p>
|
|
<h3>Databaselaag doorzoeken</h3>
|
|
</div>
|
|
<span className="count-pill">{mapSelectionResult ? `${mapSelectionResult.feature_count} resultaten` : 'gereed'}</span>
|
|
</div>
|
|
<p className="muted">
|
|
Kies een bewaarde vectorlaag en doorzoek daarna de objecten in PostGIS binnen het werkgebied of de volledige laag.
|
|
</p>
|
|
<div className="gis-test-run-grid">
|
|
<div>
|
|
<span>Databaselaag</span>
|
|
<strong>{selectedMapDataset?.name ?? 'Kies een laag'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Begrenzing werkgebied</span>
|
|
<strong>{selectedAreaBbox ? 'beschikbaar' : 'ontbreekt'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Begrenzing kaartlaag</span>
|
|
<strong>{activeLayerBbox ? 'beschikbaar' : 'ontbreekt'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Resultaat</span>
|
|
<strong>{mapSelectionResult ? `${mapSelectionResult.feature_count} objecten` : 'nog niet uitgevoerd'}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="feature-extract-actions">
|
|
<button
|
|
className="primary-action"
|
|
disabled={!selectedMapDataset || !mapFeatureCollection || (!selectedAreaBbox && !activeLayerBbox) || mapSelectionLoading}
|
|
type="button"
|
|
onClick={runQuickAoiExtract}
|
|
>
|
|
{mapSelectionLoading ? 'Selectie uitvoeren...' : 'Werkgebied of laag doorzoeken'}
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!selectedAreaBbox}
|
|
type="button"
|
|
onClick={() => setSelectionBbox(selectedAreaBbox)}
|
|
>
|
|
Begrenzing werkgebied gebruiken
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!activeLayerBbox}
|
|
type="button"
|
|
onClick={() => setSelectionBbox(activeLayerBbox)}
|
|
>
|
|
Begrenzing kaartlaag gebruiken
|
|
</button>
|
|
</div>
|
|
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
|
<div className="guided-gis-flow" aria-label="Begeleide operationele GIS-werkstroom">
|
|
<div className="guided-gis-steps">
|
|
<div className={selectedMapDataset ? 'complete' : ''}>
|
|
<span>1</span>
|
|
<strong>Kaartlaag</strong>
|
|
<small>{selectedMapDataset ? selectedMapDataset.name : 'Kies een databaselaag'}</small>
|
|
</div>
|
|
<div className={currentSelectionBbox ? 'complete' : ''}>
|
|
<span>2</span>
|
|
<strong>Begrenzing</strong>
|
|
<small>{currentSelectionBbox ? 'Gebiedsbegrenzing gereed' : 'Gebruik het werkgebied of de laagbegrenzing'}</small>
|
|
</div>
|
|
<div className={mapSelectionResult ? 'complete' : ''}>
|
|
<span>3</span>
|
|
<strong>Selectie</strong>
|
|
<small>{mapSelectionResult ? `${mapSelectionResult.feature_count} bewaarde objecten` : 'Voer de ruimtelijke selectie uit'}</small>
|
|
</div>
|
|
<div className={latestSelectionDatasetName ? 'complete' : ''}>
|
|
<span>4</span>
|
|
<strong>Resultaatlaag</strong>
|
|
<small>{latestSelectionDatasetName ?? 'Bewaar het selectieresultaat'}</small>
|
|
</div>
|
|
<div className={mapSelectionQaResult ? 'complete' : ''}>
|
|
<span>5</span>
|
|
<strong>Kwaliteitscontrole</strong>
|
|
<small>{mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n.v.t.'}` : 'Vergelijk met een referentielaag'}</small>
|
|
</div>
|
|
<div className={latestSelectionExportPath ? 'complete' : ''}>
|
|
<span>6</span>
|
|
<strong>Download</strong>
|
|
<small>{latestSelectionExportPath ? 'GeoJSON-bestand gereed' : 'Bewaar een downloadbestand'}</small>
|
|
</div>
|
|
</div>
|
|
<div className="guided-gis-actions">
|
|
<label className="guided-gis-run-mode">
|
|
Uitvoermodus
|
|
<select
|
|
value={fullWorkflowMode}
|
|
onChange={(event) => setFullWorkflowMode(event.target.value === 'reuse' ? 'reuse' : 'new')}
|
|
disabled={fullWorkflowRunning}
|
|
>
|
|
<option value="new">Nieuwe resultaatlaag en download maken</option>
|
|
<option value="reuse" disabled={!latestSelectionDataset}>
|
|
Laatste resultaatlaag opnieuw controleren
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="primary-action guided-gis-full-run"
|
|
disabled={
|
|
fullWorkflowRunning ||
|
|
(fullWorkflowMode === 'new' && (!selectedMapDataset || !mapFeatureCollection || (!currentSelectionBbox && !selectedAreaBbox && !activeLayerBbox))) ||
|
|
(fullWorkflowMode === 'reuse' && (!latestSelectionDataset || !selectedMapQaReferenceDatasetId))
|
|
}
|
|
type="button"
|
|
onClick={runFullGisWorkflow}
|
|
>
|
|
{fullWorkflowRunning ? 'Volledige werkstroom uitvoeren...' : 'Volledige GIS-werkstroom uitvoeren'}
|
|
</button>
|
|
<div className="guided-gis-batch-status" aria-live="polite">
|
|
<strong>Selecteren, bewaren, controleren en downloaden</strong>
|
|
<span>{fullWorkflowStatus}</span>
|
|
</div>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!mapSelectionResult || !currentSelectionBbox || selectionDatasetSaving}
|
|
type="button"
|
|
onClick={saveAreaSelectionDataset}
|
|
>
|
|
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Resultaatlaag bewaren'}
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!mapSelectionResult || !currentSelectionBbox || selectionExporting}
|
|
type="button"
|
|
onClick={saveAreaSelectionExport}
|
|
>
|
|
{selectionExporting ? 'Download bewaren...' : 'GeoJSON-download bewaren'}
|
|
</button>
|
|
<label>
|
|
Referentielaag
|
|
<select
|
|
value={selectedMapQaReferenceDatasetId}
|
|
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
|
|
disabled={!latestSelectionDatasetName || mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
|
|
>
|
|
<option value="">Kies een referentielaag</option>
|
|
{mapQaReferenceDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="primary-action"
|
|
disabled={!latestSelectionDatasetName || !selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
|
|
type="button"
|
|
onClick={() => onRunMapSelectionQa()}
|
|
>
|
|
{mapSelectionQaRunning ? 'Kwaliteit controleren...' : 'Kwaliteit controleren'}
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!latestMapSelectionQualityCheckId}
|
|
type="button"
|
|
onClick={onOpenMapSelectionQualityEvidence}
|
|
>
|
|
Kaartbewijs openen
|
|
</button>
|
|
</div>
|
|
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
|
|
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
|
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
|
|
{fullWorkflowError ? <p className="error">{fullWorkflowError}</p> : null}
|
|
</div>
|
|
</div>
|
|
<details className="map-advanced-tools">
|
|
<summary>
|
|
<span>Geavanceerde selectie en inspectie</span>
|
|
<strong>Coördinaten, objectextractie en ruwe eigenschappen</strong>
|
|
</summary>
|
|
<div className="map-advanced-tools-body">
|
|
<div className="bbox-select-surface" aria-label="Gebiedsselectie en extractie">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Bewaarde vectorobjecten</p>
|
|
<h3>Gebiedsselectie</h3>
|
|
</div>
|
|
<span className="count-pill">
|
|
{mapSelectionResult ? `${mapSelectionResult.feature_count} geselecteerd` : bboxSelectionMode ? 'selecteren' : 'gereed'}
|
|
</span>
|
|
</div>
|
|
<div className="bbox-select-status">
|
|
<span>{bboxSelectionMode ? (firstSelectionCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'}</span>
|
|
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
|
|
</div>
|
|
<div className="bbox-select-grid" aria-label="Coördinaten van de gebiedsselectie">
|
|
<label>
|
|
Min lon
|
|
<input
|
|
inputMode="decimal"
|
|
value={bboxInput.min_x}
|
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_x: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Min lat
|
|
<input
|
|
inputMode="decimal"
|
|
value={bboxInput.min_y}
|
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_y: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Max lon
|
|
<input
|
|
inputMode="decimal"
|
|
value={bboxInput.max_x}
|
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_x: event.target.value }))}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Max lat
|
|
<input
|
|
inputMode="decimal"
|
|
value={bboxInput.max_y}
|
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_y: event.target.value }))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="bbox-select-actions">
|
|
<button className="primary-action" type="button" onClick={startBboxSelection}>
|
|
Rechthoek op kaart tekenen
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!selectedFeatureBbox}
|
|
type="button"
|
|
onClick={() => setSelectionBbox(selectedFeatureBbox)}
|
|
>
|
|
Begrenzing van object gebruiken
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!selectedAreaBbox}
|
|
type="button"
|
|
onClick={() => setSelectionBbox(selectedAreaBbox)}
|
|
>
|
|
Begrenzing van werkgebied gebruiken
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!activeLayerBbox}
|
|
type="button"
|
|
onClick={() => setSelectionBbox(activeLayerBbox)}
|
|
>
|
|
Begrenzing van laag gebruiken
|
|
</button>
|
|
<button className="primary-action" disabled={!currentSelectionBbox || mapSelectionLoading} type="button" onClick={runAreaExtract}>
|
|
{mapSelectionLoading ? 'Objecten ophalen...' : 'Objecten in gebied ophalen'}
|
|
</button>
|
|
<button className="secondary-action" type="button" onClick={clearAreaSelection}>
|
|
Gebied wissen
|
|
</button>
|
|
</div>
|
|
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
|
{mapSelectionResult ? (
|
|
<div className="bbox-selection-result" aria-label="Resultaat van de gebiedsselectie">
|
|
<div className="feature-extract-grid">
|
|
<div>
|
|
<span>Objecten</span>
|
|
<strong>{mapSelectionResult.feature_count}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Limiet</span>
|
|
<strong>{mapSelectionResult.limit}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Afgekapt</span>
|
|
<strong>{mapSelectionResult.truncated ? 'ja' : 'nee'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Bron</span>
|
|
<strong>Bewaarde databankobjecten</strong>
|
|
</div>
|
|
</div>
|
|
<div className="feature-extract-actions">
|
|
<button className="primary-action" type="button" onClick={downloadAreaSelection}>
|
|
GeoJSON downloaden
|
|
</button>
|
|
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
|
|
GeoJSON kopiëren
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!currentSelectionBbox || selectionExporting}
|
|
type="button"
|
|
onClick={saveAreaSelectionExport}
|
|
>
|
|
{selectionExporting ? 'Download bewaren...' : 'Gebiedsdownload bewaren'}
|
|
</button>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!currentSelectionBbox || selectionDatasetSaving}
|
|
type="button"
|
|
onClick={saveAreaSelectionDataset}
|
|
>
|
|
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Als resultaatlaag bewaren'}
|
|
</button>
|
|
</div>
|
|
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
|
{latestSelectionExportPath ? (
|
|
<p className="muted">De geselecteerde download is bewaard.</p>
|
|
) : null}
|
|
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
|
|
{latestSelectionDatasetName ? (
|
|
<p className="muted">Bewaarde afgeleide laag: {latestSelectionDatasetName}</p>
|
|
) : null}
|
|
{latestSelectionDatasetName ? (
|
|
<div className="map-selection-qa-surface" aria-label="Map selection QA shortcut">
|
|
<label>
|
|
Referentielaag
|
|
<select
|
|
value={selectedMapQaReferenceDatasetId}
|
|
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
|
|
disabled={mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
|
|
>
|
|
<option value="">Kies een referentielaag</option>
|
|
{mapQaReferenceDatasets.map((dataset) => (
|
|
<option key={dataset.id} value={dataset.id}>
|
|
{dataset.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="primary-action"
|
|
disabled={!selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
|
|
type="button"
|
|
onClick={() => onRunMapSelectionQa()}
|
|
>
|
|
{mapSelectionQaRunning ? 'Kwaliteit controleren...' : 'Bewaarde laag controleren'}
|
|
</button>
|
|
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
|
|
{mapSelectionQaResult ? (
|
|
<div className="map-selection-qa-evidence" aria-label="Map selection QA result">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Kaartbewijs</p>
|
|
<h4>Vergelijking van de bewaarde selectie</h4>
|
|
</div>
|
|
<button
|
|
className="secondary-action"
|
|
disabled={!latestMapSelectionQualityCheckId}
|
|
type="button"
|
|
onClick={onOpenMapSelectionQualityEvidence}
|
|
>
|
|
Kaartbewijs openen
|
|
</button>
|
|
</div>
|
|
<div className="feature-extract-grid">
|
|
<div>
|
|
<span>Precisie</span>
|
|
<strong>{mapSelectionQaResult.precision ?? 'n.v.t.'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Herkenningsgraad</span>
|
|
<strong>{mapSelectionQaResult.recall ?? 'n.v.t.'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>F1</span>
|
|
<strong>{mapSelectionQaResult.f1_score ?? 'n.v.t.'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Gemiddelde overlap</span>
|
|
<strong>{mapSelectionQaResult.mean_iou ?? 'n.v.t.'}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Overeenkomsten</span>
|
|
<strong>{mapSelectionQaResult.matches}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Onterecht gevonden</span>
|
|
<strong>{mapSelectionQaResult.false_positives}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Gemist</span>
|
|
<strong>{mapSelectionQaResult.false_negatives}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Status bewijs</span>
|
|
<strong>{latestMapSelectionQualityCheckId ? 'bewaard' : 'niet bewaard'}</strong>
|
|
</div>
|
|
</div>
|
|
{mapSelectionQaResult.warnings.length > 0 ? (
|
|
<div className="map-selection-qa-warnings" aria-label="Aandachtspunten bij de kwaliteitscontrole">
|
|
<span>Aandachtspunten</span>
|
|
<ul>
|
|
{mapSelectionQaResult.warnings.map((warning) => (
|
|
<li key={warning}>{warning}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{areaSelectionPreviewFeatures.length > 0 ? (
|
|
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Object</th>
|
|
<th>Klasse</th>
|
|
<th>Bronreferentie</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{areaSelectionPreviewFeatures.map((feature, index) => (
|
|
<tr key={String(feature.id ?? index)}>
|
|
<td>{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}</td>
|
|
<td>{String(feature.properties?.['feature_class'] ?? 'n.v.t.')}</td>
|
|
<td>{String(feature.properties?.['source_feature_id'] ?? 'n.v.t.')}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="muted">Geen bewaarde vectorobjecten kruisen deze selectie.</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="feature-extract-surface" aria-label="Selectie en objectextractie">
|
|
<div className="panel-title-row">
|
|
<div>
|
|
<p className="eyebrow">Geselecteerd object</p>
|
|
<h3>Selectie en extractie</h3>
|
|
</div>
|
|
<span className="count-pill">{selectedMapFeature ? 'gereed' : 'wachten'}</span>
|
|
</div>
|
|
{selectedMapFeature ? (
|
|
<>
|
|
{isBathymetryProfile ? (
|
|
<div className="bathymetry-profile-summary" aria-label="Samenvatting VHA-dwarsprofiel">
|
|
<div>
|
|
<span>Waterloop</span>
|
|
<strong>{String(featureProperties?.['watercourse_name'] ?? 'Onbekende waterloop')}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Profiel</span>
|
|
<strong>{String(featureProperties?.['profile_number'] ?? 'n.v.t.')}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Meetdatum</span>
|
|
<strong>{String(featureProperties?.['measurement_date'] ?? 'Niet geregistreerd')}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Geregistreerde diepte</span>
|
|
<strong>
|
|
{typeof featureProperties?.['recorded_depth_m'] === 'number'
|
|
? `${featureProperties['recorded_depth_m'].toLocaleString('nl-BE')} m`
|
|
: 'Niet als veld beschikbaar'}
|
|
</strong>
|
|
</div>
|
|
{bathymetryDocumentUrl ? (
|
|
<a href={bathymetryDocumentUrl} target="_blank" rel="noreferrer">
|
|
Officieel profielblad openen
|
|
</a>
|
|
) : (
|
|
<small>Voor dit meetpunt is geen digitaal profielblad gekoppeld.</small>
|
|
)}
|
|
<p>
|
|
Historisch dwarsprofiel. Dit punt is geen continue actuele bodemkaart en levert zonder
|
|
gelijktijdig waterpeil geen actueel watervolume.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
<div className="feature-extract-grid" aria-label="Geometrie van het geselecteerde object">
|
|
<div>
|
|
<span>Geometrie</span>
|
|
<strong>{featureGeometrySummary.geometryType}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Coördinaten</span>
|
|
<strong>{featureGeometrySummary.coordinateCount}</strong>
|
|
</div>
|
|
<div>
|
|
<span>Eigenschappen</span>
|
|
<strong>{featureExtractionEntries.length}</strong>
|
|
</div>
|
|
<div>
|
|
<span>BBox EPSG:4326</span>
|
|
<strong>{featureGeometrySummary.bboxLabel}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="feature-extract-actions">
|
|
<button className="primary-action" type="button" onClick={downloadSelectedMapFeature}>
|
|
Geselecteerde GeoJSON downloaden
|
|
</button>
|
|
<button className="secondary-action" type="button" onClick={copySelectedMapFeatureProperties}>
|
|
Eigenschappen kopiëren
|
|
</button>
|
|
<button className="secondary-action" type="button" onClick={() => onSelectMapFeature(null)}>
|
|
Selectie wissen
|
|
</button>
|
|
</div>
|
|
{featureExtractionEntries.length > 0 ? (
|
|
<div className="table-scroll feature-property-table" aria-label="Eigenschappen van het geselecteerde object">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Eigenschap</th>
|
|
<th>Waarde</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{featureExtractionEntries.map(([key, value]) => (
|
|
<tr key={key}>
|
|
<td>{key}</td>
|
|
<td>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="muted">Het geselecteerde object heeft geometrie maar geen bewaarde eigenschappen.</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="feature-extract-empty">
|
|
<strong>Geen object geselecteerd</strong>
|
|
<p>Klik op een zichtbaar kaartobject om de eigenschappen en GeoJSON te bekijken.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="feature-inspector">
|
|
<div className="panel-title-row">
|
|
<h3>Objectinspectie</h3>
|
|
<span className="count-pill">{selectedMapFeature?.geometry?.type ?? 'geen'}</span>
|
|
</div>
|
|
{selectedMapFeature ? (
|
|
<>
|
|
{featureSummaryEntries.length > 0 ? (
|
|
<div className="feature-summary-grid" aria-label="Samenvatting van het geselecteerde object">
|
|
{featureSummaryEntries.map(([key, value]) => (
|
|
<div className="feature-property-chip" key={key}>
|
|
<span>{key}</span>
|
|
<strong>{String(value)}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
|
|
</>
|
|
) : (
|
|
<p className="muted">Klik op een zichtbaar kaartobject om de eigenschappen te bekijken.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|