diff --git a/CHANGELOG.md b/CHANGELOG.md
index dca03ba0..f7a740da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,17 @@
## 1.0.0 - Final Belgium and Belgian North Sea release (2026-07-19)
+- Fixed rectangle analysis so it materializes and reads all applicable
+ official sources instead of acquiring only the active theme and labelling
+ every other result `Bron ontbreekt`. Provider work is bounded to three
+ concurrent requests, persisted artifacts remain reusable and real failures
+ are shown as load failures.
+- Registered VMM VHA historical profile points as the operational bounded
+ Flemish bathymetry-adjacent source and added them to the map product
+ catalogue. Continuous depth and water volume remain explicitly unsupported.
+- Live-smoked a Mol rectangle through NGI, Statbel, GRB, Flemish thematic
+ rasters, DHMV, VMM flood hazard, BWK, DOV and VHA, including persisted
+ Dataset output and semantic vector/raster metrics.
- Rebuilt the complete frontend presentation as the Stitch-guided GeoIntel
Atlas Workbench. The new shell uses one compact icon navigation rail, one
context bar and task-specific work surfaces across Map, Sources, AI
diff --git a/backend/app/services/coverage_registry_service.py b/backend/app/services/coverage_registry_service.py
index cd4d8214..d9e2c198 100644
--- a/backend/app/services/coverage_registry_service.py
+++ b/backend/app/services/coverage_registry_service.py
@@ -208,6 +208,25 @@ SOURCE_DEFINITIONS = (
"agentschap_landbouw_zeevisserij_agricultural_parcels",
),
),
+ _contract(
+ source_name="vmm_vha_bathymetry_profiles",
+ display_name="VHA historische dwarsprofielen",
+ authority_level="authoritative",
+ coverage_zones=("flanders",),
+ themes=("bathymetry",),
+ native_layers=("digitale_atlas_profile_points",),
+ geometry_types=("Point",),
+ acquisition_mode="bounded_api",
+ integration_status="operational",
+ source_url="https://vha.waterinfo.be/arcgis/rest/services/digitale_atlas/MapServer/0",
+ attribution="Vlaamse Milieumaatschappij (VMM), Vlaamse Hydrografische Atlas",
+ license_note="Hergebruik volgens de voorwaarden van de Vlaamse overheid en de bronmetadata.",
+ limitation_message=(
+ "Historische puntmetingen met bronafhankelijke meetdatum en verticale referentie; "
+ "geen continue actuele bodemkaart en zonder gelijktijdig waterpeil geen watervolume."
+ ),
+ materialized_source_names=("vmm_vha_bathymetry_profiles",),
+ ),
_contract(
source_name="spw_geoportail",
display_name="SPW Geoportail Wallonie",
diff --git a/backend/tests/test_rc4_national_coverage.py b/backend/tests/test_rc4_national_coverage.py
index 4c8dbeee..d06420c0 100644
--- a/backend/tests/test_rc4_national_coverage.py
+++ b/backend/tests/test_rc4_national_coverage.py
@@ -64,9 +64,13 @@ def test_coverage_catalog_uses_normalized_contracts_and_does_not_change_provider
"rbins_marine_reporting_units",
"rbins_msp_2026",
"mdk_bathymetry",
+ "vmm_vha_bathymetry_profiles",
}
assert next(source for source in catalog.sources if source.source_name == "ngi_adminvector").license_note == "CC BY 4.0"
assert next(source for source in catalog.sources if source.source_name == "mdk_bathymetry").integration_status == "not_configured"
+ assert next(
+ source for source in catalog.sources if source.source_name == "vmm_vha_bathymetry_profiles"
+ ).integration_status == "operational"
response = TestClient(app).get("/api/v1/external/coverage/catalog")
assert response.status_code == 200
@@ -268,6 +272,43 @@ def test_spw_bathymetry_materialization_is_source_specific() -> None:
assert with_bathymetry.items[0].materialized_dataset_ids == [bathymetry_id]
+def test_vha_bathymetry_profiles_are_operational_only_inside_the_persisted_selection() -> None:
+ project_id = uuid4()
+ scope = [
+ scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
+ scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
+ ]
+ selection = CoverageBBox(minx=5.101, miny=51.171, maxx=5.109, maxy=51.179)
+ without_profiles = CoverageRegistryService.resolve(
+ FakeSession(project=SimpleNamespace(id=project_id), areas=scope, datasets=[]),
+ project_id,
+ selection,
+ ["bathymetry"],
+ )
+ assert without_profiles.items[0].status == "partial"
+ assert without_profiles.items[0].source_names == ["vmm_vha_bathymetry_profiles"]
+
+ profile_id = uuid4()
+ profiles = SimpleNamespace(
+ id=profile_id,
+ status="ready",
+ source_name="vmm_vha_bathymetry_profiles",
+ reference_layer_name="bathymetry_profile_points",
+ source_metadata={
+ "coverage_zones": ["flanders"],
+ "bbox_epsg4326": [5.1, 51.17, 5.11, 51.18],
+ },
+ )
+ with_profiles = CoverageRegistryService.resolve(
+ FakeSession(project=SimpleNamespace(id=project_id), areas=scope, datasets=[profiles]),
+ project_id,
+ selection,
+ ["bathymetry"],
+ )
+ assert with_profiles.items[0].status == "operational"
+ assert with_profiles.items[0].materialized_dataset_ids == [profile_id]
+
+
def test_mixed_land_and_north_sea_selection_remains_split() -> None:
project_id = uuid4()
project = SimpleNamespace(id=project_id)
diff --git a/backend/tests/test_sprint186_map_first_geographic_explorer.py b/backend/tests/test_sprint186_map_first_geographic_explorer.py
index cb992cef..077d391f 100644
--- a/backend/tests/test_sprint186_map_first_geographic_explorer.py
+++ b/backend/tests/test_sprint186_map_first_geographic_explorer.py
@@ -20,7 +20,7 @@ def test_map_first_explorer_is_the_default_product_flow() -> None:
assert "
Inzichten
" in workspace
assert "Teken rechthoek" in workspace
assert "Volledig werkgebied" in workspace
- assert "Alle beschikbare thema" in workspace
+ assert "Alle relevante thema" in workspace
assert "Bron nog niet ingeladen" in workspace
assert "useMapThemeSelectionInsights" in workspace
assert "datasetsApi.selectVectorFeatures" in read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
diff --git a/backend/tests/test_sprint194_regional_timeseries.py b/backend/tests/test_sprint194_regional_timeseries.py
index 9f835568..c6e44fe7 100644
--- a/backend/tests/test_sprint194_regional_timeseries.py
+++ b/backend/tests/test_sprint194_regional_timeseries.py
@@ -249,7 +249,7 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
assert "statbel: 'Statbel'" in display
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
- assert "dataset ? getDatasetSourceDisplayName(dataset)" in workspace
+ assert "resultDataset ? getDatasetSourceDisplayName(resultDataset)" in workspace
assert "Snel naar een gemeente (optioneel)" in workspace
assert "latestDatasetBySeries" in catalog
assert "Historische meetmomenten" in catalog
diff --git a/backend/tests/test_sprint237_flanders_thematic_on_demand.py b/backend/tests/test_sprint237_flanders_thematic_on_demand.py
index bb038bbe..f7f5108d 100644
--- a/backend/tests/test_sprint237_flanders_thematic_on_demand.py
+++ b/backend/tests/test_sprint237_flanders_thematic_on_demand.py
@@ -29,19 +29,23 @@ def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> No
assert "/datasets/thematic-raster/acquire" in api
-def test_selection_reads_persisted_themes_but_acquires_only_the_active_theme() -> None:
+def test_selection_reads_and_bounded_acquires_all_relevant_themes() -> 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 ".filter((product) => product.theme === activeThemeId)" in workspace
+ assert "? onDemandProductsForZones(resolvedZones)" in workspace
+ assert ".filter((product) => product.theme === activeThemeId)" not in workspace
assert "await loadThemeInsights(bbox, availableThemes, areaId)" in workspace
assert "!regionalPartitionedThemeActive && !onDemandThemeActive" in workspace
assert "onRefreshProjectData" in workspace
assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app
assert "successful.some((item) => item.acquisition)" in selection_hook
assert "await onDatasetsChanged()" in selection_hook
+ assert "settleWithConcurrency(" in selection_hook
+ assert "queries," in selection_hook
+ assert "3," in selection_hook
def test_regional_on_demand_sources_require_a_bounded_drawn_selection() -> None:
diff --git a/backend/tests/test_sprint238_flanders_raster_catalogs.py b/backend/tests/test_sprint238_flanders_raster_catalogs.py
index 5edade02..d37b7824 100644
--- a/backend/tests/test_sprint238_flanders_raster_catalogs.py
+++ b/backend/tests/test_sprint238_flanders_raster_catalogs.py
@@ -25,7 +25,15 @@ 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' | 'grb'" in selection_hook
+ for acquisition_kind in (
+ "'thematic_raster'",
+ "'dhmv'",
+ "'flood_hazard'",
+ "'grb'",
+ "'official_vector'",
+ "'bathymetry_profiles'",
+ ):
+ assert acquisition_kind in selection_hook
assert "datasetsApi.acquireDhmv" in selection_hook
assert "datasetsApi.acquireFloodHazard" in selection_hook
assert "datasetsApi.acquireThematicRaster" in selection_hook
diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md
index ccead5a9..16fe9170 100644
--- a/docs/API_CONTRACTS.md
+++ b/docs/API_CONTRACTS.md
@@ -984,6 +984,13 @@ per zone/theme combination, matching source names and IDs of actually
materialized Datasets. An empty `themes` list requests the complete normalized
vocabulary. Unknown themes fail with `COVERAGE_THEME_UNSUPPORTED`.
+The Map workbench uses this response before a rectangle analysis. It queries
+matching persisted Datasets and may call the already documented bounded
+acquisition endpoints for applicable `partial` source contracts. Those calls
+run through a client queue with at most three concurrent acquisitions. An
+`unsupported` or `not_configured` theme is not rendered as a failed
+measurement; an attempted provider failure is reported explicitly.
+
### GET `/api/v1/external/providers`
Returns all configured provider capability descriptors.
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index 93ef2e49..a91ce007 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -10909,3 +10909,40 @@ Behavior preservation:
Ollama or model-runtime behavior changed;
- all existing actions continue through the original React hooks and service
layer.
+
+## 2026-07-19 - Complete bounded rectangle analysis
+
+Implemented:
+
+- removed the active-theme-only acquisition filter from Map rectangle
+ analysis;
+- added a deterministic acquisition queue capped at three concurrent official
+ provider requests;
+- restricted the result list to themes that are operational or have an
+ applicable bounded adapter in the resolved coverage zone;
+- replaced misleading `Bron ontbreekt` rows with explicit provider-failure
+ states and counts results per theme rather than per contributing layer;
+- registered VMM VHA historical profile points as an operational bounded
+ Flanders bathymetry source and connected its existing acquire/select routes
+ to the Map product catalogue.
+
+Live evidence:
+
+- a bounded Mol rectangle persisted and selected all relevant source families:
+ NGI, Statbel, four GRB products, seven Flemish thematic rasters, DHMV,
+ VMM flood hazard, BWK/Natura 2000, DOV soil and VHA profile points;
+- semantic results included building/forest/agricultural/open-space/water/
+ parcel/soil/nature hectares, road kilometres, inhabitants, terrain height,
+ flood area, accessibility/service scores and historical profile counts;
+- no browser-direct provider request, fabricated value or vertical-datum
+ conversion was introduced.
+
+Validation:
+
+- the complete readiness gate passed: 1,053 backend tests, 24 frontend tests,
+ backend compile, frontend TypeScript/typecheck, production build, Alembic
+ head `202607160001` and script smoke checks;
+- a live API smoke over `5.10,51.17,5.11,51.18` proved all 17 applicable Mol
+ themes through acquisition or persisted national data and semantic
+ selection metrics;
+- browser verification follows against the deployed commit on port 1202.
diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md
index 17b66a50..1336dbe6 100644
--- a/docs/DATA_SOURCES.md
+++ b/docs/DATA_SOURCES.md
@@ -799,6 +799,14 @@ profile numbers, measurement dates, available structured depth/width values
and official document URLs. Scanned documents remain evidence; missing fields
are not filled by fabricated OCR output.
+VHA is registered as the operational bounded source
+`vmm_vha_bathymetry_profiles` for the Flemish `bathymetry` coverage contract.
+The Map workbench can therefore acquire the profile points as part of the same
+bounded selection queue as the other applicable Flemish themes. The resulting
+metric remains a count of historical profiles unless structured source values
+support an additional metric; it is never presented as continuous water depth
+or volume.
+
The following sources are audited:
- MDK Dieptemodel Belgisch Continentaal Plat/Noordzee: 20 x 20 m continuous
diff --git a/docs/TODO.md b/docs/TODO.md
index 6728e0dc..78557aa1 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -61,9 +61,11 @@ geen open productroadmap meer.
selecteerbaar werkgebied; een technische project- of regioselectie is niet
vereist.
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
- volledige werkgebied en analyseer alle reeds beschikbare thema's uit
- PostGIS; alleen het actief gekozen thema mag een nieuwe begrensde
- on-demandbron ophalen.
+ volledige werkgebied en analyseer alle relevante thema's uit PostGIS.
+ Een getekende selectie mag ontbrekende operationele bronproducten begrensd
+ en met maximaal drie gelijktijdige aanvragen materialiseren; niet-toepasselijke
+ of niet-operationele regionale thema's worden niet als ontbrekende meting
+ gepresenteerd.
- [x] Gebruik voor getekende en handmatig ingevoerde rechthoeken overal
`bbox ∩ Area`; een bbox rond het volledige werkgebied resolveert naar de
exacte persistente Area-geometrie en activeert pas dan de full-Area fast path.
@@ -99,6 +101,10 @@ geen open productroadmap meer.
- [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.
+- [x] Maak de volledige selectieanalyse in Vlaanderen operationeel voor NGI,
+ Statbel, GRB, beleidsrasters, DHMV, overstromingsgevaar, BWK, DOV en VHA:
+ één rechthoek materialiseert ontbrekende officiële bronproducten, leest
+ persistente vector/rastergegevens uit en rapporteert inhoudelijke eenheden.
- [x] Vervang de gegroeide dashboardpresentatie door de Stitch-gestuurde Atlas
Workbench met een compacte navigatierail, vaste contextbalk, taakgerichte
schermen en gevalideerde desktop-, ultrawide- en mobiele layouts.
diff --git a/frontend/README.md b/frontend/README.md
index 0f4f091e..db464335 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -69,16 +69,27 @@ result. Water explicitly explains that volume cannot be derived without a
reliable depth or bathymetry source. The advanced workbench remains available
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.
+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 evidence. Releasing the drag resolves the coverage zone,
+reuses every applicable persisted Dataset and bounded-acquires missing
+operational products with a concurrency ceiling of three provider requests.
+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 only themes that are
+applicable and operational for the resolved zone. It reports a real provider
+failure as `Inladen mislukt`; it never turns an unsupported regional theme into
+`Bron ontbreekt`. 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.
+In Flanders, a bounded selection can combine current NGI administration and
+Statbel population with GRB buildings/roads/water/parcels, the governed
+thematic rasters, DHMV terrain, VMM flood hazard, BWK/Natura 2000, DOV soil and
+VHA historical profile points. Every acquisition still uses the existing
+backend Job/Dataset flow and identical requests reuse the persisted artifact.
+The complete Flanders Area is deliberately unavailable for monolithic
+on-demand rasters because it exceeds the backend safety ceiling; this does not
+limit ordinary rectangle analysis or audited regional partitions.
Detection Lab only lists ready imagery rasters. Governed height, flood-hazard
and thematic policy rasters remain available in the map explorer but are
diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx
index 831651f3..2672fab0 100644
--- a/frontend/src/components/map/MapWorkspace.tsx
+++ b/frontend/src/components/map/MapWorkspace.tsx
@@ -1042,6 +1042,22 @@ export function MapWorkspace({
limitationMessage: product.limitation_message,
})
}
+ for (const source of officialMapProducts.bathymetry.filter(
+ (item) =>
+ item.key === 'vha_inland_profiles'
+ && item.acquisition_supported
+ && item.configured,
+ )) {
+ result.push({
+ kind: 'bathymetry_profiles',
+ productKey: source.key,
+ displayName: source.display_name,
+ theme: 'bathymetry',
+ availabilityLabel: 'historische profielpunten · laad bij selectie',
+ attribution: source.attribution,
+ limitationMessage: source.limitation_message,
+ })
+ }
}
for (const product of officialMapProducts.officialVector.filter((item) =>
productCoversZones(item.coverage_zones, effectiveZones),
@@ -1101,6 +1117,27 @@ export function MapWorkspace({
}
return result
}, [onDemandProductsForZones, selectedCoverageZones])
+ const selectionRelevantThemes = useMemo(() => {
+ if (!mapSelectionBbox || !coverage) {
+ return DATA_THEMES
+ }
+ const boundedThemes = new Set(
+ onDemandProductsForZones(coverage.intersected_zones).map((product) => product.theme),
+ )
+ return DATA_THEMES.filter((theme) => {
+ if (boundedThemes.has(theme.id)) {
+ return true
+ }
+ if (!themeDatasetMap[theme.id]) {
+ return false
+ }
+ const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id]
+ return coverage.items.some(
+ (item) => item.theme === coverageTheme && item.status === 'operational',
+ )
+ })
+ }, [coverage, mapSelectionBbox, onDemandProductsForZones, themeDatasetMap])
+ const unavailableSelectionThemeCount = Math.max(DATA_THEMES.length - selectionRelevantThemes.length, 0)
const activeOnDemandMapProduct = themeDatasetMap[activeTheme.id]
? null
: onDemandProductMap.get(activeTheme.id) ?? null
@@ -1290,6 +1327,10 @@ export function MapWorkspace({
}),
[themeInsights],
)
+ const readSelectionThemeCount = useMemo(
+ () => new Set(themeResults.map((result) => result.theme.id)).size,
+ [themeResults],
+ )
const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId)
const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset
const activeSelectionResult = activeThemeInsight?.result
@@ -1703,7 +1744,7 @@ export function MapWorkspace({
resolvedZones = resolvedCoverage.intersected_zones
}
const resolvedProducts = analysisMode === 'current'
- ? onDemandProductsForZones(resolvedZones).filter((product) => product.theme === activeThemeId)
+ ? onDemandProductsForZones(resolvedZones)
: []
const availableThemes: Array> = []
for (const theme of DATA_THEMES) {
@@ -2389,7 +2430,7 @@ export function MapWorkspace({
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
- Gegevens worden uit PostGIS gelezen…
+ Officiële bronnen worden begrensd geladen en geanalyseerd…
) : (
<>
@@ -2498,26 +2539,35 @@ export function MapWorkspace({
-
Alle beschikbare thema’s
- {themeResults.length} bevraagd
+ Alle relevante thema’s
+ {readSelectionThemeCount} van {selectionRelevantThemes.length} uitgelezen
- {DATA_THEMES.map((theme) => {
+ {selectionRelevantThemes.flatMap((theme) => {
const dataset = themeDatasetMap[theme.id]
- const item = themeResults.find((result) => result.theme.id === theme.id)
- return (
-
-
-
- {theme.label}
- {dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}
-
-
- {item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}
- {item?.result.summary ? {item.result.summary.metric_label} : null}
-
-
- )
+ const items = themeResults.filter((result) => result.theme.id === theme.id)
+ const rows = items.length > 0 ? items : [null]
+ return rows.map((item, index) => {
+ const resultDataset = item?.dataset ?? dataset
+ return (
+
+
+
+ {theme.label}
+ {resultDataset ? getDatasetSourceDisplayName(resultDataset) : 'Officiële bron kon niet worden geladen'}
+
+
+ {item ? resultMetricLabel(item.result) : 'Inladen mislukt'}
+ {item?.result.summary ? {item.result.summary.metric_label} : null}
+
+
+ )
+ })
})}
+ {unavailableSelectionThemeCount > 0 ? (
+
+ {unavailableSelectionThemeCount} thema’s zijn voor deze zone niet van toepassing of hebben nog geen gevalideerde operationele koppeling.
+
+ ) : null}
>
)}
diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.test.ts b/frontend/src/hooks/useMapThemeSelectionInsights.test.ts
new file mode 100644
index 00000000..5681db5b
--- /dev/null
+++ b/frontend/src/hooks/useMapThemeSelectionInsights.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import { settleWithConcurrency } from './useMapThemeSelectionInsights'
+
+describe('settleWithConcurrency', () => {
+ it('keeps result order and never exceeds the acquisition limit', async () => {
+ let active = 0
+ let maximumActive = 0
+
+ const results = await settleWithConcurrency([0, 1, 2, 3, 4, 5], 3, async (value) => {
+ active += 1
+ maximumActive = Math.max(maximumActive, active)
+ await new Promise((resolve) => setTimeout(resolve, (5 - value) * 2))
+ active -= 1
+ return value * 10
+ })
+
+ expect(maximumActive).toBe(3)
+ expect(results).toEqual([
+ { status: 'fulfilled', value: 0 },
+ { status: 'fulfilled', value: 10 },
+ { status: 'fulfilled', value: 20 },
+ { status: 'fulfilled', value: 30 },
+ { status: 'fulfilled', value: 40 },
+ { status: 'fulfilled', value: 50 },
+ ])
+ })
+
+ it('retains individual acquisition failures without stopping the queue', async () => {
+ const results = await settleWithConcurrency(['ok', 'fail', 'later'], 2, async (value) => {
+ if (value === 'fail') {
+ throw new Error('provider unavailable')
+ }
+ return value.toUpperCase()
+ })
+
+ expect(results[0]).toEqual({ status: 'fulfilled', value: 'OK' })
+ expect(results[1].status).toBe('rejected')
+ expect(results[2]).toEqual({ status: 'fulfilled', value: 'LATER' })
+ })
+})
diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts
index eab33a20..a9d47afc 100644
--- a/frontend/src/hooks/useMapThemeSelectionInsights.ts
+++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts
@@ -7,7 +7,13 @@ import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
import { bathymetryRasterSelectionToMapSelection } from '../lib/bathymetryRaster'
-export type MapThemeAcquisitionKind = 'thematic_raster' | 'dhmv' | 'flood_hazard' | 'grb' | 'official_vector'
+export type MapThemeAcquisitionKind =
+ | 'thematic_raster'
+ | 'dhmv'
+ | 'flood_hazard'
+ | 'grb'
+ | 'official_vector'
+ | 'bathymetry_profiles'
export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind
@@ -30,6 +36,31 @@ export interface MapThemeInsight {
result: VectorSelectionResponse
}
+export async function settleWithConcurrency(
+ items: T[],
+ concurrency: number,
+ task: (item: T, index: number) => Promise,
+): Promise>> {
+ const results = new Array>(items.length)
+ const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)))
+ let nextIndex = 0
+
+ const runWorker = async () => {
+ while (nextIndex < items.length) {
+ const index = nextIndex
+ nextIndex += 1
+ try {
+ results[index] = { status: 'fulfilled', value: await task(items[index], index) }
+ } catch (reason) {
+ results[index] = { status: 'rejected', reason }
+ }
+ }
+ }
+
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
+ return results
+}
+
export function useMapThemeSelectionInsights(
selectedProjectId: string | null,
onDatasetsChanged?: () => Promise,
@@ -69,8 +100,10 @@ export function useMapThemeSelectionInsights(
setThemeInsightsLoading(true)
setThemeInsightsError(null)
try {
- const settled = await Promise.allSettled(
- queries.map(async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => {
+ const settled = await settleWithConcurrency(
+ queries,
+ 3,
+ async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => {
let dataset = existingDataset
if (acquisition) {
const commonPayload = {
@@ -98,10 +131,12 @@ export function useMapThemeSelectionInsights(
...commonPayload,
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
})
- : await datasetsApi.acquireOfficialVector(selectedProjectId, {
- ...commonPayload,
- product_key: acquisition.productKey,
- })
+ : acquisition.kind === 'bathymetry_profiles'
+ ? await datasetsApi.acquireBathymetryProfiles(selectedProjectId, commonPayload)
+ : await datasetsApi.acquireOfficialVector(selectedProjectId, {
+ ...commonPayload,
+ product_key: acquisition.productKey,
+ })
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error(
acquisitionJob.error_message
@@ -166,7 +201,7 @@ export function useMapThemeSelectionInsights(
limit: 1000,
}),
}
- }),
+ },
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
const failures = settled.flatMap((item, index) => (
diff --git a/frontend/src/hooks/useOfficialMapProducts.ts b/frontend/src/hooks/useOfficialMapProducts.ts
index 2e43e26b..0a91a707 100644
--- a/frontend/src/hooks/useOfficialMapProducts.ts
+++ b/frontend/src/hooks/useOfficialMapProducts.ts
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { datasetsApi, externalApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type {
+ BathymetrySourceRead,
DhmvProductRead,
FloodHazardProductRead,
GrbProductRead,
@@ -15,6 +16,7 @@ export interface OfficialMapProducts {
floodHazard: FloodHazardProductRead[]
grb: GrbProductRead[]
officialVector: OfficialVectorProductRead[]
+ bathymetry: BathymetrySourceRead[]
}
const EMPTY_PRODUCTS: OfficialMapProducts = {
@@ -23,6 +25,7 @@ const EMPTY_PRODUCTS: OfficialMapProducts = {
floodHazard: [],
grb: [],
officialVector: [],
+ bathymetry: [],
}
export function useOfficialMapProducts(selectedProjectId: string | null) {
@@ -49,8 +52,9 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
datasetsApi.listFloodHazardProducts(selectedProjectId),
datasetsApi.listGrbProducts(selectedProjectId),
datasetsApi.listOfficialVectorProducts(selectedProjectId),
+ datasetsApi.listBathymetrySources(selectedProjectId),
])
- .then(([thematic, dhmv, floodHazard, grb, officialVector]) => {
+ .then(([thematic, dhmv, floodHazard, grb, officialVector, bathymetry]) => {
if (!cancelled) {
setProducts({
thematic: thematic.items,
@@ -58,6 +62,7 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
floodHazard: floodHazard.items,
grb: grb.items,
officialVector: officialVector.items,
+ bathymetry: bathymetry.items,
})
}
})