Add governed VMM flood hazard scenarios
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 22:14:12 +02:00
parent 6238b252a9
commit 501f824257
38 changed files with 2059 additions and 18 deletions
@@ -12,6 +12,7 @@ const THEME_LABELS: Record<string, string> = {
nature_value: 'Natuurwaarde',
agriculture: 'Landbouw',
water: 'Water',
flood_hazard: 'Overstromingsgevaar',
elevation: 'Hoogte en reliëf',
roads: 'Wegen en transport',
parcels: 'Percelen',
@@ -66,6 +67,14 @@ const AVAILABLE_SOURCES = [
value: 'Hydrologische toestand; geen gebiedsdekkend watervolume zonder bodemprofiel',
url: 'https://waterinfo.vlaanderen.be/',
},
{
key: 'flood_hazard',
name: 'Overstromingsgevaarkaarten waterdiepte',
owner: 'Vlaamse Milieumaatschappij',
coverage: 'Fluviaal/pluviaal, T10/T100/T1000, huidig klimaat en projectie 2050',
value: 'Gemodelleerd overstroomd oppervlak en maximale waterdiepte per vast scenario; geen bathymetrie',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/publieke-inspire-coverage-service-van-ogrk',
},
]
function datasetTheme(dataset: DatasetCreateResponse): string | null {
@@ -113,6 +122,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
(dataset) => dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register',
)
const dhmvDatasets = ready.filter((dataset) => dataset.source_name === 'digitaal_vlaanderen_dhmv')
const floodHazardDatasets = ready.filter((dataset) => dataset.source_name === 'vmm_flood_hazard')
const latestBuildingsRegister = [...buildingsRegisterDatasets].sort(
(left, right) => new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime(),
)[0]
@@ -125,6 +135,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
if (source.key === 'agriculture') return agricultureDatasets.length === 0
if (source.key === 'buildings_register') return buildingsRegisterDatasets.length === 0
if (source.key === 'elevation') return dhmvDatasets.length === 0
if (source.key === 'flood_hazard') return floodHazardDatasets.length === 0
return true
})
const themes = Object.keys(THEME_LABELS).map((theme) => {
@@ -184,7 +195,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
))}
</div>
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 || dhmvDatasets.length > 0 ? (
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 || dhmvDatasets.length > 0 || floodHazardDatasets.length > 0 ? (
<div className="source-catalog-loaded" aria-label="Aanvullende ingeladen bronnen">
{waterinfoDatasets.length > 0 ? (
<article>
@@ -232,6 +243,13 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
<p>Hoogte in TAW, reliëf en helling uit opnameperiode 2013-2015. Geen waterdiepte of watervolume.</p>
</article>
) : null}
{floodHazardDatasets.length > 0 ? (
<article>
<strong>VMM-overstromingsgevaarkaarten</strong>
<span>{floodHazardDatasets.length} scenario&apos;s · fluviaal en pluviaal · huidig en 2050</span>
<p>Gemodelleerde maximumdiepte per kansscenario. Geen actuele waterstand, bathymetrie of permanent watervolume.</p>
</article>
) : null}
</div>
) : null}
+88 -9
View File
@@ -7,6 +7,7 @@ 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'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -14,7 +15,7 @@ const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
type DataThemeId = 'buildings' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'water' | 'elevation' | 'roads' | 'parcels'
type DataThemeId = 'buildings' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'water' | 'flood_hazard' | 'elevation' | 'roads' | 'parcels'
interface DataTheme {
id: DataThemeId
@@ -73,6 +74,13 @@ const DATA_THEMES: DataTheme[] = [
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
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',
@@ -103,6 +111,7 @@ const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }>
nature_value: { fill: '#9a4f64', line: '#74364a' },
agriculture: { fill: '#7b8f32', line: '#53671d' },
water: { fill: '#2676a8', line: '#155b85' },
flood_hazard: { fill: '#1597c2', line: '#075985' },
elevation: { fill: '#a57a4b', line: '#315f59' },
roads: { fill: '#6b7280', line: '#4b5563' },
parcels: { fill: '#a7792f', line: '#7d571f' },
@@ -113,6 +122,10 @@ function datasetAvailabilityLabel(dataset: DatasetCreateResponse): string {
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid beschikbaar`
}
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`
}
return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar`
}
@@ -126,6 +139,7 @@ function datasetSearchText(dataset: DatasetCreateResponse): string {
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
dataset.source_metadata?.['product_display_name'],
]
.filter(Boolean)
.join(' ')
@@ -162,7 +176,9 @@ function pickThemeDataset(
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_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_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) +
(dataset.observed_at ? new Date(dataset.observed_at).getTime() / 100_000_000 : 0) +
(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0)
@@ -220,6 +236,9 @@ function formatObservationDate(value: string | null | undefined): string {
}
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'] ?? '')}`
}
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
@@ -227,6 +246,11 @@ function formatDatasetObservation(dataset: DatasetCreateResponse): string {
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)
}
function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
@@ -643,6 +667,7 @@ export function MapWorkspace({
clearTemporalComparison,
} = useTemporalComparison(selectedProjectId)
const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current')
const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('')
const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('')
const [earlierDatasetId, setEarlierDatasetId] = useState('')
const [laterDatasetId, setLaterDatasetId] = useState('')
@@ -675,13 +700,22 @@ export function MapWorkspace({
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 themeDatasetMap = useMemo(
() =>
Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]),
) as Record<DataThemeId, DatasetCreateResponse | null>,
const floodHazardDatasets = useMemo(
() => availableMapDatasets
.filter((dataset) => dataset.source_name === 'vmm_flood_hazard' && datasetCoversSelectedArea(dataset, selectedMapAreaId))
.sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')),
[availableMapDatasets, selectedMapAreaId],
)
const themeDatasetMap = useMemo(() => {
const result = Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]),
) as Record<DataThemeId, DatasetCreateResponse | null>
const selectedFloodHazard = floodHazardDatasets.find((dataset) => dataset.id === selectedFloodHazardDatasetId)
if (selectedFloodHazard) {
result.flood_hazard = selectedFloodHazard
}
return result
}, [availableMapDatasets, floodHazardDatasets, selectedFloodHazardDatasetId, selectedMapAreaId])
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
@@ -706,7 +740,18 @@ export function MapWorkspace({
opacity: 0.82,
}
: null
const activeImageOverlay = terrainImageOverlay ?? orthophotoImageOverlay
const floodHazardBounds = activeThemeDataset?.source_name === 'vmm_flood_hazard'
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
: null
const floodHazardImageOverlay = activeTheme.id === 'flood_hazard' && activeThemeDataset && selectedProjectId && Array.isArray(floodHazardBounds) && floodHazardBounds.length === 4
? {
url: floodHazardImageUrl(selectedProjectId, activeThemeDataset.id),
bbox: floodHazardBounds.map(Number) as [number, number, number, number],
label: floodScenarioLabel(activeThemeDataset),
opacity: 0.82,
}
: null
const activeImageOverlay = floodHazardImageOverlay ?? terrainImageOverlay ?? orthophotoImageOverlay
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
@@ -730,7 +775,8 @@ export function MapWorkspace({
}),
[themeInsights],
)
const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result ?? mapSelectionResult
const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result
?? (selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null)
const selectedAreaSquareMetres = useMemo(
() =>
bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2
@@ -788,6 +834,16 @@ export function MapWorkspace({
)
}, [activeTemporalSeriesGroups])
useEffect(() => {
if (floodHazardDatasets.some((dataset) => dataset.id === selectedFloodHazardDatasetId)) {
return
}
const preferred = floodHazardDatasets.find(
(dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100',
) ?? floodHazardDatasets[0]
setSelectedFloodHazardDatasetId(preferred?.id ?? '')
}, [floodHazardDatasets, selectedFloodHazardDatasetId])
useEffect(() => {
const first = activeTemporalSeries[0]
const last = activeTemporalSeries[activeTemporalSeries.length - 1]
@@ -1172,6 +1228,29 @@ export function MapWorkspace({
</small>
</div>
{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 ? (
@@ -1559,7 +1638,7 @@ export function MapWorkspace({
{analysisMode === 'current' ? (
<div className="geo-result-actions">
<button className="secondary-action" disabled={!activeSelectionResult || activeTheme.id === 'elevation'} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button>
<button className="secondary-action" disabled={!activeSelectionResult || activeTheme.id === 'elevation' || activeTheme.id === 'flood_hazard'} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button>
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeSelection}>Kopieer gegevens</button>
</div>
) : null}