fix(map): make area analysis scale-aware
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-21 21:59:56 +02:00
parent 80631607f7
commit 20829f1a24
20 changed files with 619 additions and 56 deletions
+97 -7
View File
@@ -41,8 +41,11 @@ import {
safeFileStem,
selectedAreaCoverageZones,
selectedFeatureCollection,
selectionAnalysisScale,
selectionAreaSquareMetres,
selectionDimensions,
selectionMetricLabel,
splitSelectionBbox,
} from './mapWorkspaceUtils'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
@@ -91,6 +94,25 @@ interface OnDemandMapProduct extends MapThemeAcquisition {
limitationMessage: string
}
interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
acquisitionBboxes: VectorSelectionBBox[]
}
function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return false
const dimensions = selectionDimensions(bbox)
if (product.kind === 'dhmv' || product.kind === 'flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (product.kind === 'thematic_raster') {
return dimensions.widthMetres <= 50_000
&& dimensions.heightMetres <= 50_000
&& dimensions.areaSquareMetres <= 2_800_000_000
}
return true
}
const DATA_THEMES: DataTheme[] = [
{
id: 'administrative',
@@ -836,6 +858,7 @@ export function MapWorkspace({
loading: officialMapProductsLoading,
error: officialMapProductsError,
resolveCoverage,
resolveCoveragePartitions,
} = useOfficialMapProducts(selectedProjectId)
const {
temporalComparison,
@@ -1114,12 +1137,18 @@ export function MapWorkspace({
}
return result
}, [onDemandProductsForZones, selectedCoverageZones])
const mapSelectionScale = mapSelectionBbox ? selectionAnalysisScale(mapSelectionBbox) : null
const selectionRelevantThemes = useMemo(() => {
if (!mapSelectionBbox || !coverage) {
return DATA_THEMES
}
const boundedThemes = new Set(
onDemandProductsForZones(coverage.intersected_zones).map((product) => product.theme),
(mapSelectionBbox
? onDemandProductsForZones(coverage.intersected_zones).filter(
(product) => productSupportsSelection(product, mapSelectionBbox),
)
: [])
.map((product) => product.theme),
)
return DATA_THEMES.filter((theme) => {
if (boundedThemes.has(theme.id)) {
@@ -1133,9 +1162,9 @@ export function MapWorkspace({
(item) => item.theme === coverageTheme && item.status === 'operational',
)
})
}, [coverage, mapSelectionBbox, onDemandProductsForZones, themeDatasetMap])
}, [coverage, mapSelectionBbox, mapSelectionScale, onDemandProductsForZones, themeDatasetMap])
const unavailableSelectionThemeCount = Math.max(DATA_THEMES.length - selectionRelevantThemes.length, 0)
const activeOnDemandMapProduct = themeDatasetMap[activeTheme.id]
const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id]
? null
: onDemandProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
@@ -1366,6 +1395,20 @@ export function MapWorkspace({
: selectionAreaSquareMetres(mapSelectionBbox),
[mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2],
)
const selectionScaleNotice = useMemo(() => {
if (!mapSelectionBbox || !mapSelectionScale) return null
const dimensions = selectionDimensions(mapSelectionBbox)
const widthKm = dimensions.widthMetres / 1000
const heightKm = dimensions.heightMetres / 1000
if (mapSelectionScale === 'regional') {
const partitionCount = splitSelectionBbox(mapSelectionBbox).length
return `Regionale analyse van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} × ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Geschikte detailbronnen worden automatisch over ${partitionCount} begrensde bronpartities verwerkt; 5 m-rasters worden alleen meegenomen wanneer het veilige pixelbudget volstaat.`
}
if (mapSelectionScale === 'overview') {
return `Overzichtsanalyse van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} × ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. Alleen landelijke en vooraf ingeladen bronnen die deze schaal betrouwbaar ondersteunen worden bevraagd. Teken maximaal 50 × 50 km voor regionale thema's of 20 × 20 km voor alle detailbronnen.`
}
return null
}, [mapSelectionBbox, mapSelectionScale])
const selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
@@ -1727,6 +1770,7 @@ export function MapWorkspace({
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
let resolvedZones = selectedCoverageZones
const scale = selectionAnalysisScale(bbox)
if (analysisMode === 'current' && selectedProjectId) {
const resolvedCoverage = await resolveCoverage({
minx: bbox.min_x,
@@ -1740,9 +1784,49 @@ export function MapWorkspace({
}
resolvedZones = resolvedCoverage.intersected_zones
}
const resolvedProducts = analysisMode === 'current'
? onDemandProductsForZones(resolvedZones)
: []
let resolvedProducts: PlannedOnDemandMapProduct[] = []
if (analysisMode === 'current' && scale !== 'overview') {
const zoneProducts = resolvedZones
? onDemandProductsForZones(resolvedZones)
: []
if (scale === 'detail' || !selectedProjectId) {
resolvedProducts = zoneProducts
.filter((product) => productSupportsSelection(product, bbox))
.map((product) => ({
...product,
acquisitionBboxes: [bbox],
}))
} else {
const detailTiles = splitSelectionBbox(bbox)
const tileCoverage = await resolveCoveragePartitions(detailTiles)
if (!tileCoverage) {
clearThemeInsights()
return
}
const grouped = new Map<string, PlannedOnDemandMapProduct>()
for (const item of tileCoverage) {
for (const product of onDemandProductsForZones(item.coverage.intersected_zones)) {
if (product.kind === 'thematic_raster' || !productSupportsSelection(product, bbox)) continue
const key = `${product.kind}:${product.productKey}`
const existing = grouped.get(key)
if (existing) {
existing.acquisitionBboxes.push(item.bbox)
} else {
grouped.set(key, { ...product, acquisitionBboxes: [item.bbox] })
}
}
}
for (const product of zoneProducts.filter(
(candidate) => candidate.kind === 'thematic_raster' && productSupportsSelection(candidate, bbox),
)) {
grouped.set(`${product.kind}:${product.productKey}`, {
...product,
acquisitionBboxes: [bbox],
})
}
resolvedProducts = [...grouped.values()]
}
}
const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) {
const dataset = themeDatasetMap[theme.id]
@@ -1765,6 +1849,7 @@ export function MapWorkspace({
productKey: onDemandProduct.productKey,
displayName: onDemandProduct.displayName,
},
acquisitionBboxes: onDemandProduct.acquisitionBboxes,
})
}
continue
@@ -2560,9 +2645,14 @@ export function MapWorkspace({
)
})
})}
{selectionScaleNotice ? (
<p className="geo-data-notice">{selectionScaleNotice}</p>
) : null}
{unavailableSelectionThemeCount > 0 ? (
<p className="geo-data-notice">
{unavailableSelectionThemeCount} themas zijn voor deze zone niet van toepassing of hebben nog geen gevalideerde operationele koppeling.
{mapSelectionScale === 'overview'
? `${unavailableSelectionThemeCount} detailthema's zijn op deze overzichtsschaal bewust niet bevraagd.`
: `${unavailableSelectionThemeCount} thema's zijn voor deze zone niet van toepassing, niet operationeel gekoppeld of te fijnmazig voor deze selectieschaal.`}
</p>
) : null}
</div>
@@ -8,9 +8,12 @@ import {
productCoversZones,
resultMetricLabel,
selectedAreaCoverageZones,
selectionAnalysisScale,
selectionAreaSquareMetres,
selectionDimensions,
splitSelectionBbox,
} from './mapWorkspaceUtils'
import type { VectorSelectionResponse } from '../../types'
import type { VectorSelectionBBox, VectorSelectionResponse } from '../../types'
describe('map workspace selection guards', () => {
it('normalizes drag corners into an EPSG:4326 bbox', () => {
@@ -90,4 +93,49 @@ describe('map workspace selection guards', () => {
geometry_clipped_to_selection: true,
})).toBe(false)
})
it('classifies local, regional and overview selections before provider calls', () => {
const bbox = (widthDegrees: number, heightDegrees: number): VectorSelectionBBox => ({
min_x: 5,
min_y: 51,
max_x: 5 + widthDegrees,
max_y: 51 + heightDegrees,
crs: 'EPSG:4326',
})
expect(selectionAnalysisScale(bbox(0.1, 0.1))).toBe('detail')
expect(selectionAnalysisScale(bbox(0.35, 0.25))).toBe('regional')
expect(selectionAnalysisScale(bbox(1, 1))).toBe('overview')
})
it('splits regional selections into provider-safe tiles without changing the outer bounds', () => {
const selection: VectorSelectionBBox = {
min_x: 5,
min_y: 51,
max_x: 5.5,
max_y: 51.35,
crs: 'EPSG:4326',
}
const tiles = splitSelectionBbox(selection)
expect(tiles.length).toBeGreaterThan(1)
expect(Math.min(...tiles.map((tile) => tile.min_x))).toBe(selection.min_x)
expect(Math.min(...tiles.map((tile) => tile.min_y))).toBe(selection.min_y)
expect(Math.max(...tiles.map((tile) => tile.max_x))).toBe(selection.max_x)
expect(Math.max(...tiles.map((tile) => tile.max_y))).toBe(selection.max_y)
for (const tile of tiles) {
const dimensions = selectionDimensions(tile)
expect(dimensions.widthMetres).toBeLessThanOrEqual(18_100)
expect(dimensions.heightMetres).toBeLessThanOrEqual(18_100)
}
})
it('refuses an unbounded detail fan-out', () => {
const selection: VectorSelectionBBox = {
min_x: 4,
min_y: 50,
max_x: 6,
max_y: 52,
crs: 'EPSG:4326',
}
expect(() => splitSelectionBbox(selection)).toThrow('detailpartities')
})
})
@@ -73,6 +73,63 @@ export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): num
return Math.max(0, widthMetres * heightMetres)
}
export interface SelectionDimensions {
widthMetres: number
heightMetres: number
areaSquareMetres: number
}
export type SelectionAnalysisScale = 'detail' | 'regional' | 'overview'
export function selectionDimensions(bbox: VectorSelectionBBox): SelectionDimensions {
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = Math.max(0, (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians))
const heightMetres = Math.max(0, (bbox.max_y - bbox.min_y) * 110_574)
return {
widthMetres,
heightMetres,
areaSquareMetres: widthMetres * heightMetres,
}
}
export function selectionAnalysisScale(bbox: VectorSelectionBBox): SelectionAnalysisScale {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const longestSide = Math.max(widthMetres, heightMetres)
if (longestSide <= 20_000) return 'detail'
if (longestSide <= 50_000) return 'regional'
return 'overview'
}
export function splitSelectionBbox(
bbox: VectorSelectionBBox,
maxTileSideMetres = 18_000,
maxTiles = 16,
): VectorSelectionBBox[] {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const columns = Math.max(1, Math.ceil(widthMetres / maxTileSideMetres))
const rows = Math.max(1, Math.ceil(heightMetres / maxTileSideMetres))
if (columns * rows > maxTiles) {
throw new Error(
`De selectie vereist ${columns * rows} detailpartities; maximaal ${maxTiles} zijn toegestaan.`,
)
}
const longitudeStep = (bbox.max_x - bbox.min_x) / columns
const latitudeStep = (bbox.max_y - bbox.min_y) / rows
const tiles: VectorSelectionBBox[] = []
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
tiles.push({
min_x: bbox.min_x + longitudeStep * column,
min_y: bbox.min_y + latitudeStep * row,
max_x: column === columns - 1 ? bbox.max_x : bbox.min_x + longitudeStep * (column + 1),
max_y: row === rows - 1 ? bbox.max_y : bbox.min_y + latitudeStep * (row + 1),
crs: 'EPSG:4326',
})
}
}
return tiles
}
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) {
return false
@@ -26,6 +26,7 @@ export interface MapThemeQuery<TThemeId extends string> {
dataset?: DatasetCreateResponse
partitioned?: boolean
acquisition?: MapThemeAcquisition
acquisitionBboxes?: VectorSelectionBBox[]
}
export interface MapThemeInsight<TThemeId extends string> {
@@ -103,51 +104,63 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
const settled = await settleWithConcurrency(
queries,
3,
async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => {
async ({ themeId, dataset: existingDataset, partitioned, acquisition, acquisitionBboxes }) => {
let dataset = existingDataset
let acquiredDatasets: DatasetCreateResponse[] = []
if (acquisition) {
const commonPayload = {
bbox,
area_id: areaId,
force_refresh: false,
}
const acquisitionJob = acquisition.kind === 'thematic_raster'
? await datasetsApi.acquireThematicRaster(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey,
})
: acquisition.kind === 'dhmv'
? await datasetsApi.acquireDhmv(selectedProjectId, {
const requestedBboxes = acquisitionBboxes?.length ? acquisitionBboxes : [bbox]
const acquisitionResults = await settleWithConcurrency(requestedBboxes, 1, async (acquisitionBbox) => {
const commonPayload = {
bbox: acquisitionBbox,
area_id: areaId,
force_refresh: false,
}
const acquisitionJob = acquisition.kind === 'thematic_raster'
? await datasetsApi.acquireThematicRaster(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey as 'dtm_1m' | 'dsm_1m',
product_key: acquisition.productKey,
})
: acquisition.kind === 'flood_hazard'
? await datasetsApi.acquireFloodHazard(selectedProjectId, {
: acquisition.kind === 'dhmv'
? await datasetsApi.acquireDhmv(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey,
product_key: acquisition.productKey as 'dtm_1m' | 'dsm_1m',
})
: acquisition.kind === 'grb'
? await datasetsApi.acquireGrb(selectedProjectId, {
: acquisition.kind === 'flood_hazard'
? await datasetsApi.acquireFloodHazard(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
product_key: acquisition.productKey,
})
: acquisition.kind === 'bathymetry_profiles'
? await datasetsApi.acquireBathymetryProfiles(selectedProjectId, commonPayload)
: await datasetsApi.acquireOfficialVector(selectedProjectId, {
: acquisition.kind === 'grb'
? await datasetsApi.acquireGrb(selectedProjectId, {
...commonPayload,
product_key: acquisition.productKey,
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
})
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error(
acquisitionJob.error_message
|| `De officiële kaartbron ${acquisition.displayName} kon niet worden ingeladen.`,
)
: 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
|| `De officiële kaartbron ${acquisition.displayName} kon niet worden ingeladen.`,
)
}
return datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)
})
const failedAcquisition = acquisitionResults.find((item) => item.status === 'rejected')
if (failedAcquisition?.status === 'rejected') {
throw failedAcquisition.reason
}
dataset = await datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)
acquiredDatasets = acquisitionResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : [])
dataset = acquiredDatasets[0]
}
if (!dataset) {
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
}
const acquiredDatasetIds = acquiredDatasets.map((item) => item.id)
const acquiredAsPartitions = acquiredDatasetIds.length > 1
return {
themeId,
dataset,
@@ -155,11 +168,12 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
acquisition,
result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
? terrainSelectionToMapSelection(
partitioned
partitioned || acquiredAsPartitions
? await datasetsApi.selectTerrainPartitions(selectedProjectId, {
bbox,
area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'dtm_1m'),
...(acquiredAsPartitions ? { dataset_ids: acquiredDatasetIds } : {}),
})
: await datasetsApi.selectTerrain(selectedProjectId, dataset.id, {
bbox,
@@ -168,11 +182,12 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
)
: dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard'
? floodHazardSelectionToMapSelection(
partitioned
partitioned || acquiredAsPartitions
? await datasetsApi.selectFloodHazardPartitions(selectedProjectId, {
bbox,
area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'pluviaal_current_t100'),
...(acquiredAsPartitions ? { dataset_ids: acquiredDatasetIds } : {}),
})
: await datasetsApi.selectFloodHazard(selectedProjectId, dataset.id, {
bbox,
@@ -189,6 +204,13 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
bbox,
area_id: areaId,
}))
: acquiredAsPartitions && dataset.dataset_type !== 'raster'
? await datasetsApi.selectVectorFeaturePartitions(selectedProjectId, {
dataset_ids: acquiredDatasetIds,
bbox,
area_id: areaId,
limit: 1000,
})
: dataset.source_name === 'vmm_vha_bathymetry_profiles' && partitioned
? await datasetsApi.selectBathymetryProfilePartitions(selectedProjectId, {
bbox,
+45 -1
View File
@@ -3,11 +3,13 @@ import { datasetsApi, externalApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type {
BathymetrySourceRead,
CoverageResolveResponse,
DhmvProductRead,
FloodHazardProductRead,
GrbProductRead,
OfficialVectorProductRead,
ThematicRasterProductRead,
VectorSelectionBBox,
} from '../types'
export interface OfficialMapProducts {
@@ -104,5 +106,47 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
[selectedProjectId],
)
return { products, loading, error, resolveCoverage }
const resolveCoveragePartitions = useCallback(
async (bboxes: VectorSelectionBBox[]): Promise<Array<{
bbox: VectorSelectionBBox
coverage: CoverageResolveResponse
}> | null> => {
if (!selectedProjectId) {
setError('Selecteer eerst een werkruimte.')
return null
}
try {
const resolved: Array<{
bbox: VectorSelectionBBox
coverage: CoverageResolveResponse
}> = []
for (let offset = 0; offset < bboxes.length; offset += 4) {
const batch = bboxes.slice(offset, offset + 4)
resolved.push(...await Promise.all(batch.map(async (bbox) => ({
bbox,
coverage: await externalApi.resolveCoverage({
projectId: selectedProjectId,
bbox: {
minx: bbox.min_x,
miny: bbox.min_y,
maxx: bbox.max_x,
maxy: bbox.max_y,
},
}),
}))))
}
setError(null)
return resolved
} catch (requestError) {
setError(formatError(
requestError,
'De regionale dekking kon niet voor alle bronpartities worden bepaald.',
))
return null
}
},
[selectedProjectId],
)
return { products, loading, error, resolveCoverage, resolveCoveragePartitions }
}
+10 -2
View File
@@ -171,7 +171,7 @@ export const datasetsApi = {
apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/select`, payload),
selectTerrainPartitions: (
projectId: string,
payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string; product_key: string },
payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string; product_key: string; dataset_ids?: string[] },
): Promise<TerrainSelectionResponse> =>
apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/terrain/select`, payload),
acquireFloodHazard: (projectId: string, payload: FloodHazardAcquireRequest): Promise<JobRead> =>
@@ -186,7 +186,7 @@ export const datasetsApi = {
apiPost<FloodHazardSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/select`, payload),
selectFloodHazardPartitions: (
projectId: string,
payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string; product_key: string },
payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string; product_key: string; dataset_ids?: string[] },
): Promise<FloodHazardSelectionResponse> =>
apiPost<FloodHazardSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/flood-hazard/select`, payload),
listBathymetrySources: (projectId: string): Promise<{ items: BathymetrySourceRead[]; total: number }> =>
@@ -244,6 +244,14 @@ export const datasetsApi = {
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload),
selectVectorFeaturePartitions: (
projectId: string,
payload: VectorSelectionRequest & { dataset_ids: string[] },
): Promise<VectorSelectionResponse> =>
apiPost<VectorSelectionResponse>(
`/api/v1/projects/${projectId}/datasets/vector/partitions/select`,
payload,
),
deriveVectorSelection: (projectId: string, datasetId: string, payload: VectorSelectionDeriveRequest): Promise<DatasetCreateResponse> =>
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload),
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>