fix(map): honor raster coverage and temporal dates
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-22 00:18:55 +02:00
parent 80df5e70c9
commit 919f5879e5
9 changed files with 329 additions and 73 deletions
+72 -26
View File
@@ -21,6 +21,8 @@ import {
bboxToInputState,
bboxesEqual,
copyText,
datasetIntersectsSelection,
deduplicateTemporalSnapshots,
downloadJsonFile,
formatArea,
formatBboxLabel,
@@ -571,13 +573,11 @@ function listThemeTemporalSeries(datasets: DatasetCreateResponse[], theme: DataT
groups.set(dataset.temporal_series_key, items)
}
return Array.from(groups.entries())
.filter(([, items]) => items.length >= 2)
.map(([key, items]) => {
const ordered = [...items].sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
const ordered = deduplicateTemporalSnapshots(items)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.filter((group) => group.items.length >= 2)
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
@@ -1048,7 +1048,7 @@ export function MapWorkspace({
productKey: product.key,
displayName: product.display_name,
theme: product.theme,
availabilityLabel: `${product.native_resolution_m} m · ${product.observation_year} · laad bij selectie`,
availabilityLabel: `${product.native_resolution_m} m · ${product.observation_year} · automatisch bij selectie`,
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
@@ -1059,7 +1059,7 @@ export function MapWorkspace({
productKey: product.key,
displayName: product.display_name,
theme: product.key,
availabilityLabel: 'officiële vectorbron · laad bij selectie',
availabilityLabel: 'officiële vectorbron · automatisch bij selectie',
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
@@ -1075,7 +1075,7 @@ export function MapWorkspace({
productKey: source.key,
displayName: source.display_name,
theme: 'bathymetry',
availabilityLabel: 'historische profielpunten · laad bij selectie',
availabilityLabel: 'historische profielpunten · automatisch bij selectie',
attribution: source.attribution,
limitationMessage: source.limitation_message,
})
@@ -1089,7 +1089,7 @@ export function MapWorkspace({
productKey: product.key,
displayName: product.display_name,
theme: product.theme,
availabilityLabel: `${product.observation_label} · officiële vectorbron · laad bij selectie`,
availabilityLabel: `${product.observation_label} · officiële vectorbron · automatisch bij selectie`,
attribution: product.attribution,
limitationMessage: product.limitation_message,
})
@@ -1103,7 +1103,7 @@ export function MapWorkspace({
productKey: dhmvProduct.key,
displayName: dhmvProduct.display_name,
theme: 'elevation',
availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · laad bij selectie`,
availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · automatisch bij selectie`,
attribution: dhmvProduct.attribution,
limitationMessage: dhmvProduct.limitation_message,
})
@@ -1119,7 +1119,7 @@ export function MapWorkspace({
productKey: floodProduct.key,
displayName: floodProduct.display_name,
theme: 'flood_hazard',
availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · laad bij selectie`,
availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · automatisch bij selectie`,
attribution: floodProduct.attribution,
limitationMessage: floodProduct.limitation_message,
})
@@ -1329,6 +1329,19 @@ export function MapWorkspace({
const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey)
?? activeTemporalSeriesGroups[0]
const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES
const earlierTemporalOptions = activeTemporalSeries.slice(0, -1)
const selectedEarlierSnapshot = activeTemporalSeries.find((dataset) => dataset.id === earlierDatasetId)
const selectedLaterSnapshot = activeTemporalSeries.find((dataset) => dataset.id === laterDatasetId)
const selectedEarlierTime = new Date(selectedEarlierSnapshot?.observed_at ?? 0).getTime()
const laterTemporalOptions = activeTemporalSeries.filter(
(dataset) => new Date(dataset.observed_at ?? 0).getTime() > selectedEarlierTime,
)
const temporalSelectionValid = Boolean(
selectedEarlierSnapshot
&& selectedLaterSnapshot
&& selectedEarlierSnapshot.id !== selectedLaterSnapshot.id
&& selectedEarlierTime < new Date(selectedLaterSnapshot.observed_at ?? 0).getTime(),
)
const activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2
&& activeTemporalSeries.every((dataset) => dataset.source_name === 'grb')
&& new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime()
@@ -1378,7 +1391,7 @@ export function MapWorkspace({
activeSelectionResult.total_feature_count
?? activeSelectionResult.feature_count
).toLocaleString('nl-BE')} objecten gemeten`
: 'Op aanvraag'
: 'Automatisch bij selectie'
: null
onSetContextLayerLabel(contextLayerLabel)
return () => onSetContextLayerLabel(null)
@@ -1841,13 +1854,26 @@ export function MapWorkspace({
const resultFeatureLimit = selectionFeatureLimit(bbox)
for (const theme of DATA_THEMES) {
const dataset = themeDatasetMap[theme.id]
if (dataset && persistedDatasetSupportsSelection(dataset, bbox)) {
const partitioned = Boolean(
dataset
&& regionalScopeSelected
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
)
const coveringPartitions = partitioned
? themePartitionMap[theme.id].filter((partition) => datasetIntersectsSelection(partition, bbox))
: []
const persistedCoverageAvailable = Boolean(
dataset
&& persistedDatasetSupportsSelection(dataset, bbox)
&& (!partitioned || coveringPartitions.length > 0),
)
if (dataset && persistedCoverageAvailable) {
availableThemes.push({
themeId: theme.id,
dataset,
datasetIds: coveringPartitions.map((partition) => partition.id),
featureLimit: resultFeatureLimit,
partitioned: regionalScopeSelected
&& (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)),
partitioned,
})
continue
}
@@ -1877,16 +1903,20 @@ export function MapWorkspace({
const startedAt = Date.now()
setMapAnalysisDurationMs(null)
setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
const tasks: Array<Promise<unknown>> = analysisMode === 'current'
? [loadAllThemeResults(bbox, areaId)]
: []
const activeDatasetSupportsSelection = !activeThemeDataset
|| persistedDatasetSupportsSelection(activeThemeDataset, bbox)
if (
activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive
analysisMode === 'current'
&& advancedMode
&& activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive
&& activeDatasetSupportsSelection
) {
tasks.push(onRunMapSelectionExtract(bbox, areaId))
}
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
if (analysisMode === 'evolution' && temporalSelectionValid) {
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
}
try {
@@ -1899,7 +1929,7 @@ export function MapWorkspace({
}
const runTemporalComparison = () => {
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
if (!mapSelectionBbox || !temporalSelectionValid) {
return
}
void compareTemporalSnapshots(
@@ -2127,7 +2157,7 @@ export function MapWorkspace({
? 'Alleen huidige toestand'
: 'Bron nog niet ingeladen'
: dataset
? datasetAvailabilityLabel(dataset, partitions)
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
: onDemandProduct
? onDemandProduct.availabilityLabel
: 'Bron nog niet ingeladen'}
@@ -2138,7 +2168,7 @@ export function MapWorkspace({
? 'Laden'
: analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Op aanvraag' : 'Ontbreekt'}
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Automatisch' : 'Ontbreekt'}
</i>
</button>
)
@@ -2172,7 +2202,7 @@ export function MapWorkspace({
: regionalBathymetryThemeActive
? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd`
: activeThemeDataset
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}`
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}${onDemandProductMap.get(activeTheme.id) ? ' · ontbrekende lokale dekking wordt automatisch aangevuld' : ''}`
: activeOnDemandMapProduct
? `${activeOnDemandMapProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen`
: activeTheme.description}
@@ -2286,16 +2316,32 @@ export function MapWorkspace({
) : null}
<label>
Van
<select value={earlierDatasetId} onChange={(event) => { setEarlierDatasetId(event.target.value); clearTemporalComparison() }} disabled={activeTemporalSeries.length < 2}>
{activeTemporalSeries.map((dataset) => (
<select
value={earlierDatasetId}
onChange={(event) => {
const nextEarlierId = event.target.value
const nextEarlier = activeTemporalSeries.find((dataset) => dataset.id === nextEarlierId)
setEarlierDatasetId(nextEarlierId)
if (
nextEarlier
&& new Date(selectedLaterSnapshot?.observed_at ?? 0).getTime()
<= new Date(nextEarlier.observed_at ?? 0).getTime()
) {
setLaterDatasetId(activeTemporalSeries[activeTemporalSeries.length - 1]?.id ?? '')
}
clearTemporalComparison()
}}
disabled={earlierTemporalOptions.length === 0}
>
{earlierTemporalOptions.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
))}
</select>
</label>
<label>
Naar
<select value={laterDatasetId} onChange={(event) => { setLaterDatasetId(event.target.value); clearTemporalComparison() }} disabled={activeTemporalSeries.length < 2}>
{activeTemporalSeries.map((dataset) => (
<select value={laterDatasetId} onChange={(event) => { setLaterDatasetId(event.target.value); clearTemporalComparison() }} disabled={laterTemporalOptions.length === 0}>
{laterTemporalOptions.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
))}
</select>
@@ -2303,7 +2349,7 @@ export function MapWorkspace({
<button
className="primary-action"
type="button"
disabled={!mapSelectionBbox || !earlierDatasetId || !laterDatasetId || temporalComparisonLoading}
disabled={!mapSelectionBbox || !temporalSelectionValid || temporalComparisonLoading}
onClick={runTemporalComparison}
>
{temporalComparisonLoading ? 'Vergelijken…' : 'Vergelijk periode'}
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
bboxesEqual,
datasetIntersectsSelection,
deduplicateTemporalSnapshots,
isMunicipalityAreaName,
isSelectionBoundedDataset,
normalizeBboxFromCorners,
@@ -160,4 +162,31 @@ describe('map workspace selection guards', () => {
expect(selectionFeatureLimit(overview)).toBe(25)
expect(selectionFeatureLimit({ ...overview, min_x: 5, max_x: 5.1, min_y: 51, max_y: 51.1 })).toBe(1000)
})
it('uses persisted raster partitions only where their recorded bounds overlap', () => {
const molPartition = { source_metadata: { bbox_epsg4326: [5.03, 51.15, 5.24, 51.32] } }
expect(datasetIntersectsSelection(molPartition, {
min_x: 5.08,
min_y: 51.17,
max_x: 5.12,
max_y: 51.2,
crs: 'EPSG:4326',
})).toBe(true)
expect(datasetIntersectsSelection(molPartition, {
min_x: 4.3,
min_y: 50.8,
max_x: 4.4,
max_y: 50.9,
crs: 'EPSG:4326',
})).toBe(false)
})
it('counts one canonical temporal snapshot per official observation date', () => {
const snapshots = deduplicateTemporalSnapshots([
{ id: 'old-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-19T00:00:00Z' },
{ id: 'new-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
{ id: 'year-2022', observed_at: '2022-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
])
expect(snapshots.map((dataset) => dataset.id)).toEqual(['year-2022', 'new-2025'])
})
})
@@ -117,6 +117,49 @@ export function persistedDatasetSupportsSelection(
return scale === 'detail'
}
export function datasetIntersectsSelection(
dataset: { source_metadata?: Record<string, unknown> | null },
bbox: VectorSelectionBBox,
): boolean {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
if (!Array.isArray(bounds) || bounds.length !== 4) {
return true
}
const [minX, minY, maxX, maxY] = bounds.map(Number)
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return true
}
return !(
bbox.max_x < minX
|| bbox.min_x > maxX
|| bbox.max_y < minY
|| bbox.min_y > maxY
)
}
export function deduplicateTemporalSnapshots<
T extends {
id: string
observed_at?: string | null
imported_at?: string | null
created_at?: string | null
},
>(datasets: T[]): T[] {
const byObservation = new Map<string, T>()
for (const dataset of datasets) {
if (!dataset.observed_at) continue
const current = byObservation.get(dataset.observed_at)
const recency = new Date(dataset.imported_at ?? dataset.created_at ?? 0).getTime()
const currentRecency = new Date(current?.imported_at ?? current?.created_at ?? 0).getTime()
if (!current || recency > currentRecency || (recency === currentRecency && dataset.id > current.id)) {
byObservation.set(dataset.observed_at, dataset)
}
}
return Array.from(byObservation.values()).sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
}
export function selectionFeatureLimit(bbox: VectorSelectionBBox): number {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return 25
@@ -24,6 +24,7 @@ export interface MapThemeAcquisition {
export interface MapThemeQuery<TThemeId extends string> {
themeId: TThemeId
dataset?: DatasetCreateResponse
datasetIds?: string[]
partitioned?: boolean
acquisition?: MapThemeAcquisition
acquisitionBboxes?: VectorSelectionBBox[]
@@ -102,10 +103,11 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
setThemeInsightsLoading(true)
setThemeInsightsError(null)
try {
const queryOrder = new Map(queries.map((query, index) => [query.themeId, index]))
const settled = await settleWithConcurrency(
queries,
3,
async ({ themeId, dataset: existingDataset, partitioned, acquisition, acquisitionBboxes, featureLimit }) => {
async ({ themeId, dataset: existingDataset, datasetIds, partitioned, acquisition, acquisitionBboxes, featureLimit }) => {
let dataset = existingDataset
let acquiredDatasets: DatasetCreateResponse[] = []
const resultLimit = featureLimit ?? 1000
@@ -162,20 +164,16 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
}
const acquiredDatasetIds = acquiredDatasets.map((item) => item.id)
const selectedDatasetIds = acquiredDatasetIds.length > 0 ? acquiredDatasetIds : datasetIds ?? []
const acquiredAsPartitions = acquiredDatasetIds.length > 1
return {
themeId,
dataset,
partitioned,
acquisition,
result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
const result = dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
? terrainSelectionToMapSelection(
partitioned || acquiredAsPartitions
? await datasetsApi.selectTerrainPartitions(selectedProjectId, {
bbox,
area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'dtm_1m'),
...(acquiredAsPartitions ? { dataset_ids: acquiredDatasetIds } : {}),
...(selectedDatasetIds.length > 0 ? { dataset_ids: selectedDatasetIds } : {}),
})
: await datasetsApi.selectTerrain(selectedProjectId, dataset.id, {
bbox,
@@ -189,7 +187,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
bbox,
area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'pluviaal_current_t100'),
...(acquiredAsPartitions ? { dataset_ids: acquiredDatasetIds } : {}),
...(selectedDatasetIds.length > 0 ? { dataset_ids: selectedDatasetIds } : {}),
})
: await datasetsApi.selectFloodHazard(selectedProjectId, dataset.id, {
bbox,
@@ -201,30 +199,45 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
bbox,
area_id: areaId,
}))
: dataset.dataset_type === 'raster' && dataset.source_name === 'spw_bathymetry'
? bathymetryRasterSelectionToMapSelection(await datasetsApi.selectBathymetryRaster(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}))
: acquiredAsPartitions && dataset.dataset_type !== 'raster'
? await datasetsApi.selectVectorFeaturePartitions(selectedProjectId, {
dataset_ids: acquiredDatasetIds,
bbox,
area_id: areaId,
limit: resultLimit,
})
: dataset.source_name === 'vmm_vha_bathymetry_profiles' && partitioned
? await datasetsApi.selectBathymetryProfilePartitions(selectedProjectId, {
bbox,
area_id: areaId,
limit: resultLimit,
})
: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
limit: resultLimit,
}),
: dataset.dataset_type === 'raster' && dataset.source_name === 'spw_bathymetry'
? bathymetryRasterSelectionToMapSelection(await datasetsApi.selectBathymetryRaster(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
}))
: selectedDatasetIds.length > 1 && dataset.dataset_type !== 'raster'
? await datasetsApi.selectVectorFeaturePartitions(selectedProjectId, {
dataset_ids: selectedDatasetIds,
bbox,
area_id: areaId,
limit: resultLimit,
})
: dataset.source_name === 'vmm_vha_bathymetry_profiles' && partitioned
? await datasetsApi.selectBathymetryProfilePartitions(selectedProjectId, {
bbox,
area_id: areaId,
limit: resultLimit,
})
: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
limit: resultLimit,
})
const insight: MapThemeInsight<TThemeId> = {
themeId,
dataset,
partitioned,
acquisition,
result,
}
if (requestSequence.current === sequence) {
setThemeInsights((current) => (
[...current.filter((item) => item.themeId !== themeId), insight]
.sort(
(left, right) => (queryOrder.get(left.themeId) ?? 0) - (queryOrder.get(right.themeId) ?? 0),
)
))
}
return insight
},
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))