Add bounded terrain and flood analysis
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 20:36:45 +02:00
parent 972c3f0e1b
commit 47cde21e17
12 changed files with 441 additions and 125 deletions
+18
View File
@@ -7,6 +7,24 @@
# Changelog # Changelog
## Sprint 238 Governed Flanders terrain and flood selection (2026-07-17)
- Unified the Flanders map selection flow behind one official-raster
acquisition contract covering the five policy rasters, DHMV and VMM flood
hazard without adding browser-side provider calls.
- Added explicit DTM/DSM and twelve-scenario VMM registry selectors. Products
are acquired only for the selected municipality or rectangle, persisted as
ordinary Datasets and reused by exact request identity.
- Made one cross-domain area run include measured terrain and scenario-bound
flood metrics alongside the existing policy and bathymetry results.
- Corrected the default VMM request key from the invalid
`pluvial_current_t100` spelling to the governed
`pluviaal_current_t100` product.
- Kept whole-Flanders raster acquisition behind the existing bounded-area
safety gate and retained explicit source semantics: DHMV is height in TAW,
while VMM flood depth is modeled hazard rather than current water level or
bathymetry.
## Sprint 237 Bounded Flanders cross-domain profile (2026-07-17) ## Sprint 237 Bounded Flanders cross-domain profile (2026-07-17)
- Exposed the five existing governed MercatorNet policy rasters as - Exposed the five existing governed MercatorNet policy rasters as
+1 -1
View File
@@ -10,7 +10,7 @@ from .operations import VectorSelectionBBox
class FloodHazardAcquireRequest(BaseModel): class FloodHazardAcquireRequest(BaseModel):
bbox: VectorSelectionBBox bbox: VectorSelectionBBox
area_id: UUID | None = None area_id: UUID | None = None
product_key: str = "pluvial_current_t100" product_key: str = "pluviaal_current_t100"
resolution_m: float | None = Field(default=None, ge=2.0, le=20.0) resolution_m: float | None = Field(default=None, ge=2.0, le=20.0)
force_refresh: bool = False force_refresh: bool = False
@@ -340,7 +340,7 @@ def test_theme_failures_name_the_source_and_reason() -> None:
hook = (root / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8") hook = (root / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
assert "queries[index]?.dataset?.name" in hook assert "queries[index]?.dataset?.name" in hook
assert "queries[index]?.thematicProductKey" in hook assert "queries[index]?.acquisition?.displayName" in hook
assert "reason: formatError(item.reason" in hook assert "reason: formatError(item.reason" in hook
assert "failure.dataset}: ${failure.reason}" in hook assert "failure.dataset}: ${failure.reason}" in hook
@@ -10,21 +10,22 @@ def read(path: str) -> str:
def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> None: def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read("frontend/src/components/map/MapWorkspace.tsx")
product_hook = read("frontend/src/hooks/useThematicRasterProducts.ts") product_hook = read("frontend/src/hooks/useOfficialRasterProducts.ts")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts") selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
api = read("frontend/src/services/api/datasets.ts") api = read("frontend/src/services/api/datasets.ts")
assert "activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME" in workspace assert "activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME" in workspace
assert "new Map<DataThemeId, ThematicRasterProductRead>" in workspace assert "new Map<DataThemeId, OnDemandRasterProduct>" in workspace
assert "'Op aanvraag'" in workspace assert "'Op aanvraag'" in workspace
assert "theme.id === 'space_occupation'" in workspace assert "theme.id === 'space_occupation'" in workspace
assert "setActiveThemeId(fallbackTheme.id)" in workspace assert "setActiveThemeId(fallbackTheme.id)" in workspace
assert "return `referentiejaar ${observationYear}`" in workspace assert "return `referentiejaar ${observationYear}`" in workspace
assert "thematicProductKey: thematicProduct.key" in workspace assert "kind: 'thematic_raster'" in workspace
assert "productKey: onDemandProduct.productKey" in workspace
assert "datasetsApi.listThematicRasterProducts" in product_hook assert "datasetsApi.listThematicRasterProducts" in product_hook
assert "datasetsApi.acquireThematicRaster" in selection_hook assert "datasetsApi.acquireThematicRaster" in selection_hook
assert "datasetsApi.selectThematicRaster" in selection_hook assert "datasetsApi.selectThematicRaster" in selection_hook
assert "datasetsApi.get(selectedProjectId, acquisition.output_dataset_id)" in selection_hook assert "datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)" in selection_hook
assert "/datasets/thematic-raster/acquire" in api assert "/datasets/thematic-raster/acquire" in api
@@ -35,18 +36,18 @@ def test_selection_runs_all_available_themes_and_refreshes_persisted_datasets()
assert "for (const theme of DATA_THEMES)" in workspace assert "for (const theme of DATA_THEMES)" in workspace
assert "await loadThemeInsights(bbox, availableThemes, areaId)" in workspace assert "await loadThemeInsights(bbox, availableThemes, areaId)" in workspace
assert "!regionalPartitionedThemeActive && !onDemandThematicThemeActive" in workspace assert "!regionalPartitionedThemeActive && !onDemandRasterThemeActive" in workspace
assert "onRefreshProjectData" in workspace assert "onRefreshProjectData" in workspace
assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app
assert "successful.some((item) => item.thematicProductKey)" in selection_hook assert "successful.some((item) => item.acquisition)" in selection_hook
assert "await onDatasetsChanged()" in selection_hook assert "await onDatasetsChanged()" in selection_hook
def test_regional_on_demand_rasters_require_a_bounded_drawn_selection() -> None: def test_regional_on_demand_rasters_require_a_bounded_drawn_selection() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "regionalOnDemandThematicThemeActive" in workspace assert "regionalOnDemandRasterThemeActive" in workspace
assert "regionalRasterThemeActive || regionalOnDemandThematicThemeActive" in workspace assert "regionalRasterThemeActive || regionalOnDemandRasterThemeActive" in workspace
assert "Teken een begrensde rechthoek voor een regionale rasteranalyse." in workspace assert "Teken een begrensde rechthoek voor een regionale rasteranalyse." in workspace
assert "officiële Vlaamse rasters worden begrensd opgehaald, bewaard en hergebruikt" in workspace assert "officiële Vlaamse rasters worden begrensd opgehaald, bewaard en hergebruikt" in workspace
@@ -0,0 +1,43 @@
from pathlib import Path
from app.schemas.flood_hazard import FloodHazardAcquireRequest
from app.schemas.operations import VectorSelectionBBox
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_official_raster_catalog_hook_loads_all_governed_registries() -> None:
hook = read("frontend/src/hooks/useOfficialRasterProducts.ts")
assert "datasetsApi.listThematicRasterProducts" in hook
assert "datasetsApi.listDhmvProducts" in hook
assert "datasetsApi.listFloodHazardProducts" in hook
assert "Promise.all([" in hook
def test_map_selection_can_acquire_dhmv_and_flood_hazard_products() -> None:
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "'thematic_raster' | 'dhmv' | 'flood_hazard'" in selection_hook
assert "datasetsApi.acquireDhmv" in selection_hook
assert "datasetsApi.acquireFloodHazard" in selection_hook
assert "datasetsApi.acquireThematicRaster" in selection_hook
assert 'aria-label="Hoogtemodel"' in workspace
assert 'aria-label="Overstromingsscenario"' in workspace
assert "product.display_name" in workspace
assert "DTM meet het maaiveld; DSM bevat ook gebouwen en vegetatie." in workspace
assert "geen actuele waterstand" in workspace
def test_default_flood_hazard_product_exists_in_the_governed_registry() -> None:
request = FloodHazardAcquireRequest(
bbox=VectorSelectionBBox(min_x=5.0, min_y=51.0, max_x=5.01, max_y=51.01),
)
assert request.product_key == "pluviaal_current_t100"
+3
View File
@@ -314,6 +314,9 @@ metres, publication metadata, attribution and limitation.
Acquires one bounded official VMM OGRK WCS 1.1 coverage behind the synchronous Acquires one bounded official VMM OGRK WCS 1.1 coverage behind the synchronous
Job abstraction. Arbitrary coverage identifiers and service URLs are rejected. Job abstraction. Arbitrary coverage identifiers and service URLs are rejected.
When `product_key` is omitted, the governed default is
`pluviaal_current_t100`; the default is itself present in the fixed product
registry.
```json ```json
{ {
+22
View File
@@ -10200,3 +10200,25 @@ Final live acceptance:
and labelled `coverage_scope=municipality`. The UI reports product reference and labelled `coverage_scope=municipality`. The UI reports product reference
years directly so an end-of-year UTC timestamp cannot roll 2025 into a years directly so an end-of-year UTC timestamp cannot roll 2025 into a
misleading local `1 jan 2026` label. misleading local `1 jan 2026` label.
## Sprint 238 - Governed Flanders terrain and flood selection (2026-07-17)
Implemented:
- Replaced the policy-raster-specific map query field with a typed official
raster acquisition contract for `thematic_raster`, `dhmv` and
`flood_hazard`.
- Added a single Flanders product hook that loads all three fixed backend
registries in parallel. The browser still uses only canonical GeoIntel API
routes and has no external WCS URL.
- Added an end-user DTM/DSM selector and the full twelve-product VMM scenario
selector. Existing exact-area Datasets are reused; absent products are
acquired, persisted, analyzed and reflected back into the Dataset catalog.
- Extended the all-theme selection run with terrain and flood hazard while
preserving area intersection and the no-whole-Flanders-raster guard.
- Fixed the invalid default flood key spelling in the Pydantic request schema.
Validation:
- Focused DHMV, VMM, map-orchestration and source-contract tests passed
(49 tests).
- Frontend TypeScript typecheck and production build passed before the full
release gate.
+15
View File
@@ -598,6 +598,21 @@ pattern and a rectangle returns scenario-bound hectare/depth metrics. Raster
GeoJSON export stays disabled. The UI never labels the maximum-depth area GeoJSON export stays disabled. The UI never labels the maximum-depth area
integral as current, permanent or concurrent water volume. integral as current, permanent or concurrent water volume.
In the Flanders workspace, DHMV and VMM products do not need to be
pre-provisioned before they become usable. The explorer reads the governed
backend registries, exposes DTM/DSM and all twelve VMM scenarios, and acquires
only the municipality or rectangle the user explicitly analyses. The
acquisition still runs through the backend Job and Dataset boundaries; the
browser never contacts WCS directly. An exact repeat reuses the persisted
request. A complete-Flanders raster request remains disabled by the regional
size guard, while a complete municipality remains a valid bounded analysis.
Selecting `Hoogte & reliëf` shows the source distinction before analysis:
DTM represents terrain after removal of buildings and other objects; DSM
represents the visible surface including buildings and vegetation. Selecting
`Overstroming` shows mechanism, climate context and return period. The result
therefore cannot be mistaken for an observed current water level.
Regional VMM provisioning creates one scenario raster per municipality Area. Regional VMM provisioning creates one scenario raster per municipality Area.
For a municipality the explorer still uses only that exact Area-linked file. For a municipality the explorer still uses only that exact Area-linked file.
For the complete Kempen Area it presents the 28 VMM and DHMV partitions as one For the complete Kempen Area it presents the 28 VMM and DHMV partitions as one
+218 -50
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap' import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, ThematicRasterProductRead, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
import { useThematicRasterProducts } from '../../hooks/useThematicRasterProducts' import { useOfficialRasterProducts } from '../../hooks/useOfficialRasterProducts'
import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart' import { TemporalTrendChart } from './TemporalTrendChart'
@@ -54,6 +54,14 @@ interface TemporalSeriesGroup {
items: DatasetCreateResponse[] items: DatasetCreateResponse[]
} }
interface OnDemandRasterProduct extends MapThemeAcquisition {
theme: DataThemeId
nativeResolutionM: number
referenceLabel: string
attribution: string
limitationMessage: string
}
const DATA_THEMES: DataTheme[] = [ const DATA_THEMES: DataTheme[] = [
{ {
id: 'buildings', id: 'buildings',
@@ -661,10 +669,10 @@ export function MapWorkspace({
clearThemeInsights, clearThemeInsights,
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId, onRefreshProjectData) } = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId, onRefreshProjectData)
const { const {
products: thematicRasterProducts, products: officialRasterProducts,
loading: thematicRasterProductsLoading, loading: officialRasterProductsLoading,
error: thematicRasterProductsError, error: officialRasterProductsError,
} = useThematicRasterProducts(flandersScopeSelected ? selectedProjectId : null) } = useOfficialRasterProducts(flandersScopeSelected ? selectedProjectId : null)
const { const {
temporalComparison, temporalComparison,
temporalComparisonLoading, temporalComparisonLoading,
@@ -674,6 +682,8 @@ export function MapWorkspace({
} = useTemporalComparison(selectedProjectId) } = useTemporalComparison(selectedProjectId)
const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current') const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current')
const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('') 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 [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('')
const [earlierDatasetId, setEarlierDatasetId] = useState('') const [earlierDatasetId, setEarlierDatasetId] = useState('')
const [laterDatasetId, setLaterDatasetId] = useState('') const [laterDatasetId, setLaterDatasetId] = useState('')
@@ -743,12 +753,37 @@ export function MapWorkspace({
pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId, regionalScopeSelected), pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId, regionalScopeSelected),
]), ]),
) as Record<DataThemeId, DatasetCreateResponse | null> ) as Record<DataThemeId, DatasetCreateResponse | null>
const selectedFloodHazard = floodHazardDatasets.find((dataset) => dataset.id === selectedFloodHazardDatasetId) const selectedFloodHazard = floodHazardDatasets.find(
(dataset) =>
dataset.id === selectedFloodHazardDatasetId
|| (flandersScopeSelected && datasetProductKey(dataset) === selectedFloodHazardProductKey),
)
if (selectedFloodHazard) { if (selectedFloodHazard) {
result.flood_hazard = selectedFloodHazard result.flood_hazard = selectedFloodHazard
} else if (flandersScopeSelected && officialRasterProducts.floodHazard.length > 0) {
result.flood_hazard = null
}
if (flandersScopeSelected && officialRasterProducts.dhmv.length > 0) {
result.elevation = availableMapDatasets.find(
(dataset) =>
dataset.source_name === 'digitaal_vlaanderen_dhmv'
&& datasetProductKey(dataset) === selectedDhmvProductKey
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, regionalScopeSelected),
) ?? null
} }
return result return result
}, [availableMapDatasets, floodHazardDatasets, regionalScopeSelected, selectedFloodHazardDatasetId, selectedMapAreaId]) }, [
availableMapDatasets,
flandersScopeSelected,
floodHazardDatasets,
officialRasterProducts.dhmv.length,
officialRasterProducts.floodHazard.length,
regionalScopeSelected,
selectedDhmvProductKey,
selectedFloodHazardDatasetId,
selectedFloodHazardProductKey,
selectedMapAreaId,
])
const themePartitionMap = useMemo( const themePartitionMap = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
@@ -765,15 +800,62 @@ export function MapWorkspace({
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap], [availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap],
) )
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const onDemandThematicProductMap = useMemo( const onDemandRasterProductMap = useMemo(
() => flandersScopeSelected () => {
? new Map<DataThemeId, ThematicRasterProductRead>( const result = new Map<DataThemeId, OnDemandRasterProduct>()
thematicRasterProducts.map((product) => [product.theme, product]), if (!flandersScopeSelected) {
) return result
: new Map<DataThemeId, ThematicRasterProductRead>(), }
[flandersScopeSelected, thematicRasterProducts], for (const product of officialRasterProducts.thematic) {
result.set(product.theme, {
kind: 'thematic_raster',
productKey: product.key,
displayName: product.display_name,
theme: product.theme,
nativeResolutionM: product.native_resolution_m,
referenceLabel: String(product.observation_year),
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
}
const dhmvProduct = officialRasterProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
if (dhmvProduct) {
result.set('elevation', {
kind: 'dhmv',
productKey: dhmvProduct.key,
displayName: dhmvProduct.display_name,
theme: 'elevation',
nativeResolutionM: dhmvProduct.native_resolution_m,
referenceLabel: dhmvProduct.acquisition_period,
attribution: dhmvProduct.attribution,
limitationMessage: dhmvProduct.limitation_message,
})
}
const floodProduct = officialRasterProducts.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',
nativeResolutionM: floodProduct.native_resolution_m,
referenceLabel: `${floodProduct.climate_context} · T${floodProduct.return_period_years}`,
attribution: floodProduct.attribution,
limitationMessage: floodProduct.limitation_message,
})
}
return result
},
[
flandersScopeSelected,
officialRasterProducts,
selectedDhmvProductKey,
selectedFloodHazardProductKey,
],
) )
const activeOnDemandThematicProduct = onDemandThematicProductMap.get(activeTheme.id) ?? null const activeOnDemandRasterProduct = onDemandRasterProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id] const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection) const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
@@ -793,24 +875,24 @@ export function MapWorkspace({
const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset) const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset)
const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset) const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset)
const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive
const onDemandThematicThemeActive = analysisMode === 'current' && Boolean(activeOnDemandThematicProduct) const onDemandRasterThemeActive = analysisMode === 'current' && Boolean(activeOnDemandRasterProduct)
const regionalOnDemandThematicThemeActive = regionalScopeSelected && onDemandThematicThemeActive const regionalOnDemandRasterThemeActive = regionalScopeSelected && onDemandRasterThemeActive
const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThematicThemeActive const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandRasterThemeActive
useEffect(() => { useEffect(() => {
if ( if (
!flandersScopeSelected !flandersScopeSelected
|| analysisMode !== 'current' || analysisMode !== 'current'
|| activeThemeAvailable || activeThemeAvailable
|| (onDemandThematicProductMap.size === 0 && !thematicRasterProductsError) || (onDemandRasterProductMap.size === 0 && !officialRasterProductsError)
) { ) {
return return
} }
const fallbackTheme = DATA_THEMES.find((theme) => const fallbackTheme = DATA_THEMES.find((theme) =>
theme.id === 'space_occupation' theme.id === 'space_occupation'
&& Boolean(themeDatasetMap[theme.id] || onDemandThematicProductMap.get(theme.id)), && Boolean(themeDatasetMap[theme.id] || onDemandRasterProductMap.get(theme.id)),
) ?? DATA_THEMES.find((theme) => ) ?? DATA_THEMES.find((theme) =>
Boolean(themeDatasetMap[theme.id] || onDemandThematicProductMap.get(theme.id)), Boolean(themeDatasetMap[theme.id] || onDemandRasterProductMap.get(theme.id)),
) )
if (!fallbackTheme) { if (!fallbackTheme) {
return return
@@ -824,9 +906,9 @@ export function MapWorkspace({
activeThemeAvailable, activeThemeAvailable,
analysisMode, analysisMode,
flandersScopeSelected, flandersScopeSelected,
onDemandThematicProductMap, officialRasterProductsError,
onDemandRasterProductMap,
onOpenDatasetInMap, onOpenDatasetInMap,
thematicRasterProductsError,
themeDatasetMap, themeDatasetMap,
]) ])
@@ -917,12 +999,12 @@ export function MapWorkspace({
useEffect(() => { useEffect(() => {
const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null
: regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen' : regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen'
: activeOnDemandThematicProduct?.display_name ?? null : activeOnDemandRasterProduct?.displayName ?? null
onSetContextSourceLabel(contextSourceLabel) onSetContextSourceLabel(contextSourceLabel)
return () => onSetContextSourceLabel(null) return () => onSetContextSourceLabel(null)
}, [ }, [
activeTemporalSeriesGroup?.label, activeTemporalSeriesGroup?.label,
activeOnDemandThematicProduct?.display_name, activeOnDemandRasterProduct?.displayName,
analysisMode, analysisMode,
onSetContextSourceLabel, onSetContextSourceLabel,
regionalBathymetryThemeActive, regionalBathymetryThemeActive,
@@ -1021,14 +1103,33 @@ export function MapWorkspace({
}, [activeTemporalSeriesGroups]) }, [activeTemporalSeriesGroups])
useEffect(() => { useEffect(() => {
if (floodHazardDatasets.some((dataset) => dataset.id === selectedFloodHazardDatasetId)) { const selected = floodHazardDatasets.find(
(dataset) => datasetProductKey(dataset) === selectedFloodHazardProductKey,
)
if (selected) {
if (selected.id !== selectedFloodHazardDatasetId) {
setSelectedFloodHazardDatasetId(selected.id)
}
return
}
if (flandersScopeSelected && officialRasterProducts.floodHazard.length > 0) {
setSelectedFloodHazardDatasetId('')
return return
} }
const preferred = floodHazardDatasets.find( const preferred = floodHazardDatasets.find(
(dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100', (dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100',
) ?? floodHazardDatasets[0] ) ?? floodHazardDatasets[0]
setSelectedFloodHazardDatasetId(preferred?.id ?? '') setSelectedFloodHazardDatasetId(preferred?.id ?? '')
}, [floodHazardDatasets, selectedFloodHazardDatasetId]) if (preferred && datasetProductKey(preferred)) {
setSelectedFloodHazardProductKey(datasetProductKey(preferred))
}
}, [
flandersScopeSelected,
floodHazardDatasets,
officialRasterProducts.floodHazard.length,
selectedFloodHazardDatasetId,
selectedFloodHazardProductKey,
])
useEffect(() => { useEffect(() => {
const first = activeTemporalSeries[0] const first = activeTemporalSeries[0]
@@ -1049,6 +1150,17 @@ export function MapWorkspace({
if (!selectedMapDataset) { if (!selectedMapDataset) {
return 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) const matchingThemeId = themeIdForDataset(selectedMapDataset)
if (matchingThemeId) { if (matchingThemeId) {
setActiveThemeId(matchingThemeId) setActiveThemeId(matchingThemeId)
@@ -1238,7 +1350,7 @@ export function MapWorkspace({
? temporalGroup?.items[temporalGroup.items.length - 1] ?? null ? temporalGroup?.items[temporalGroup.items.length - 1] ?? null
: themeDatasetMap[theme.id] : themeDatasetMap[theme.id]
const onDemandProduct = analysisMode === 'current' const onDemandProduct = analysisMode === 'current'
? onDemandThematicProductMap.get(theme.id) ? onDemandRasterProductMap.get(theme.id)
: null : null
if (!dataset && !onDemandProduct) { if (!dataset && !onDemandProduct) {
return return
@@ -1268,13 +1380,17 @@ export function MapWorkspace({
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
const availableThemes: Array<MapThemeQuery<DataThemeId>> = [] const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) { for (const theme of DATA_THEMES) {
const thematicProduct = analysisMode === 'current' const onDemandProduct = analysisMode === 'current'
? onDemandThematicProductMap.get(theme.id) ? onDemandRasterProductMap.get(theme.id)
: null : null
if (thematicProduct) { if (onDemandProduct) {
availableThemes.push({ availableThemes.push({
themeId: theme.id, themeId: theme.id,
thematicProductKey: thematicProduct.key, acquisition: {
kind: onDemandProduct.kind,
productKey: onDemandProduct.productKey,
displayName: onDemandProduct.displayName,
},
}) })
continue continue
} }
@@ -1294,7 +1410,7 @@ export function MapWorkspace({
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
setSelectionBbox(bbox) setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)] const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
if (!regionalPartitionedThemeActive && !onDemandThematicThemeActive) { if (!regionalPartitionedThemeActive && !onDemandRasterThemeActive) {
tasks.push(onRunMapSelectionExtract(bbox, areaId)) tasks.push(onRunMapSelectionExtract(bbox, areaId))
} }
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
@@ -1455,7 +1571,7 @@ export function MapWorkspace({
<div className="geo-theme-list"> <div className="geo-theme-list">
{DATA_THEMES.map((theme) => { {DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id] const dataset = themeDatasetMap[theme.id]
const onDemandProduct = onDemandThematicProductMap.get(theme.id) const onDemandProduct = onDemandRasterProductMap.get(theme.id)
const partitions = themePartitionMap[theme.id] const partitions = themePartitionMap[theme.id]
const temporalGroups = themeTemporalSeriesMap[theme.id] const temporalGroups = themeTemporalSeriesMap[theme.id]
const temporalGroup = temporalGroups[0] const temporalGroup = temporalGroups[0]
@@ -1487,7 +1603,7 @@ export function MapWorkspace({
: dataset : dataset
? datasetAvailabilityLabel(dataset, partitions) ? datasetAvailabilityLabel(dataset, partitions)
: onDemandProduct : onDemandProduct
? `${onDemandProduct.native_resolution_m} m · ${onDemandProduct.observation_year} · laad bij selectie` ? `${onDemandProduct.nativeResolutionM} m · ${onDemandProduct.referenceLabel} · laad bij selectie`
: 'Bron nog niet ingeladen'} : 'Bron nog niet ingeladen'}
</small> </small>
</span> </span>
@@ -1512,7 +1628,7 @@ export function MapWorkspace({
? 'VHA-dwarsprofielen Vlaanderen' ? 'VHA-dwarsprofielen Vlaanderen'
: activeThemeDataset : activeThemeDataset
? getDatasetDisplayName(activeThemeDataset) ? getDatasetDisplayName(activeThemeDataset)
: activeOnDemandThematicProduct?.display_name ?? 'Geen databron beschikbaar'} : activeOnDemandRasterProduct?.displayName ?? 'Geen databron beschikbaar'}
</strong> </strong>
<small> <small>
{analysisOverlayActive {analysisOverlayActive
@@ -1525,19 +1641,71 @@ export function MapWorkspace({
? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd` ? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd`
: activeThemeDataset : activeThemeDataset
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}` ? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}`
: activeOnDemandThematicProduct : activeOnDemandRasterProduct
? `${activeOnDemandThematicProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen` ? `${activeOnDemandRasterProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen`
: activeTheme.description} : activeTheme.description}
</small> </small>
</div> </div>
{thematicRasterProductsLoading && flandersScopeSelected ? ( {officialRasterProductsLoading && flandersScopeSelected ? (
<p className="geo-data-notice">Beschikbare Vlaamse beleidsbronnen worden gecontroleerd</p> <p className="geo-data-notice">Beschikbare Vlaamse rasterbronnen worden gecontroleerd</p>
) : null} ) : null}
{thematicRasterProductsError && flandersScopeSelected ? ( {officialRasterProductsError && flandersScopeSelected ? (
<p className="error">{thematicRasterProductsError}</p> <p className="error">{officialRasterProductsError}</p>
) : null} ) : null}
{analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? ( {analysisMode === 'current' && activeTheme.id === 'elevation' && officialRasterProducts.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, regionalScopeSelected),
)
if (dataset) {
onOpenDatasetInMap(dataset)
}
}}
>
{officialRasterProducts.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' && officialRasterProducts.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)
}
}}
>
{officialRasterProducts.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"> <label className="geo-scope-select">
Overstromingsscenario Overstromingsscenario
<select <select
@@ -1633,7 +1801,7 @@ export function MapWorkspace({
? 'Sleep nu een rechthoek op de kaart.' ? 'Sleep nu een rechthoek op de kaart.'
: regionalRasterThemeActive : regionalRasterThemeActive
? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.' ? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.'
: onDemandThematicThemeActive : onDemandRasterThemeActive
? 'Teken een rechthoek; officiële Vlaamse rasters worden begrensd opgehaald, bewaard en hergebruikt.' ? 'Teken een rechthoek; officiële Vlaamse rasters worden begrensd opgehaald, bewaard en hergebruikt.'
: regionalBathymetryThemeActive : regionalBathymetryThemeActive
? 'Teken een rechthoek of analyseer Vlaanderen; alleen overlappende VHA-partities worden samengevoegd.' ? 'Teken een rechthoek of analyseer Vlaanderen; alleen overlappende VHA-partities worden samengevoegd.'
@@ -1652,16 +1820,16 @@ export function MapWorkspace({
</button> </button>
<button <button
className="secondary-action" className="secondary-action"
disabled={!activeThemeAvailable || regionalRasterThemeActive || regionalOnDemandThematicThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading} disabled={!activeThemeAvailable || regionalRasterThemeActive || regionalOnDemandRasterThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
type="button" type="button"
title={ title={
regionalRasterThemeActive || regionalOnDemandThematicThemeActive regionalRasterThemeActive || regionalOnDemandRasterThemeActive
? 'Teken een begrensde rechthoek voor een regionale rasteranalyse.' ? 'Teken een begrensde rechthoek voor een regionale rasteranalyse.'
: undefined : undefined
} }
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)} onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
> >
{regionalRasterThemeActive || regionalOnDemandThematicThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'} {regionalRasterThemeActive || regionalOnDemandRasterThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'}
</button> </button>
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}> <button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
Wis selectie Wis selectie
@@ -2030,7 +2198,7 @@ export function MapWorkspace({
? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities` ? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities`
: activeThemeDataset : activeThemeDataset
? getDatasetDisplayName(activeThemeDataset) ? getDatasetDisplayName(activeThemeDataset)
: activeOnDemandThematicProduct?.display_name : activeOnDemandRasterProduct?.displayName
?? 'niet beschikbaar'} ?? 'niet beschikbaar'}
</span> </span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null} {usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
@@ -6,18 +6,26 @@ import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection' import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster' import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
export type MapThemeAcquisitionKind = 'thematic_raster' | 'dhmv' | 'flood_hazard'
export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind
productKey: string
displayName: string
}
export interface MapThemeQuery<TThemeId extends string> { export interface MapThemeQuery<TThemeId extends string> {
themeId: TThemeId themeId: TThemeId
dataset?: DatasetCreateResponse dataset?: DatasetCreateResponse
partitioned?: boolean partitioned?: boolean
thematicProductKey?: string acquisition?: MapThemeAcquisition
} }
export interface MapThemeInsight<TThemeId extends string> { export interface MapThemeInsight<TThemeId extends string> {
themeId: TThemeId themeId: TThemeId
dataset: DatasetCreateResponse dataset: DatasetCreateResponse
partitioned?: boolean partitioned?: boolean
thematicProductKey?: string acquisition?: MapThemeAcquisition
result: VectorSelectionResponse result: VectorSelectionResponse
} }
@@ -61,22 +69,36 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
setThemeInsightsError(null) setThemeInsightsError(null)
try { try {
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
queries.map(async ({ themeId, dataset: existingDataset, partitioned, thematicProductKey }) => { queries.map(async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => {
let dataset = existingDataset let dataset = existingDataset
if (thematicProductKey) { if (acquisition) {
const acquisition = await datasetsApi.acquireThematicRaster(selectedProjectId, { const acquisitionJob = acquisition.kind === 'thematic_raster'
bbox, ? await datasetsApi.acquireThematicRaster(selectedProjectId, {
area_id: areaId, bbox,
product_key: thematicProductKey, area_id: areaId,
force_refresh: false, product_key: acquisition.productKey,
}) force_refresh: false,
if (acquisition.status !== 'success' || !acquisition.output_dataset_id) { })
: acquisition.kind === 'dhmv'
? await datasetsApi.acquireDhmv(selectedProjectId, {
bbox,
area_id: areaId,
product_key: acquisition.productKey as 'dtm_1m' | 'dsm_1m',
force_refresh: false,
})
: await datasetsApi.acquireFloodHazard(selectedProjectId, {
bbox,
area_id: areaId,
product_key: acquisition.productKey,
force_refresh: false,
})
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error( throw new Error(
acquisition.error_message acquisitionJob.error_message
|| `De officiële rasterbron ${thematicProductKey} kon niet worden ingeladen.`, || `De officiële rasterbron ${acquisition.displayName} kon niet worden ingeladen.`,
) )
} }
dataset = await datasetsApi.get(selectedProjectId, acquisition.output_dataset_id) dataset = await datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)
} }
if (!dataset) { if (!dataset) {
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`) throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
@@ -85,7 +107,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
themeId, themeId,
dataset, dataset,
partitioned, partitioned,
thematicProductKey, acquisition,
result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv' result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
? terrainSelectionToMapSelection( ? terrainSelectionToMapSelection(
partitioned partitioned
@@ -137,7 +159,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
? [{ ? [{
dataset: dataset:
queries[index]?.dataset?.name queries[index]?.dataset?.name
?? queries[index]?.thematicProductKey ?? queries[index]?.acquisition?.displayName
?? queries[index]?.themeId ?? queries[index]?.themeId
?? 'Onbekende bron', ?? 'Onbekende bron',
reason: formatError(item.reason, 'Bron kon niet worden bevraagd.'), reason: formatError(item.reason, 'Bron kon niet worden bevraagd.'),
@@ -150,7 +172,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
} }
setThemeInsights(successful) setThemeInsights(successful)
let refreshFailure: string | null = null let refreshFailure: string | null = null
if (successful.some((item) => item.thematicProductKey) && onDatasetsChanged) { if (successful.some((item) => item.acquisition) && onDatasetsChanged) {
try { try {
await onDatasetsChanged() await onDatasetsChanged()
} catch (error) { } catch (error) {
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react'
import { datasetsApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type {
DhmvProductRead,
FloodHazardProductRead,
ThematicRasterProductRead,
} from '../types'
interface OfficialRasterProducts {
thematic: ThematicRasterProductRead[]
dhmv: DhmvProductRead[]
floodHazard: FloodHazardProductRead[]
}
const EMPTY_PRODUCTS: OfficialRasterProducts = {
thematic: [],
dhmv: [],
floodHazard: [],
}
export function useOfficialRasterProducts(selectedProjectId: string | null) {
const [products, setProducts] = useState<OfficialRasterProducts>(EMPTY_PRODUCTS)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
if (!selectedProjectId) {
setProducts(EMPTY_PRODUCTS)
setLoading(false)
setError(null)
return () => {
cancelled = true
}
}
setLoading(true)
setError(null)
void Promise.all([
datasetsApi.listThematicRasterProducts(selectedProjectId),
datasetsApi.listDhmvProducts(selectedProjectId),
datasetsApi.listFloodHazardProducts(selectedProjectId),
])
.then(([thematic, dhmv, floodHazard]) => {
if (!cancelled) {
setProducts({
thematic: thematic.items,
dhmv: dhmv.items,
floodHazard: floodHazard.items,
})
}
})
.catch((requestError) => {
if (!cancelled) {
setProducts(EMPTY_PRODUCTS)
setError(formatError(requestError, 'De officiële Vlaamse rastercatalogi konden niet worden geladen.'))
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [selectedProjectId])
return { products, loading, error }
}
@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react'
import { datasetsApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type { ThematicRasterProductRead } from '../types'
export function useThematicRasterProducts(selectedProjectId: string | null) {
const [products, setProducts] = useState<ThematicRasterProductRead[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
if (!selectedProjectId) {
setProducts([])
setLoading(false)
setError(null)
return () => {
cancelled = true
}
}
setLoading(true)
setError(null)
void datasetsApi.listThematicRasterProducts(selectedProjectId)
.then((response) => {
if (!cancelled) {
setProducts(response.items)
}
})
.catch((requestError) => {
if (!cancelled) {
setProducts([])
setError(formatError(requestError, 'De Vlaamse beleidsrasters konden niet worden geladen.'))
}
})
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [selectedProjectId])
return { products, loading, error }
}