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 ( -
-
- ) + 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 ( +
+
+ ) + }) })} + {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, }) } })