fix(map): read all relevant selection sources
This commit is contained in:
+20
-9
@@ -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
|
||||
|
||||
@@ -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 thema’s</h4>
|
||||
<span>{themeResults.length} bevraagd</span>
|
||||
<h4>Alle relevante thema’s</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} thema’s 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) => (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user