Add bounded Flanders thematic 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 18:28:48 +02:00
parent 21103441cf
commit ce597ee0b4
17 changed files with 389 additions and 55 deletions
+16
View File
@@ -7,6 +7,22 @@
# Changelog
## Sprint 237 Bounded Flanders cross-domain profile (2026-07-17)
- Exposed the five existing governed MercatorNet policy rasters as
`Op aanvraag` in the Flanders map workbench without adding a new provider or
browser-side external fetch path.
- Made one explicit municipality or rectangle selection acquire/reuse,
persist and analyse space occupation, open space, population density, node
value and service level through the canonical Job and Dataset services.
- Kept whole-Flanders raster acquisition disabled behind the existing 60 km
and 30 million cell guardrails while preserving complete-region vector and
partitioned bathymetry analysis.
- Corrected thematic Dataset metadata so a regional Area is no longer
mislabelled as municipality coverage merely because an `area_id` was used.
- Added source-contract and metadata regression coverage for the complete
on-demand flow.
## Sprint 236 Flemish bathymetry partitions and safe North Sea probe (2026-07-17)
- Added dynamic provisioning of the complete official VRBG Flemish
+9
View File
@@ -1508,6 +1508,15 @@ Kempen work area fits. External WCS transfers remain split into fixed 10 km
tiles, product identifiers remain server-allowlisted and other raster
pipelines retain their smaller independent limits.
The Flanders browser workflow uses these same endpoints on demand. It never
performs a startup import or direct browser WCS request: an explicit
municipality or drawn rectangle starts five bounded acquisitions, followed by
the existing persisted-raster analyses. Exact request hashes reuse ready
Datasets. A full-Flanders raster request remains blocked by the same 60 km and
30 million cell limits. Dataset metadata labels only Areas named
`Gemeente ...` as `coverage_scope=municipality`; regional Area clipping is
stored as `bounded_selection`.
Provision the official DOV soil polygons for Mol through the existing vector
upload path:
@@ -304,6 +304,15 @@ class ThematicRasterAcquisitionService:
raise AppError(code="THEMATIC_RASTER_SELECTION_OUTSIDE_AREA", message="Selection does not overlap the selected work area", status_code=422)
return intersection
@staticmethod
def _coverage_scope(db, area_id: UUID | None) -> str:
if area_id is None:
return "bounded_selection"
area = db.get(Area, area_id)
if area and str(area.name).casefold().startswith("gemeente "):
return "municipality"
return "bounded_selection"
@staticmethod
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
request = Request(request_url, headers={"Accept": "image/tiff,*/*", "User-Agent": "GeoIntel/0.1 bounded-thematic-raster"})
@@ -568,7 +577,7 @@ class ThematicRasterAcquisitionService:
"license_note": ThematicRasterAcquisitionService.LICENSE_NOTE,
"legend_min_label": product.legend_min_label,
"legend_max_label": product.legend_max_label,
"coverage_scope": "municipality" if payload.area_id else "bounded_selection",
"coverage_scope": ThematicRasterAcquisitionService._coverage_scope(db, payload.area_id),
},
provenance_metadata={
"acquisition": "explicit_bounded_tiled_wcs_coverage",
@@ -21,7 +21,9 @@ def test_evolution_mode_falls_back_to_an_available_series() -> None:
def test_evolution_theme_catalog_distinguishes_history_from_current_only_data() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "analysisMode === 'current' || evolutionAvailable" in workspace
assert "analysisMode === 'current'" in workspace
assert "Boolean(dataset || onDemandProduct)" in workspace
assert "Boolean(dataset) && evolutionAvailable" in workspace
assert "meetmomenten" in workspace
assert "Tijdreeks" in workspace
assert "Alleen huidige toestand" in workspace
@@ -17,7 +17,7 @@ from app.core.config import Settings
from app.core.errors import AppError
from app.db.session import get_db
from app.main import app
from app.models import Dataset, Job, Project
from app.models import Area, Dataset, Job, Project
from app.schemas.thematic_raster import ThematicRasterAcquireRequest, ThematicRasterSelectionRequest
from app.schemas.assistant import AssistantQueryRequest
from app.services.geo_assistant_service import GeoAssistantService
@@ -177,6 +177,20 @@ def test_complete_kempen_scope_fits_the_tiled_thematic_guardrails() -> None:
assert exc_info.value.code == "THEMATIC_RASTER_SELECTION_TOO_LARGE"
def test_coverage_scope_only_labels_named_municipality_areas_as_municipality() -> None:
project_id = uuid4()
municipality_id = uuid4()
region_id = uuid4()
db = FakeSession({
(Area, municipality_id): Area(id=municipality_id, project_id=project_id, name="Gemeente Mol"),
(Area, region_id): Area(id=region_id, project_id=project_id, name="Vlaanderen"),
})
assert ThematicRasterAcquisitionService._coverage_scope(db, municipality_id) == "municipality"
assert ThematicRasterAcquisitionService._coverage_scope(db, region_id) == "bounded_selection"
assert ThematicRasterAcquisitionService._coverage_scope(db, None) == "bounded_selection"
def test_wcs_fetch_retries_an_incomplete_tile_without_accepting_partial_bytes(monkeypatch) -> None:
content = b"II*\x00complete-geotiff"
responses = [IncompleteResponse(content), FakeResponse(content)]
@@ -339,7 +339,8 @@ def test_theme_failures_name_the_source_and_reason() -> None:
root = Path(__file__).resolve().parents[2]
hook = (root / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
assert "dataset: queries[index]?.dataset.name" in hook
assert "queries[index]?.dataset?.name" in hook
assert "queries[index]?.thematicProductKey" in hook
assert "reason: formatError(item.reason" in hook
assert "failure.dataset}: ${failure.reason}" in hook
@@ -0,0 +1,59 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
product_hook = read("frontend/src/hooks/useThematicRasterProducts.ts")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
api = read("frontend/src/services/api/datasets.ts")
assert "activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME" in workspace
assert "new Map<DataThemeId, ThematicRasterProductRead>" in workspace
assert "'Op aanvraag'" in workspace
assert "thematicProductKey: thematicProduct.key" in workspace
assert "datasetsApi.listThematicRasterProducts" in product_hook
assert "datasetsApi.acquireThematicRaster" in selection_hook
assert "datasetsApi.selectThematicRaster" in selection_hook
assert "datasetsApi.get(selectedProjectId, acquisition.output_dataset_id)" in selection_hook
assert "/datasets/thematic-raster/acquire" in api
def test_selection_runs_all_available_themes_and_refreshes_persisted_datasets() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
app = read("frontend/src/App.tsx")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
assert "for (const theme of DATA_THEMES)" in workspace
assert "await loadThemeInsights(bbox, availableThemes, areaId)" in workspace
assert "!regionalPartitionedThemeActive && !onDemandThematicThemeActive" in workspace
assert "onRefreshProjectData" in workspace
assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app
assert "successful.some((item) => item.thematicProductKey)" in selection_hook
assert "await onDatasetsChanged()" in selection_hook
def test_regional_on_demand_rasters_require_a_bounded_drawn_selection() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
assert "regionalOnDemandThematicThemeActive" in workspace
assert "regionalRasterThemeActive || regionalOnDemandThematicThemeActive" 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
def test_frontend_does_not_contact_the_external_wcs_directly() -> None:
frontend_sources = "\n".join(
path.read_text(encoding="utf-8")
for path in (ROOT / "frontend/src").rglob("*")
if path.suffix in {".ts", ".tsx"}
)
assert "mercatornet.be" not in frontend_sources.casefold()
assert "GetCoverage" not in frontend_sources
+10
View File
@@ -384,6 +384,16 @@ WCS 1.0 tiles, validates EPSG:31370 and documented source values, masks the
exact Area and persists an ordinary raster Dataset and DatasetVersion. It does
not accept arbitrary URLs, coverage ids, resolutions or expressions.
The Flanders map workbench orchestrates this existing endpoint only after an
explicit municipality or rectangle selection. It requests all five governed
products for the same `bbox ∩ Area`, reuses the checksum-bound Dataset for an
identical request and then calls the normal selection endpoint. The browser
never contacts WCS directly. A whole-Flanders raster request is intentionally
unavailable in the map flow because it exceeds the bounded 60 km/30 million
cell guardrail; users must choose a municipality or draw a smaller rectangle.
An Area is recorded as `coverage_scope=municipality` only when its canonical
name starts with `Gemeente `; a regional Area remains `bounded_selection`.
### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/thematic/select`
Returns source-correct metrics for a bbox and optional exact Area mask:
+28
View File
@@ -10154,3 +10154,31 @@ Validation:
display limit. A final shell follow-up replaces the representative
municipality count with regional profile/partition totals and uses the
end-user label `Vlaanderen (285 gemeenten)`.
## Sprint 237 - Bounded Flanders cross-domain profile (2026-07-17)
Implemented:
- Reused the governed MercatorNet registry and canonical acquisition,
Dataset, analysis and image endpoints instead of introducing a second
provider path.
- Added a Flanders-only `Op aanvraag` state for ruimtebeslag 2025, open ruimte
2022, inwonersdichtheid 2019, knooppuntwaarde 2022 and
voorzieningenniveau 2022.
- Made one explicit map rectangle or municipality analysis acquire/reuse all
five products for the same `bbox ∩ Area`, refresh the Dataset catalog and
keep the measured results available for switching, export and AI context.
- Kept the whole Flanders Area disabled for policy-raster acquisition under
the existing 60 km/30 million cell safety limits. Full-region vectors and
manifest-aware VHA analysis are unchanged.
- Corrected `coverage_scope` so only canonically named municipality Areas are
labelled municipality coverage; regional clipping remains a bounded
selection.
Validation:
- A pre-implementation live probe acquired and persisted a real 143 x 224
ruimtebeslag raster for a bounded Mol rectangle through Tower. The canonical
analysis returned 275.4 ha, 88.4563 percent and 311.34 ha valid raster area
with complete selection coverage.
- The complete local release gate passed 932 backend tests, backend
compilation, the 116-route contract audit, Alembic head `202607160001`,
frontend TypeScript typecheck and the production Vite build.
+14 -5
View File
@@ -73,6 +73,14 @@ publicly accessible land. Population is a 2019 raster estimate, not a current
register count. Accessibility and service scores are not live travel times or
object counts.
In `Flanders Regional Workbench` these five products are shown as
`Op aanvraag` until a user chooses a municipality or draws a bounded
rectangle. One selection acquires, persists and analyses the five products
through the canonical backend. Repeating the exact same product, bounds and
Area reuses the retained source evidence. GeoIntel does not preload 1,425
municipality/product combinations and does not permit one unbounded
whole-Flanders WCS transfer.
## DOV digital soil map
- Service: `https://www.dov.vlaanderen.be/geoserver/wfs`
@@ -704,11 +712,12 @@ provider output continues to use DatasetService and, for vectors,
VectorFeatureService. Arbitrary service URLs, browser-side fetches and startup
downloads remain forbidden.
The recommended next connector is a fixed allowlist of public Mercator
thematic rasters for space occupation, open space, population density, node
value and service level. The digital soil map should follow the existing
canonical vector persistence path. Watercourse/runoff additions remain in the
roadmap but no longer precede these cross-domain gaps.
The fixed allowlist of public Mercator thematic rasters for space occupation,
open space, population density, node value and service level is operational
for bounded Mol, Kempen and Flanders selections. The digital soil map follows
the canonical vector persistence path for Mol and Kempen. Additional Flemish
vector themes still require governed regional operators; a catalogue record
alone never makes them operational.
## Bathymetry, inland profiles and maritime scope
+3
View File
@@ -43,6 +43,9 @@ geen open productroadmap meer.
kaart-, kwaliteits-, detectie- en segmentatiewerkstromen.
- [x] Begrens de grootste frontendverantwoordelijkheden met afzonderlijke
overzichts-, modelbeheer- en kaarthelpermodules zonder gedrag te wijzigen.
- [x] Maak de vijf bestaande officiële beleidsrasters veilig op aanvraag
beschikbaar voor elke begrensde selectie in Vlaanderen, met Dataset-cache,
semantische metrics en zonder 1.425 vooraf geladen rasters.
Bewuste, niet-blokkerende grenzen:
+16 -1
View File
@@ -71,6 +71,15 @@ but is not required for the primary choose-theme, draw-area, read-result flow.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The active `area_id` constrains every drawn/manual selection to `bbox ∩ Area`; `Volledig werkgebied` uses a bbox enclosing the Area and therefore resolves to the exact persisted geometry. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
In the Flanders workbench, ruimtebeslag, open ruimte, population density,
node value and service level may appear as `Op aanvraag`. Drawing a rectangle
or choosing a municipality uses the existing backend Job/Dataset flow to
acquire and persist those five official rasters for the exact selection, then
shows all available semantic metrics together. Identical requests reuse the
persisted artifact. The complete Flanders Area is deliberately unavailable for
these rasters because it exceeds the backend safety ceiling; this does not
limit vector or partitioned bathymetry analysis of the complete region.
Detection Lab only lists ready imagery rasters. Governed height, flood-hazard
and thematic policy rasters remain available in the map explorer but are
excluded from the `Luchtbeeld` selector.
@@ -92,7 +101,13 @@ local YOLO model, Detection persistence and automatic QA against ready GRB
buildings. The panel shows all stages and errors; successful detections open as
an explicit AI-result overlay. Rectangles must be 128-1,024 m per side.
The theme catalog currently recognizes buildings, population, forest/green, water, roads and parcels from dataset names and canonical `reference_layer_name` metadata. A theme is enabled only when a ready persisted vector dataset exists; otherwise it states `Bron nog niet ingeladen`. This prevents missing population or land-cover sources from appearing as zero-valued observations. The previous technical Map workspace remains available through `Geavanceerde werkbank` for derived datasets, QA/QC evidence and export operations.
The theme catalog recognizes the governed vector, raster and partitioned
sources through canonical source metadata. A theme is enabled only when a
ready persisted Dataset exists, except for the five allowlisted Flanders
policy rasters that explicitly show `Op aanvraag`. Other missing themes state
`Bron nog niet ingeladen`; they never appear as zero-valued observations. The
technical Map workspace remains available through `Geavanceerde werkbank` for
derived datasets, QA/QC evidence and export operations.
The workbench uses a task-based shell instead of a single long panel stack. `App.tsx` still owns shared orchestration state, but Map is the default product entry and Overview, Data, QA/QC, AI Labs, Exports and System remain secondary workspaces with a persistent top context bar and an optional selection-detail drawer.
+3
View File
@@ -972,6 +972,9 @@ function App(): JSX.Element {
onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run}
onSelectOrthophotoProduct={mapOrthophotoAnalysis.setSelectedProductKey}
onClearQualityEvidence={clearQualityEvidenceGeoJson}
onRefreshProjectData={() => (
selectedProjectId ? loadProjectData(selectedProjectId) : Promise.resolve(null)
)}
onOpenAssistant={() => setActiveWorkspace('assistant')}
onOpenExports={() => setActiveWorkspace('exports')}
/>
+100 -37
View File
@@ -1,13 +1,15 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, ThematicRasterProductRead, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
import { useThematicRasterProducts } from '../../hooks/useThematicRasterProducts'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart'
import { terrainImageUrl } from '../../lib/terrainImage'
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
import { thematicRasterImageUrl } from '../../lib/thematicRaster'
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
import {
bboxToInputState,
bboxesEqual,
@@ -548,6 +550,7 @@ interface MapWorkspaceProps {
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onSelectOrthophotoProduct: (productKey: string) => void
onClearQualityEvidence?: () => void
onRefreshProjectData: () => Promise<unknown>
onOpenAssistant: () => void
onOpenExports: () => void
}
@@ -632,6 +635,7 @@ export function MapWorkspace({
onRunOrthophotoAnalysis,
onSelectOrthophotoProduct,
onClearQualityEvidence,
onRefreshProjectData,
onOpenAssistant,
onOpenExports,
}: MapWorkspaceProps): JSX.Element {
@@ -640,13 +644,21 @@ export function MapWorkspace({
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
return themeIdForDataset(selectedDataset) ?? 'buildings'
})
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const flandersScopeSelected = activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME
const {
themeInsights,
themeInsightsLoading: themeResultsLoading,
themeInsightsError: themeResultsError,
loadThemeInsights,
clearThemeInsights,
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId)
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId, onRefreshProjectData)
const {
products: thematicRasterProducts,
loading: thematicRasterProductsLoading,
error: thematicRasterProductsError,
} = useThematicRasterProducts(flandersScopeSelected ? selectedProjectId : null)
const {
temporalComparison,
temporalComparisonLoading,
@@ -747,6 +759,15 @@ export function MapWorkspace({
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap],
)
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const onDemandThematicProductMap = useMemo(
() => flandersScopeSelected
? new Map<DataThemeId, ThematicRasterProductRead>(
thematicRasterProducts.map((product) => [product.theme, product]),
)
: new Map<DataThemeId, ThematicRasterProductRead>(),
[flandersScopeSelected, thematicRasterProducts],
)
const activeOnDemandThematicProduct = onDemandThematicProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
@@ -766,6 +787,9 @@ export function MapWorkspace({
const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset)
const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset)
const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive
const onDemandThematicThemeActive = analysisMode === 'current' && Boolean(activeOnDemandThematicProduct)
const regionalOnDemandThematicThemeActive = regionalScopeSelected && onDemandThematicThemeActive
const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThematicThemeActive
const terrainImageOverlays = useMemo(
() =>
activeTheme.id === 'elevation' && selectedProjectId
@@ -830,8 +854,6 @@ export function MapWorkspace({
: orthophotoImageOverlay ? [orthophotoImageOverlay] : [],
[floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays],
)
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
const themeTemporalSeriesMap = useMemo(
() =>
@@ -855,11 +877,12 @@ export function MapWorkspace({
useEffect(() => {
const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null
: regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen'
: null
: activeOnDemandThematicProduct?.display_name ?? null
onSetContextSourceLabel(contextSourceLabel)
return () => onSetContextSourceLabel(null)
}, [
activeTemporalSeriesGroup?.label,
activeOnDemandThematicProduct?.display_name,
analysisMode,
onSetContextSourceLabel,
regionalBathymetryThemeActive,
@@ -873,7 +896,9 @@ export function MapWorkspace({
}),
[themeInsights],
)
const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result
const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId)
const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset
const activeSelectionResult = activeThemeInsight?.result
?? (!regionalPartitionedThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null)
const explorerMapFeatureCollection = regionalBathymetryThemeActive
? null
@@ -1066,15 +1091,15 @@ export function MapWorkspace({
if (!activeSelectionResult) {
return
}
if (activeThemeDataset?.dataset_type === 'raster') {
if (activeResultDataset?.dataset_type === 'raster') {
downloadJsonFile(`${activeTheme.id}-analysis.json`, {
project_id: selectedProjectId,
area_id: areaIdForSelection(mapSelectionBbox) ?? null,
area_name: selectedMapArea?.name ?? null,
theme: activeTheme,
dataset_id: activeThemeDataset.id,
dataset_name: activeThemeDataset.name,
source_name: activeThemeDataset.source_name,
dataset_id: activeResultDataset.id,
dataset_name: activeResultDataset.name,
source_name: activeResultDataset.source_name,
result: activeSelectionResult,
})
return
@@ -1116,19 +1141,19 @@ export function MapWorkspace({
name: `${activeTheme.id}-evolution`,
}
: null
: activeSelectionResult && activeThemeDataset
: activeSelectionResult && activeResultDataset
? {
project_id: selectedProjectId,
mode: 'current',
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
dataset_id: activeThemeDataset.id,
dataset_id: activeResultDataset.id,
area_id: areaId,
partitioned: regionalPartitionedThemeActive,
product_key: regionalRasterThemeActive
? String(activeThemeDataset.source_metadata?.['product_key'] ?? '') || undefined
? String(activeResultDataset.source_metadata?.['product_key'] ?? '') || undefined
: undefined,
partition_scope_key: regionalBathymetryThemeActive
? String(activeThemeDataset.source_metadata?.['partition_scope_key'] ?? 'flanders')
? String(activeResultDataset.source_metadata?.['partition_scope_key'] ?? 'flanders')
: undefined,
theme_id: activeTheme.id,
name: `${activeTheme.id}-analysis`,
@@ -1172,12 +1197,17 @@ export function MapWorkspace({
const dataset = analysisMode === 'evolution'
? temporalGroup?.items[temporalGroup.items.length - 1] ?? null
: themeDatasetMap[theme.id]
if (!dataset) {
const onDemandProduct = analysisMode === 'current'
? onDemandThematicProductMap.get(theme.id)
: null
if (!dataset && !onDemandProduct) {
return
}
setActiveThemeId(theme.id)
clearTemporalComparison()
onOpenDatasetInMap(dataset)
if (dataset) {
onOpenDatasetInMap(dataset)
}
}
const setExplorerMode = (mode: 'current' | 'evolution') => {
@@ -1196,24 +1226,35 @@ export function MapWorkspace({
}
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
const availableThemes = DATA_THEMES.flatMap((theme) => {
const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) {
const thematicProduct = analysisMode === 'current'
? onDemandThematicProductMap.get(theme.id)
: null
if (thematicProduct) {
availableThemes.push({
themeId: theme.id,
thematicProductKey: thematicProduct.key,
})
continue
}
const dataset = themeDatasetMap[theme.id]
return dataset
? [{
themeId: theme.id,
dataset,
partitioned: regionalScopeSelected
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
}]
: []
})
if (dataset) {
availableThemes.push({
themeId: theme.id,
dataset,
partitioned: regionalScopeSelected
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
})
}
}
await loadThemeInsights(bbox, availableThemes, areaId)
}
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
if (!regionalPartitionedThemeActive) {
if (!regionalPartitionedThemeActive && !onDemandThematicThemeActive) {
tasks.push(onRunMapSelectionExtract(bbox, areaId))
}
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
@@ -1374,11 +1415,14 @@ export function MapWorkspace({
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const onDemandProduct = onDemandThematicProductMap.get(theme.id)
const partitions = themePartitionMap[theme.id]
const temporalGroups = themeTemporalSeriesMap[theme.id]
const temporalGroup = temporalGroups[0]
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
const available = Boolean(dataset) && (analysisMode === 'current' || evolutionAvailable)
const available = analysisMode === 'current'
? Boolean(dataset || onDemandProduct)
: Boolean(dataset) && evolutionAvailable
const active = activeThemeId === theme.id
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
return (
@@ -1402,13 +1446,15 @@ export function MapWorkspace({
: 'Bron nog niet ingeladen'
: dataset
? datasetAvailabilityLabel(dataset, partitions)
: onDemandProduct
? `${onDemandProduct.native_resolution_m} m · ${onDemandProduct.observation_year} · laad bij selectie`
: 'Bron nog niet ingeladen'}
</small>
</span>
<i>
{analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : 'Ontbreekt'}
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Op aanvraag' : 'Ontbreekt'}
</i>
</button>
)
@@ -1424,7 +1470,9 @@ export function MapWorkspace({
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
: regionalBathymetryThemeActive
? 'VHA-dwarsprofielen Vlaanderen'
: activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'}
: activeThemeDataset
? getDatasetDisplayName(activeThemeDataset)
: activeOnDemandThematicProduct?.display_name ?? 'Geen databron beschikbaar'}
</strong>
<small>
{analysisOverlayActive
@@ -1437,9 +1485,17 @@ export function MapWorkspace({
? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd`
: activeThemeDataset
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}`
: activeOnDemandThematicProduct
? `${activeOnDemandThematicProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen`
: activeTheme.description}
</small>
</div>
{thematicRasterProductsLoading && flandersScopeSelected ? (
<p className="geo-data-notice">Beschikbare Vlaamse beleidsbronnen worden gecontroleerd</p>
) : null}
{thematicRasterProductsError && flandersScopeSelected ? (
<p className="error">{thematicRasterProductsError}</p>
) : null}
{analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? (
<label className="geo-scope-select">
@@ -1537,6 +1593,8 @@ export function MapWorkspace({
? 'Sleep nu een rechthoek op de kaart.'
: regionalRasterThemeActive
? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.'
: onDemandThematicThemeActive
? 'Teken een rechthoek; officiële Vlaamse rasters worden begrensd opgehaald, bewaard en hergebruikt.'
: regionalBathymetryThemeActive
? 'Teken een rechthoek of analyseer Vlaanderen; alleen overlappende VHA-partities worden samengevoegd.'
: 'Sleep een rechthoek of analyseer het volledige werkgebied.'}
@@ -1546,7 +1604,7 @@ export function MapWorkspace({
<div className="geo-map-actions">
<button
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || mapSelectionLoading || themeResultsLoading}
disabled={!activeThemeAvailable || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={startBboxSelection}
>
@@ -1554,12 +1612,16 @@ export function MapWorkspace({
</button>
<button
className="secondary-action"
disabled={!activeThemeDataset || regionalRasterThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
disabled={!activeThemeAvailable || regionalRasterThemeActive || regionalOnDemandThematicThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
type="button"
title={regionalRasterThemeActive ? 'Teken een begrensde rechthoek voor een regionale rasteranalyse.' : undefined}
title={
regionalRasterThemeActive || regionalOnDemandThematicThemeActive
? 'Teken een begrensde rechthoek voor een regionale rasteranalyse.'
: undefined
}
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
>
{regionalRasterThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'}
{regionalRasterThemeActive || regionalOnDemandThematicThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'}
</button>
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
Wis selectie
@@ -1881,7 +1943,7 @@ export function MapWorkspace({
{analysisMode === 'current' ? (
<div className="geo-result-actions">
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeResult}>
{activeThemeDataset?.dataset_type === 'raster' ? 'Download analyse' : 'Download GeoJSON'}
{activeResultDataset?.dataset_type === 'raster' ? 'Download analyse' : 'Download GeoJSON'}
</button>
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeResult}>Kopieer gegevens</button>
</div>
@@ -1928,7 +1990,8 @@ export function MapWorkspace({
? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities`
: activeThemeDataset
? getDatasetDisplayName(activeThemeDataset)
: 'niet beschikbaar'}
: activeOnDemandThematicProduct?.display_name
?? 'niet beschikbaar'}
</span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
</footer>
@@ -8,16 +8,22 @@ import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
export interface MapThemeQuery<TThemeId extends string> {
themeId: TThemeId
dataset: DatasetCreateResponse
dataset?: DatasetCreateResponse
partitioned?: boolean
thematicProductKey?: string
}
export interface MapThemeInsight<TThemeId extends string> extends MapThemeQuery<TThemeId> {
export interface MapThemeInsight<TThemeId extends string> {
themeId: TThemeId
dataset: DatasetCreateResponse
partitioned?: boolean
thematicProductKey?: string
result: VectorSelectionResponse
}
export function useMapThemeSelectionInsights<TThemeId extends string>(
selectedProjectId: string | null,
onDatasetsChanged?: () => Promise<unknown>,
) {
const [themeInsights, setThemeInsights] = useState<Array<MapThemeInsight<TThemeId>>>([])
const [themeInsightsLoading, setThemeInsightsLoading] = useState(false)
@@ -55,10 +61,32 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
setThemeInsightsError(null)
try {
const settled = await Promise.allSettled(
queries.map(async ({ themeId, dataset, partitioned }) => ({
themeId,
dataset,
result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
queries.map(async ({ themeId, dataset: existingDataset, partitioned, thematicProductKey }) => {
let dataset = existingDataset
if (thematicProductKey) {
const acquisition = await datasetsApi.acquireThematicRaster(selectedProjectId, {
bbox,
area_id: areaId,
product_key: thematicProductKey,
force_refresh: false,
})
if (acquisition.status !== 'success' || !acquisition.output_dataset_id) {
throw new Error(
acquisition.error_message
|| `De officiële rasterbron ${thematicProductKey} kon niet worden ingeladen.`,
)
}
dataset = await datasetsApi.get(selectedProjectId, acquisition.output_dataset_id)
}
if (!dataset) {
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
}
return {
themeId,
dataset,
partitioned,
thematicProductKey,
result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
? terrainSelectionToMapSelection(
partitioned
? await datasetsApi.selectTerrainPartitions(selectedProjectId, {
@@ -100,13 +128,18 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
area_id: areaId,
limit: 1000,
}),
})),
}
}),
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
const failures = settled.flatMap((item, index) => (
item.status === 'rejected'
? [{
dataset: queries[index]?.dataset.name ?? queries[index]?.themeId ?? 'Onbekende bron',
dataset:
queries[index]?.dataset?.name
?? queries[index]?.thematicProductKey
?? queries[index]?.themeId
?? 'Onbekende bron',
reason: formatError(item.reason, 'Bron kon niet worden bevraagd.'),
}]
: []
@@ -116,6 +149,14 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
return []
}
setThemeInsights(successful)
let refreshFailure: string | null = null
if (successful.some((item) => item.thematicProductKey) && onDatasetsChanged) {
try {
await onDatasetsChanged()
} catch (error) {
refreshFailure = formatError(error, 'De datasetlijst kon na de analyse niet worden vernieuwd.')
}
}
if (failureCount > 0) {
const details = failures
.slice(0, 4)
@@ -125,6 +166,8 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
setThemeInsightsError(
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd. ${details}${remainder}`,
)
} else if (refreshFailure) {
setThemeInsightsError(refreshFailure)
}
return successful
} catch (error) {
@@ -0,0 +1,48 @@
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 }
}
+2
View File
@@ -64,6 +64,8 @@ async function listProjectDatasets(projectId: string): Promise<DatasetListRespon
export const datasetsApi = {
list: listProjectDatasets,
get: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
apiGet<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}`),
sourceFreshness: (projectId: string): Promise<SourceFreshnessReport> =>
apiGet<SourceFreshnessReport>(`/api/v1/projects/${projectId}/datasets/source-freshness`),
sourceCatalogProbes: (projectId: string, refresh = false): Promise<SourceCatalogProbeReport> =>