fix(map): read all relevant selection sources
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-19 17:54:07 +02:00
parent 1b9848a5f4
commit e2c90da11d
16 changed files with 327 additions and 45 deletions
+11
View File
@@ -9,6 +9,17 @@
## 1.0.0 - Final Belgium and Belgian North Sea release (2026-07-19) ## 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 - Rebuilt the complete frontend presentation as the Stitch-guided GeoIntel
Atlas Workbench. The new shell uses one compact icon navigation rail, one Atlas Workbench. The new shell uses one compact icon navigation rail, one
context bar and task-specific work surfaces across Map, Sources, AI context bar and task-specific work surfaces across Map, Sources, AI
@@ -208,6 +208,25 @@ SOURCE_DEFINITIONS = (
"agentschap_landbouw_zeevisserij_agricultural_parcels", "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( _contract(
source_name="spw_geoportail", source_name="spw_geoportail",
display_name="SPW Geoportail Wallonie", display_name="SPW Geoportail Wallonie",
@@ -64,9 +64,13 @@ def test_coverage_catalog_uses_normalized_contracts_and_does_not_change_provider
"rbins_marine_reporting_units", "rbins_marine_reporting_units",
"rbins_msp_2026", "rbins_msp_2026",
"mdk_bathymetry", "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 == "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 == "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") response = TestClient(app).get("/api/v1/external/coverage/catalog")
assert response.status_code == 200 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] 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: def test_mixed_land_and_north_sea_selection_remains_split() -> None:
project_id = uuid4() project_id = uuid4()
project = SimpleNamespace(id=project_id) project = SimpleNamespace(id=project_id)
@@ -20,7 +20,7 @@ def test_map_first_explorer_is_the_default_product_flow() -> None:
assert "<h3>Inzichten</h3>" in workspace assert "<h3>Inzichten</h3>" in workspace
assert "Teken rechthoek" in workspace assert "Teken rechthoek" in workspace
assert "Volledig werkgebied" 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 "Bron nog niet ingeladen" in workspace
assert "useMapThemeSelectionInsights" in workspace assert "useMapThemeSelectionInsights" in workspace
assert "datasetsApi.selectVectorFeatures" in read("frontend/src/hooks/useMapThemeSelectionInsights.ts") assert "datasetsApi.selectVectorFeatures" in read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
@@ -249,7 +249,7 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
assert "department_omgeving_land_use: 'Departement Omgeving'" in display assert "department_omgeving_land_use: 'Departement Omgeving'" in display
assert "statbel: 'Statbel'" in display assert "statbel: 'Statbel'" in display
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace 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 "Snel naar een gemeente (optioneel)" in workspace
assert "latestDatasetBySeries" in catalog assert "latestDatasetBySeries" in catalog
assert "Historische meetmomenten" in catalog assert "Historische meetmomenten" in catalog
@@ -29,19 +29,23 @@ def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> No
assert "/datasets/thematic-raster/acquire" in api 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") workspace = read("frontend/src/components/map/MapWorkspace.tsx")
app = read("frontend/src/App.tsx") app = read("frontend/src/App.tsx")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts") selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
assert "for (const theme of DATA_THEMES)" in workspace 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 "await loadThemeInsights(bbox, availableThemes, areaId)" in workspace
assert "!regionalPartitionedThemeActive && !onDemandThemeActive" in workspace assert "!regionalPartitionedThemeActive && !onDemandThemeActive" in workspace
assert "onRefreshProjectData" in workspace assert "onRefreshProjectData" in workspace
assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app assert "selectedProjectId ? loadProjectData(selectedProjectId)" in app
assert "successful.some((item) => item.acquisition)" in selection_hook assert "successful.some((item) => item.acquisition)" in selection_hook
assert "await onDatasetsChanged()" 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: def test_regional_on_demand_sources_require_a_bounded_drawn_selection() -> None:
@@ -25,7 +25,15 @@ def test_map_selection_can_acquire_dhmv_and_flood_hazard_products() -> None:
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts") selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
workspace = read("frontend/src/components/map/MapWorkspace.tsx") 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.acquireDhmv" in selection_hook
assert "datasetsApi.acquireFloodHazard" in selection_hook assert "datasetsApi.acquireFloodHazard" in selection_hook
assert "datasetsApi.acquireThematicRaster" in selection_hook assert "datasetsApi.acquireThematicRaster" in selection_hook
+7
View File
@@ -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 materialized Datasets. An empty `themes` list requests the complete normalized
vocabulary. Unknown themes fail with `COVERAGE_THEME_UNSUPPORTED`. 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` ### GET `/api/v1/external/providers`
Returns all configured provider capability descriptors. Returns all configured provider capability descriptors.
+37
View File
@@ -10909,3 +10909,40 @@ Behavior preservation:
Ollama or model-runtime behavior changed; Ollama or model-runtime behavior changed;
- all existing actions continue through the original React hooks and service - all existing actions continue through the original React hooks and service
layer. 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.
+8
View File
@@ -799,6 +799,14 @@ profile numbers, measurement dates, available structured depth/width values
and official document URLs. Scanned documents remain evidence; missing fields and official document URLs. Scanned documents remain evidence; missing fields
are not filled by fabricated OCR output. 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: The following sources are audited:
- MDK Dieptemodel Belgisch Continentaal Plat/Noordzee: 20 x 20 m continuous - MDK Dieptemodel Belgisch Continentaal Plat/Noordzee: 20 x 20 m continuous
+9 -3
View File
@@ -61,9 +61,11 @@ geen open productroadmap meer.
selecteerbaar werkgebied; een technische project- of regioselectie is niet selecteerbaar werkgebied; een technische project- of regioselectie is niet
vereist. vereist.
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het - [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
volledige werkgebied en analyseer alle reeds beschikbare thema's uit volledige werkgebied en analyseer alle relevante thema's uit PostGIS.
PostGIS; alleen het actief gekozen thema mag een nieuwe begrensde Een getekende selectie mag ontbrekende operationele bronproducten begrensd
on-demandbron ophalen. 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 - [x] Gebruik voor getekende en handmatig ingevoerde rechthoeken overal
`bbox ∩ Area`; een bbox rond het volledige werkgebied resolveert naar de `bbox ∩ Area`; een bbox rond het volledige werkgebied resolveert naar de
exacte persistente Area-geometrie en activeert pas dan de full-Area fast path. 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 - [x] Maak de vijf bestaande officiële beleidsrasters veilig op aanvraag
beschikbaar voor elke begrensde selectie in Vlaanderen, met Dataset-cache, beschikbaar voor elke begrensde selectie in Vlaanderen, met Dataset-cache,
semantische metrics en zonder 1.425 vooraf geladen rasters. 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 - [x] Vervang de gegroeide dashboardpresentatie door de Stitch-gestuurde Atlas
Workbench met een compacte navigatierail, vaste contextbalk, taakgerichte Workbench met een compacte navigatierail, vaste contextbalk, taakgerichte
schermen en gevalideerde desktop-, ultrawide- en mobiele layouts. schermen en gevalideerde desktop-, ultrawide- en mobiele layouts.
+20 -9
View File
@@ -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 reliable depth or bathymetry source. The advanced workbench remains available
but is not required for the primary choose-theme, draw-area, read-result flow. 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, In Flanders, a bounded selection can combine current NGI administration and
node value and service level may appear as `Op aanvraag`. Drawing a rectangle Statbel population with GRB buildings/roads/water/parcels, the governed
or choosing a municipality uses the existing backend Job/Dataset flow to thematic rasters, DHMV terrain, VMM flood hazard, BWK/Natura 2000, DOV soil and
acquire and persist those five official rasters for the exact selection, then VHA historical profile points. Every acquisition still uses the existing
shows all available semantic metrics together. Identical requests reuse the backend Job/Dataset flow and identical requests reuse the persisted artifact.
persisted artifact. The complete Flanders Area is deliberately unavailable for The complete Flanders Area is deliberately unavailable for monolithic
these rasters because it exceeds the backend safety ceiling; this does not on-demand rasters because it exceeds the backend safety ceiling; this does not
limit vector or partitioned bathymetry analysis of the complete region. limit ordinary rectangle analysis or audited regional partitions.
Detection Lab only lists ready imagery rasters. Governed height, flood-hazard Detection Lab only lists ready imagery rasters. Governed height, flood-hazard
and thematic policy rasters remain available in the map explorer but are and thematic policy rasters remain available in the map explorer but are
+69 -19
View File
@@ -1042,6 +1042,22 @@ export function MapWorkspace({
limitationMessage: product.limitation_message, 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) => for (const product of officialMapProducts.officialVector.filter((item) =>
productCoversZones(item.coverage_zones, effectiveZones), productCoversZones(item.coverage_zones, effectiveZones),
@@ -1101,6 +1117,27 @@ export function MapWorkspace({
} }
return result return result
}, [onDemandProductsForZones, selectedCoverageZones]) }, [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] const activeOnDemandMapProduct = themeDatasetMap[activeTheme.id]
? null ? null
: onDemandProductMap.get(activeTheme.id) ?? null : onDemandProductMap.get(activeTheme.id) ?? null
@@ -1290,6 +1327,10 @@ export function MapWorkspace({
}), }),
[themeInsights], [themeInsights],
) )
const readSelectionThemeCount = useMemo(
() => new Set(themeResults.map((result) => result.theme.id)).size,
[themeResults],
)
const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId) const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId)
const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset
const activeSelectionResult = activeThemeInsight?.result const activeSelectionResult = activeThemeInsight?.result
@@ -1703,7 +1744,7 @@ export function MapWorkspace({
resolvedZones = resolvedCoverage.intersected_zones resolvedZones = resolvedCoverage.intersected_zones
} }
const resolvedProducts = analysisMode === 'current' const resolvedProducts = analysisMode === 'current'
? onDemandProductsForZones(resolvedZones).filter((product) => product.theme === activeThemeId) ? onDemandProductsForZones(resolvedZones)
: [] : []
const availableThemes: Array<MapThemeQuery<DataThemeId>> = [] const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) { for (const theme of DATA_THEMES) {
@@ -2389,7 +2430,7 @@ export function MapWorkspace({
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? ( ) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
<div className="geo-results-loading" role="status"> <div className="geo-results-loading" role="status">
<span /> <span />
<strong>Gegevens worden uit PostGIS gelezen</strong> <strong>Officiële bronnen worden begrensd geladen en geanalyseerd</strong>
</div> </div>
) : ( ) : (
<> <>
@@ -2498,26 +2539,35 @@ export function MapWorkspace({
<div className="geo-theme-results"> <div className="geo-theme-results">
<div className="geo-results-title-row"> <div className="geo-results-title-row">
<h4>Alle beschikbare themas</h4> <h4>Alle relevante themas</h4>
<span>{themeResults.length} bevraagd</span> <span>{readSelectionThemeCount} van {selectionRelevantThemes.length} uitgelezen</span>
</div> </div>
{DATA_THEMES.map((theme) => { {selectionRelevantThemes.flatMap((theme) => {
const dataset = themeDatasetMap[theme.id] const dataset = themeDatasetMap[theme.id]
const item = themeResults.find((result) => result.theme.id === theme.id) const items = themeResults.filter((result) => result.theme.id === theme.id)
return ( const rows = items.length > 0 ? items : [null]
<div className="geo-theme-result-row" key={theme.id}> return rows.map((item, index) => {
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" /> const resultDataset = item?.dataset ?? dataset
<span> return (
<strong>{theme.label}</strong> <div className="geo-theme-result-row" key={`${theme.id}:${resultDataset?.id ?? index}`}>
<small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small> <span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
</span> <span>
<span className="geo-theme-result-value"> <strong>{theme.label}</strong>
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b> <small>{resultDataset ? getDatasetSourceDisplayName(resultDataset) : 'Officiële bron kon niet worden geladen'}</small>
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null} </span>
</span> <span className="geo-theme-result-value">
</div> <b>{item ? resultMetricLabel(item.result) : 'Inladen mislukt'}</b>
) {item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
</span>
</div>
)
})
})} })}
{unavailableSelectionThemeCount > 0 ? (
<p className="geo-data-notice">
{unavailableSelectionThemeCount} themas zijn voor deze zone niet van toepassing of hebben nog geen gevalideerde operationele koppeling.
</p>
) : null}
</div> </div>
</> </>
)} )}
@@ -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' })
})
})
@@ -7,7 +7,13 @@ import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster' import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
import { bathymetryRasterSelectionToMapSelection } from '../lib/bathymetryRaster' 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 { export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind kind: MapThemeAcquisitionKind
@@ -30,6 +36,31 @@ export interface MapThemeInsight<TThemeId extends string> {
result: VectorSelectionResponse result: VectorSelectionResponse
} }
export async function settleWithConcurrency<T, TResult>(
items: T[],
concurrency: number,
task: (item: T, index: number) => Promise<TResult>,
): Promise<Array<PromiseSettledResult<TResult>>> {
const results = new Array<PromiseSettledResult<TResult>>(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<TThemeId extends string>( export function useMapThemeSelectionInsights<TThemeId extends string>(
selectedProjectId: string | null, selectedProjectId: string | null,
onDatasetsChanged?: () => Promise<unknown>, onDatasetsChanged?: () => Promise<unknown>,
@@ -69,8 +100,10 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
setThemeInsightsLoading(true) setThemeInsightsLoading(true)
setThemeInsightsError(null) setThemeInsightsError(null)
try { try {
const settled = await Promise.allSettled( const settled = await settleWithConcurrency(
queries.map(async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => { queries,
3,
async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => {
let dataset = existingDataset let dataset = existingDataset
if (acquisition) { if (acquisition) {
const commonPayload = { const commonPayload = {
@@ -98,10 +131,12 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
...commonPayload, ...commonPayload,
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels', product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
}) })
: await datasetsApi.acquireOfficialVector(selectedProjectId, { : acquisition.kind === 'bathymetry_profiles'
...commonPayload, ? await datasetsApi.acquireBathymetryProfiles(selectedProjectId, commonPayload)
product_key: acquisition.productKey, : await datasetsApi.acquireOfficialVector(selectedProjectId, {
}) ...commonPayload,
product_key: acquisition.productKey,
})
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) { if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error( throw new Error(
acquisitionJob.error_message acquisitionJob.error_message
@@ -166,7 +201,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
limit: 1000, limit: 1000,
}), }),
} }
}), },
) )
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : [])) const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
const failures = settled.flatMap((item, index) => ( const failures = settled.flatMap((item, index) => (
+6 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { datasetsApi, externalApi } from '../services/api' import { datasetsApi, externalApi } from '../services/api'
import { formatError } from '../lib/formatError' import { formatError } from '../lib/formatError'
import type { import type {
BathymetrySourceRead,
DhmvProductRead, DhmvProductRead,
FloodHazardProductRead, FloodHazardProductRead,
GrbProductRead, GrbProductRead,
@@ -15,6 +16,7 @@ export interface OfficialMapProducts {
floodHazard: FloodHazardProductRead[] floodHazard: FloodHazardProductRead[]
grb: GrbProductRead[] grb: GrbProductRead[]
officialVector: OfficialVectorProductRead[] officialVector: OfficialVectorProductRead[]
bathymetry: BathymetrySourceRead[]
} }
const EMPTY_PRODUCTS: OfficialMapProducts = { const EMPTY_PRODUCTS: OfficialMapProducts = {
@@ -23,6 +25,7 @@ const EMPTY_PRODUCTS: OfficialMapProducts = {
floodHazard: [], floodHazard: [],
grb: [], grb: [],
officialVector: [], officialVector: [],
bathymetry: [],
} }
export function useOfficialMapProducts(selectedProjectId: string | null) { export function useOfficialMapProducts(selectedProjectId: string | null) {
@@ -49,8 +52,9 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
datasetsApi.listFloodHazardProducts(selectedProjectId), datasetsApi.listFloodHazardProducts(selectedProjectId),
datasetsApi.listGrbProducts(selectedProjectId), datasetsApi.listGrbProducts(selectedProjectId),
datasetsApi.listOfficialVectorProducts(selectedProjectId), datasetsApi.listOfficialVectorProducts(selectedProjectId),
datasetsApi.listBathymetrySources(selectedProjectId),
]) ])
.then(([thematic, dhmv, floodHazard, grb, officialVector]) => { .then(([thematic, dhmv, floodHazard, grb, officialVector, bathymetry]) => {
if (!cancelled) { if (!cancelled) {
setProducts({ setProducts({
thematic: thematic.items, thematic: thematic.items,
@@ -58,6 +62,7 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
floodHazard: floodHazard.items, floodHazard: floodHazard.items,
grb: grb.items, grb: grb.items,
officialVector: officialVector.items, officialVector: officialVector.items,
bathymetry: bathymetry.items,
}) })
} }
}) })