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
+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)