Federate official Belgium data sources
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-19 01:34:39 +02:00
parent 33bcd0f3bd
commit 50897c3473
37 changed files with 2179 additions and 186 deletions
+4 -1
View File
@@ -723,7 +723,10 @@ remain regression workspaces and can still be selected normally.
Drawing a rectangle on the Map invokes the GeoIntel coverage resolver after a
short debounce. The map displays every intersected land or legal sea zone and
the active theme as `Beschikbaar`, `Gedeeltelijk`, `Niet gekoppeld` or `Niet
ondersteund`. A coastal or cross-region selection remains visibly split. The
ondersteund`. The same resolved zones determine which on-demand source is
eligible: GRB for Flanders, SPW/PICC for Wallonia and UrbIS for Brussels.
Products are filtered by both theme and `coverage_zones`; a coastal or
cross-region selection remains visibly split and separately persisted. The
browser never calls NGI, SPW, UrbIS, RBINS or MDK directly and never promotes
an audited catalog entry to operational data without a matching ready Dataset.
@@ -0,0 +1,64 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { AreaRead, DatasetCreateResponse, ProjectRead } from '../types'
import { WorkbenchStatusStrip } from './WorkbenchStatusStrip'
const project = {
id: 'project-1',
name: 'Belgium and North Sea Workbench',
region: 'Belgium and Belgian North Sea',
} as unknown as ProjectRead
const areas = [{ id: 'area-1', name: 'Belgium land' }] as unknown as AreaRead[]
function renderStatus(datasets: DatasetCreateResponse[]): void {
render(
<WorkbenchStatusStrip
selectedProject={project}
areas={areas}
datasets={datasets}
qualityChecks={[]}
exports={[]}
activeLayerFeatureCount={1}
selectedAreaHasGeometry
/>,
)
}
afterEach(cleanup)
describe('WorkbenchStatusStrip analytical readiness', () => {
it('treats the persisted NGI administrative baseline as analyzable', () => {
renderStatus([
{
id: 'dataset-1',
dataset_type: 'vector',
source: 'operator_official_import',
source_name: 'ngi_adminvector',
reference_layer_name: 'belgium_municipalities',
dataset_role: 'reference',
status: 'ready',
} as unknown as DatasetCreateResponse,
])
expect(screen.getByText('Kaartwerkruimte is gebruiksklaar')).toBeTruthy()
expect(screen.getByText("1 analysethema")).toBeTruthy()
})
it('does not call an unrelated stored context file an analysis theme', () => {
renderStatus([
{
id: 'dataset-2',
dataset_type: 'vector',
source: 'manual',
source_name: 'manual',
reference_layer_name: 'custom_context',
dataset_role: 'reference',
status: 'ready',
} as unknown as DatasetCreateResponse,
])
expect(screen.getByText('Kaartwerkruimte vraagt aandacht')).toBeTruthy()
expect(screen.getByText("0 analysethema's")).toBeTruthy()
})
})
@@ -32,6 +32,59 @@ function countReferenceDatasets(datasets: DatasetCreateResponse[]): number {
return datasets.filter((dataset) => dataset.dataset_role === 'reference').length
}
const ANALYTICAL_SOURCE_NAMES = new Set([
'ngi_adminvector',
'rbins_marine_reporting_units',
'rbins_msp_2026',
'grb',
'department_omgeving_land_use',
'historical_landuse',
'department_omgeving_thematic_raster',
'statbel',
'inbo_bwk_natura2000',
'agentschap_landbouw_zeevisserij_agricultural_parcels',
'dov_soil_map',
'digitaal_vlaanderen_dhmv',
'vmm_flood_hazard',
'vmm_vha_bathymetry_profiles',
])
const ANALYTICAL_LAYER_NAMES = new Set([
'administrative',
'belgium_land_boundary',
'belgium_regions',
'belgium_provinces',
'belgium_municipalities',
'buildings',
'roads',
'water',
'parcels',
'population',
'forest',
'nature_value',
'agriculture',
'soil',
'bathymetry',
'flood_hazard',
'elevation',
'maritime_planning',
'marine_environment',
'marine_legal_scopes',
'marine_spatial_plan_2026',
])
function isAnalyzableDataset(dataset: DatasetCreateResponse): boolean {
if (dataset.status !== 'ready') return false
const sourceName = String(dataset.source_name ?? dataset.source ?? '').toLowerCase()
const layerName = String(
dataset.source_metadata?.['theme']
?? dataset.reference_layer_name
?? dataset.source_metadata?.['layer_type']
?? '',
).toLowerCase()
return ANALYTICAL_SOURCE_NAMES.has(sourceName) || ANALYTICAL_LAYER_NAMES.has(layerName)
}
function statusLabel(state: StatusItem['state']): string {
if (state === 'ready') return 'gereed'
if (state === 'warning') return 'aandacht'
@@ -73,6 +126,7 @@ export function WorkbenchStatusStrip({
selectedAreaHasGeometry,
}: WorkbenchStatusStripProps): JSX.Element {
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
const analyzableDatasets = datasets.filter(isAnalyzableDataset).length
const vectorDatasets = countByDatasetType(datasets, 'vector') + countByDatasetType(datasets, 'geojson')
const rasterDatasets = countByDatasetType(datasets, 'raster')
const referenceDatasets = countReferenceDatasets(datasets)
@@ -97,9 +151,12 @@ export function WorkbenchStatusStrip({
{
key: 'datasets',
label: 'Bronnen',
value: `${readyDatasets}/${datasets.length} beschikbaar`,
detail: `${vectorDatasets} kaartlagen, ${rasterDatasets} luchtbeelden, ${referenceDatasets} officiële referenties`,
state: datasets.length === 0 ? 'waiting' : readyDatasets === datasets.length ? 'ready' : 'warning',
value: `${analyzableDatasets} analysethema${analyzableDatasets === 1 ? '' : "'s"}`,
detail: (
`${readyDatasets}/${datasets.length} bronnen gereed · `
+ `${vectorDatasets} kaartlagen, ${rasterDatasets} rasters, ${referenceDatasets} officiële referenties`
),
state: analyzableDatasets === 0 ? 'waiting' : readyDatasets === datasets.length ? 'ready' : 'warning',
},
{
key: 'map',
+227 -87
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from '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'
@@ -30,10 +30,12 @@ import {
normalizeBboxFromCorners,
operationalScopeProjectLabel,
parseBboxInput,
productCoversZones,
readablePropertyName,
resultCountLabel,
resultMetricLabel,
safeFileStem,
selectedAreaCoverageZones,
selectedFeatureCollection,
selectionAreaSquareMetres,
selectionMetricLabel,
@@ -43,7 +45,26 @@ const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
type DataThemeId = 'buildings' | 'space_occupation' | 'open_space' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'soil' | 'water' | 'bathymetry' | 'flood_hazard' | 'elevation' | 'accessibility' | 'services' | 'roads' | 'parcels'
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
@@ -67,6 +88,13 @@ interface OnDemandMapProduct extends MapThemeAcquisition {
}
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',
@@ -179,9 +207,24 @@ const DATA_THEMES: DataTheme[] = [
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',
@@ -198,6 +241,8 @@ const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
services: 'population',
roads: 'roads',
parcels: 'parcels',
maritime_planning: 'maritime_planning',
marine_environment: 'marine_environment',
}
function coverageStatusLabel(status: CoverageStatus): string {
@@ -225,6 +270,7 @@ function coverageZoneLabel(zone: string): string {
}
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' },
@@ -241,6 +287,8 @@ const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }>
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(
@@ -274,6 +322,15 @@ function datasetAvailabilityLabel(
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`
}
@@ -313,6 +370,15 @@ function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme):
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))
}
@@ -342,8 +408,17 @@ function datasetProductKey(dataset: DatasetCreateResponse): string {
function datasetCoversSelectedArea(
dataset: DatasetCreateResponse,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): boolean {
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
@@ -363,6 +438,7 @@ function rasterPartitionsForDataset(
datasets: DatasetCreateResponse[],
representative: DatasetCreateResponse | null,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope: boolean,
): DatasetCreateResponse[] {
if (!representative) {
@@ -382,7 +458,7 @@ function rasterPartitionsForDataset(
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
: datasetProductKey(dataset) === productKey
)
&& datasetCoversSelectedArea(dataset, selectedAreaId, true),
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
)
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
}
@@ -391,12 +467,13 @@ 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, regionalScope),
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
)
candidates.sort((left, right) => {
const priorityScore = (dataset: DatasetCreateResponse) =>
@@ -723,7 +800,15 @@ export function MapWorkspace({
})
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const flandersScopeSelected = activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME
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,
@@ -735,7 +820,8 @@ export function MapWorkspace({
products: officialMapProducts,
loading: officialMapProductsLoading,
error: officialMapProductsError,
} = useOfficialMapProducts(flandersScopeSelected ? selectedProjectId : null)
resolveCoverage,
} = useOfficialMapProducts(selectedProjectId)
const {
temporalComparison,
temporalComparisonLoading,
@@ -759,7 +845,6 @@ export function MapWorkspace({
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState<number | null>(null)
const mapAnalysisRequestSequence = useRef(0)
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name))
const featureProperties = selectedMapFeature?.properties ?? null
const isBathymetryProfile = featureProperties?.['provider'] === 'vmm_vha_bathymetry_profiles'
@@ -794,7 +879,7 @@ export function MapWorkspace({
.filter(
(dataset) =>
dataset.source_name === 'vmm_flood_hazard'
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, regionalScopeSelected),
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected),
)
.sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl'))
if (!regionalScopeSelected) {
@@ -809,13 +894,19 @@ export function MapWorkspace({
}
return Array.from(products.values())
},
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId],
[availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId],
)
const themeDatasetMap = useMemo(() => {
const result = Object.fromEntries(
DATA_THEMES.map((theme) => [
theme.id,
pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId, regionalScopeSelected),
pickThemeDataset(
availableMapDatasets,
theme,
selectedMapAreaId,
selectedMapArea?.name,
regionalScopeSelected,
),
]),
) as Record<DataThemeId, DatasetCreateResponse | null>
const selectedFloodHazard = floodHazardDatasets.find(
@@ -833,7 +924,7 @@ export function MapWorkspace({
(dataset) =>
dataset.source_name === 'digitaal_vlaanderen_dhmv'
&& datasetProductKey(dataset) === selectedDhmvProductKey
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, regionalScopeSelected),
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected),
) ?? null
}
if (flandersScopeSelected && officialMapProducts.thematic.length > 0) {
@@ -846,8 +937,10 @@ export function MapWorkspace({
result[product.key] = null
}
}
if (flandersScopeSelected && officialMapProducts.officialVector.length > 0) {
for (const product of officialMapProducts.officialVector) {
if (officialMapProducts.officialVector.length > 0) {
for (const product of officialMapProducts.officialVector.filter((item) =>
productCoversZones(item.coverage_zones, selectedCoverageZones),
)) {
result[product.theme] = null
}
}
@@ -865,7 +958,9 @@ export function MapWorkspace({
selectedDhmvProductKey,
selectedFloodHazardDatasetId,
selectedFloodHazardProductKey,
selectedMapArea?.name,
selectedMapAreaId,
selectedCoverageZones,
])
const themePartitionMap = useMemo(
() =>
@@ -876,11 +971,12 @@ export function MapWorkspace({
availableMapDatasets,
themeDatasetMap[theme.id],
selectedMapAreaId,
selectedMapArea?.name,
regionalScopeSelected,
),
]),
) as Record<DataThemeId, DatasetCreateResponse[]>,
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap],
[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]
@@ -892,14 +988,16 @@ export function MapWorkspace({
) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
[coverage],
)
const onDemandProductMap = useMemo(
() => {
const result = new Map<DataThemeId, OnDemandMapProduct>()
if (!flandersScopeSelected) {
return result
}
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.set(product.theme, {
result.push({
kind: 'thematic_raster',
productKey: product.key,
displayName: product.display_name,
@@ -910,7 +1008,7 @@ export function MapWorkspace({
})
}
for (const product of officialMapProducts.grb) {
result.set(product.key, {
result.push({
kind: 'grb',
productKey: product.key,
displayName: product.display_name,
@@ -920,52 +1018,65 @@ export function MapWorkspace({
limitationMessage: product.limitation_message,
})
}
for (const product of officialMapProducts.officialVector) {
result.set(product.theme, {
kind: 'official_vector',
productKey: product.key,
displayName: product.display_name,
theme: product.theme,
availabilityLabel: `${product.observation_label} · officiële vectorbron · laad bij selectie`,
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
}
const dhmvProduct = officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
if (dhmvProduct) {
result.set('elevation', {
kind: 'dhmv',
productKey: dhmvProduct.key,
displayName: dhmvProduct.display_name,
theme: 'elevation',
availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · laad bij selectie`,
attribution: dhmvProduct.attribution,
limitationMessage: dhmvProduct.limitation_message,
})
}
const floodProduct = officialMapProducts.floodHazard.find(
(product) => product.key === selectedFloodHazardProductKey,
)
if (floodProduct) {
result.set('flood_hazard', {
kind: 'flood_hazard',
productKey: floodProduct.key,
displayName: floodProduct.display_name,
theme: 'flood_hazard',
availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · laad bij selectie`,
attribution: floodProduct.attribution,
limitationMessage: floodProduct.limitation_message,
})
}
return result
},
[
flandersScopeSelected,
officialMapProducts,
selectedDhmvProductKey,
selectedFloodHazardProductKey,
],
)
}
for (const product of officialMapProducts.officialVector.filter((item) =>
productCoversZones(item.coverage_zones, effectiveZones),
)) {
result.push({
kind: 'official_vector',
productKey: product.key,
displayName: product.display_name,
theme: product.theme,
availabilityLabel: `${product.observation_label} · officiële vectorbron · laad bij selectie`,
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
}
const dhmvProduct = includesFlanders
? officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
: null
if (dhmvProduct) {
result.push({
kind: 'dhmv',
productKey: dhmvProduct.key,
displayName: dhmvProduct.display_name,
theme: 'elevation',
availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · laad bij selectie`,
attribution: dhmvProduct.attribution,
limitationMessage: dhmvProduct.limitation_message,
})
}
const floodProduct = includesFlanders
? officialMapProducts.floodHazard.find(
(product) => product.key === selectedFloodHazardProductKey,
)
: null
if (floodProduct) {
result.push({
kind: 'flood_hazard',
productKey: floodProduct.key,
displayName: floodProduct.display_name,
theme: 'flood_hazard',
availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · laad bij selectie`,
attribution: floodProduct.attribution,
limitationMessage: floodProduct.limitation_message,
})
}
return result
}, [
activeScopeProject?.name,
officialMapProducts,
selectedCoverageZones,
selectedDhmvProductKey,
selectedFloodHazardProductKey,
])
const onDemandProductMap = useMemo(() => {
const result = new Map<DataThemeId, OnDemandMapProduct>()
for (const product of onDemandProductsForZones(selectedCoverageZones)) {
result.set(product.theme, product)
}
return result
}, [onDemandProductsForZones, selectedCoverageZones])
const activeOnDemandMapProduct = onDemandProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
@@ -993,15 +1104,20 @@ export function MapWorkspace({
useEffect(() => {
if (
!flandersScopeSelected
|| analysisMode !== 'current'
analysisMode !== 'current'
|| activeThemeAvailable
|| (onDemandProductMap.size === 0 && !officialMapProductsError)
|| (
selectedProjectId
&& officialMapProductsLoading
&& onDemandProductMap.size === 0
&& !officialMapProductsError
)
) {
return
}
const fallbackTheme = DATA_THEMES.find((theme) =>
theme.id === 'space_occupation'
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)),
@@ -1019,8 +1135,10 @@ export function MapWorkspace({
analysisMode,
flandersScopeSelected,
officialMapProductsError,
officialMapProductsLoading,
onDemandProductMap,
onOpenDatasetInMap,
selectedProjectId,
themeDatasetMap,
])
@@ -1528,20 +1646,37 @@ export function MapWorkspace({
}
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
let resolvedZones = selectedCoverageZones
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
}
const resolvedProducts = analysisMode === 'current'
? onDemandProductsForZones(resolvedZones)
: []
const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) {
const onDemandProduct = analysisMode === 'current'
? onDemandProductMap.get(theme.id)
: null
if (onDemandProduct) {
availableThemes.push({
themeId: theme.id,
acquisition: {
kind: onDemandProduct.kind,
productKey: onDemandProduct.productKey,
displayName: onDemandProduct.displayName,
},
})
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,
},
})
}
continue
}
const dataset = themeDatasetMap[theme.id]
@@ -1856,10 +1991,10 @@ export function MapWorkspace({
: activeTheme.description}
</small>
</div>
{officialMapProductsLoading && flandersScopeSelected ? (
{officialMapProductsLoading && selectedProjectId ? (
<p className="geo-data-notice">Beschikbare Vlaamse kaartbronnen worden gecontroleerd</p>
) : null}
{officialMapProductsError && flandersScopeSelected ? (
{officialMapProductsError && selectedProjectId ? (
<p className="error">{officialMapProductsError}</p>
) : null}
@@ -1877,7 +2012,12 @@ export function MapWorkspace({
(item) =>
item.source_name === 'digitaal_vlaanderen_dhmv'
&& datasetProductKey(item) === productKey
&& datasetCoversSelectedArea(item, selectedMapAreaId, regionalScopeSelected),
&& datasetCoversSelectedArea(
item,
selectedMapAreaId,
selectedMapArea?.name,
regionalScopeSelected,
),
)
if (dataset) {
onOpenDatasetInMap(dataset)
@@ -3,7 +3,9 @@ import {
bboxesEqual,
normalizeBboxFromCorners,
parseBboxInput,
productCoversZones,
resultMetricLabel,
selectedAreaCoverageZones,
selectionAreaSquareMetres,
} from './mapWorkspaceUtils'
import type { VectorSelectionResponse } from '../../types'
@@ -47,4 +49,18 @@ describe('map workspace selection guards', () => {
} as unknown as VectorSelectionResponse
expect(resultMetricLabel(result)).toBe('14,24 ha')
})
it('maps national work areas to regional provider zones without merging sources', () => {
expect(selectedAreaCoverageZones('Belgium land')).toEqual([
'belgium',
'flanders',
'wallonia',
'brussels',
])
expect(selectedAreaCoverageZones('RC Ardennes inland')).toEqual(['wallonia'])
expect(selectedAreaCoverageZones('Brussels-Capital Region')).toEqual(['brussels'])
expect(selectedAreaCoverageZones('Language boundary')).toEqual(['flanders', 'wallonia'])
expect(productCoversZones(['wallonia'], ['flanders', 'wallonia'])).toBe(true)
expect(productCoversZones(['brussels'], ['wallonia'])).toBe(false)
})
})
@@ -13,6 +13,28 @@ import {
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
export function selectedAreaCoverageZones(areaName: string | null | undefined): string[] | null {
const normalized = String(areaName ?? '').toLowerCase()
if (!normalized) return null
if (normalized.includes('coast land-sea')) return ['flanders', 'belgian_north_sea']
if (normalized.includes('language boundary')) return ['flanders', 'wallonia']
if (normalized.includes('north sea')) {
return ['belgian_north_sea', 'territorial_sea', 'exclusive_economic_zone', 'continental_shelf']
}
if (normalized.includes('territorial sea')) return ['territorial_sea']
if (normalized.includes('exclusive economic zone')) return ['exclusive_economic_zone']
if (normalized.includes('continental shelf')) return ['continental_shelf']
if (normalized.includes('brussels')) return ['brussels']
if (normalized.includes('wallonia') || normalized.includes('ardennes')) return ['wallonia']
if (normalized.includes('flanders') || normalized.includes('mol') || normalized.includes('kempen')) return ['flanders']
if (normalized.includes('belgium land')) return ['belgium', 'flanders', 'wallonia', 'brussels']
return null
}
export function productCoversZones(productZones: string[], selectedZones: string[] | null): boolean {
return selectedZones === null || selectedZones.some((zone) => productZones.includes(zone))
}
export function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
+25 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { datasetsApi } from '../services/api'
import { useCallback, useEffect, useState } from 'react'
import { datasetsApi, externalApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type {
DhmvProductRead,
@@ -9,7 +9,7 @@ import type {
ThematicRasterProductRead,
} from '../types'
interface OfficialMapProducts {
export interface OfficialMapProducts {
thematic: ThematicRasterProductRead[]
dhmv: DhmvProductRead[]
floodHazard: FloodHazardProductRead[]
@@ -78,5 +78,26 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
}
}, [selectedProjectId])
return { products, loading, error }
const resolveCoverage = useCallback(
async (bbox: { minx: number; miny: number; maxx: number; maxy: number }) => {
if (!selectedProjectId) {
setError('Selecteer eerst een werkruimte.')
return null
}
try {
const coverage = await externalApi.resolveCoverage({
projectId: selectedProjectId,
bbox,
})
setError(null)
return coverage
} catch (requestError) {
setError(formatError(requestError, 'De regionale databronnen konden niet veilig worden bepaald.'))
return null
}
},
[selectedProjectId],
)
return { products, loading, error, resolveCoverage }
}
+3 -2
View File
@@ -393,11 +393,11 @@ export interface OfficialVectorAcquireRequest {
export interface OfficialVectorProductRead {
key: string
display_name: string
theme: 'nature_value' | 'soil'
theme: 'buildings' | 'roads' | 'water' | 'parcels' | 'nature_value' | 'soil'
provider: string
source_name: string
reference_layer_name: string
service_type: 'OGC API Features' | 'WFS 2.0'
service_type: 'OGC API Features' | 'WFS 2.0' | 'ArcGIS REST'
collection: string
geometry_types: string[]
source_crs: string
@@ -408,6 +408,7 @@ export interface OfficialVectorProductRead {
attribution: string
license_note: string
limitation_message: string
coverage_zones: string[]
}
export interface TerrainSelectionResponse {