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
+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
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
+69 -19
View File
@@ -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<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) {
@@ -2389,7 +2430,7 @@ export function MapWorkspace({
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
<div className="geo-results-loading" role="status">
<span />
<strong>Gegevens worden uit PostGIS gelezen</strong>
<strong>Officiële bronnen worden begrensd geladen en geanalyseerd</strong>
</div>
) : (
<>
@@ -2498,26 +2539,35 @@ export function MapWorkspace({
<div className="geo-theme-results">
<div className="geo-results-title-row">
<h4>Alle beschikbare themas</h4>
<span>{themeResults.length} bevraagd</span>
<h4>Alle relevante themas</h4>
<span>{readSelectionThemeCount} van {selectionRelevantThemes.length} uitgelezen</span>
</div>
{DATA_THEMES.map((theme) => {
{selectionRelevantThemes.flatMap((theme) => {
const dataset = themeDatasetMap[theme.id]
const item = themeResults.find((result) => result.theme.id === theme.id)
return (
<div className="geo-theme-result-row" key={theme.id}>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small>
</span>
<span className="geo-theme-result-value">
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
</span>
</div>
)
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 (
<div className="geo-theme-result-row" key={`${theme.id}:${resultDataset?.id ?? index}`}>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>{resultDataset ? getDatasetSourceDisplayName(resultDataset) : 'Officiële bron kon niet worden geladen'}</small>
</span>
<span className="geo-theme-result-value">
<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>
</>
)}
@@ -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 { 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<TThemeId extends string> {
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>(
selectedProjectId: string | null,
onDatasetsChanged?: () => Promise<unknown>,
@@ -69,8 +100,10 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
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<TThemeId extends string>(
...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<TThemeId extends string>(
limit: 1000,
}),
}
}),
},
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
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 { 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,
})
}
})