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