fix(map): make area analysis scale-aware
This commit is contained in:
@@ -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} thema’s 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
|
||||
|
||||
Reference in New Issue
Block a user