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>