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
@@ -32,6 +32,37 @@ class TemporalAnalysisService:
"provision_regional_grb_context.py",
}
@staticmethod
def _canonical_observation_snapshots(datasets: list[Dataset]) -> list[Dataset]:
by_observation: dict[datetime, Dataset] = {}
for dataset in datasets:
if dataset.observed_at is None:
continue
current = by_observation.get(dataset.observed_at)
dataset_recency = max(
(
value.timestamp()
for value in (dataset.imported_at, dataset.updated_at, dataset.created_at)
if value is not None
),
default=0.0,
)
current_recency = max(
(
value.timestamp()
for value in (
getattr(current, "imported_at", None),
getattr(current, "updated_at", None),
getattr(current, "created_at", None),
)
if value is not None
),
default=0.0,
)
if current is None or (dataset_recency, str(dataset.id)) > (current_recency, str(current.id)):
by_observation[dataset.observed_at] = dataset
return sorted(by_observation.values(), key=lambda item: item.observed_at)
@staticmethod
def list_series(db: Session, project_id: UUID) -> list[TemporalSeriesRead]:
rows = (
@@ -49,6 +80,7 @@ class TemporalAnalysisService:
result: list[TemporalSeriesRead] = []
for key, datasets in grouped.items():
datasets = TemporalAnalysisService._canonical_observation_snapshots(datasets)
observed = [item.observed_at for item in datasets if item.observed_at is not None]
if not observed:
continue
@@ -118,6 +150,13 @@ class TemporalAnalysisService:
bbox,
selection_area.geometry,
)
def is_preclipped_to_selection_area(dataset: Dataset) -> bool:
return bool(
selection_area
and VectorFeatureService.can_use_full_area_fast_path(dataset, selection_area.id)
)
summaries: dict[UUID, dict[str, Any]] = {}
def summarize(dataset: Dataset) -> dict[str, Any]:
@@ -126,14 +165,9 @@ class TemporalAnalysisService:
return cached
kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox}
if selection_area is not None:
kwargs["selection_geometry"] = selection_geometry
kwargs["full_dataset_area"] = (
selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(
dataset,
selection_area.id,
)
)
dataset_is_preclipped = is_preclipped_to_selection_area(dataset)
kwargs["selection_geometry"] = None if dataset_is_preclipped else selection_geometry
kwargs["full_dataset_area"] = selection_covers_full_area and dataset_is_preclipped
summary = VectorFeatureService.summarize_features_by_bbox(db, **kwargs)
summaries[dataset.id] = summary
return summary
@@ -164,16 +198,20 @@ class TemporalAnalysisService:
later=later,
bbox=bbox,
preview_limit=payload.preview_limit,
selection_geometry=selection_geometry,
selection_geometry=(
None
if is_preclipped_to_selection_area(earlier) and is_preclipped_to_selection_area(later)
else selection_geometry
),
earlier_full_dataset_area=(
selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(earlier, selection_area.id)
and is_preclipped_to_selection_area(earlier)
if selection_area is not None
else False
),
later_full_dataset_area=(
selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(later, selection_area.id)
and is_preclipped_to_selection_area(later)
if selection_area is not None
else False
),
@@ -298,8 +336,7 @@ class TemporalAnalysisService:
)
else:
datasets = fallback_datasets
unique = {dataset.id: dataset for dataset in datasets}
ordered = sorted(unique.values(), key=lambda item: item.observed_at or datetime.min.replace(tzinfo=timezone.utc))
ordered = TemporalAnalysisService._canonical_observation_snapshots(datasets)
observations: list[TemporalObservation] = []
for dataset in ordered:
if dataset.observed_at is None:
@@ -133,6 +133,20 @@ def temporal_dataset(*, project_id, observed_year: int, metric_method: str = "fe
)
def test_temporal_series_keeps_only_latest_snapshot_per_observation_date() -> None:
project_id = uuid4()
old = temporal_dataset(project_id=project_id, observed_year=2025)
old.imported_at = datetime(2026, 7, 19, tzinfo=timezone.utc)
latest = temporal_dataset(project_id=project_id, observed_year=2025)
latest.imported_at = datetime(2026, 7, 21, tzinfo=timezone.utc)
earlier = temporal_dataset(project_id=project_id, observed_year=2022)
earlier.imported_at = datetime(2026, 7, 21, tzinfo=timezone.utc)
canonical = TemporalAnalysisService._canonical_observation_snapshots([old, latest, earlier])
assert [dataset.id for dataset in canonical] == [earlier.id, latest.id]
def governed_grb_dataset(*, project_id, observed_day: int) -> Dataset:
dataset = temporal_dataset(project_id=project_id, observed_year=2026, metric_method="intersection_area")
dataset.observed_at = datetime(2026, 7, observed_day, tzinfo=timezone.utc)
@@ -16,7 +16,7 @@ def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> No
assert "activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME" in workspace
assert "new Map<DataThemeId, OnDemandMapProduct>" in workspace
assert "'Op aanvraag'" in workspace
assert "'Automatisch'" in workspace
assert "theme.id === 'space_occupation'" in workspace
assert "setActiveThemeId(fallbackTheme.id)" in workspace
assert "return `referentiejaar ${observationYear}`" in workspace
@@ -391,6 +391,6 @@ def test_grb_frontend_and_contracts_use_only_the_governed_backend_path() -> None
assert "result[product.key] = null" in workspace
assert ": onDemandThemeActive\n ? null\n : mapFeatureCollection" in workspace
assert "onSetContextLayerLabel" in workspace
assert "'Op aanvraag'" in workspace
assert "'Automatisch'" in workspace
assert "/datasets/grb/acquire" in contracts
assert "geo.api.vlaanderen.be" not in workspace
+74
View File
@@ -0,0 +1,74 @@
# GeoIntel data coverage status
Status date: 2026-07-21
This document is the operational interpretation of the source registry. It
does not replace the legal/source provenance stored with each Dataset.
## Status labels in the workbench
- `Beschikbaar`: a persisted Dataset covers the selection and can be queried
immediately.
- `Automatisch`: an audited official source contract exists. GeoIntel obtains
and persists only the bounded selection when analysis starts.
- `Alleen huidig`: one official observation exists, so no honest evolution can
be calculated.
- `Ontbreekt`: no operational source contract covers that theme and zone.
`Automatisch` therefore does not mean unavailable or simulated. It means that
the source is materialized on first use and reused afterwards. AI training
cannot replace missing official GIS observations and is not used to fabricate
coverage or historical dates.
## Current operational coverage
| Zone | Persisted or bounded operational sources | Historical comparison |
| --- | --- | --- |
| Belgium | NGI administrative boundaries; Statbel population/statistical sectors | Statbel population 2021-2025 |
| Flanders | GRB buildings, roads, water and parcels; DHMV terrain/surface; VMM flood scenarios; BWK/Natura 2000; DOV soil; policy rasters for space, open space, accessibility and services; agriculture and orthophoto where governed | Population 2021-2025; land-use/land-cover series where retained; agriculture editions; historical maps/orthophotos where the selected product has a real observation date |
| Wallonia | Bounded PICC buildings, roads and hydrography; governed SPW bed-elevation/bathymetry products | No general cross-theme regional history yet |
| Brussels | Bounded UrbIS buildings, street axes and cadastral parcels | No general cross-theme regional history yet |
| Belgian North Sea | RBINS reporting units; Marine Spatial Plan 2026-2034; governed MDK bathymetry only when runtime acquisition is explicitly configured | No multi-epoch bathymetry or marine-plan trend yet |
Mol and the Kempen are golden regression areas. Their persisted partitions are
not national coverage. A partition is only selected when its recorded
`bbox_epsg4326` intersects the drawn rectangle; otherwise GeoIntel uses an
applicable bounded official source or reports the theme as unsupported.
## Priority coverage gaps
1. Govern the public Walloon WALOUS land-cover editions (including the
published 2018/2020 change product) as a real regional time series. A class
crosswalk is required because the older COSW 2005/2007 methodology differs.
Official catalogue:
`https://geoportail.wallonie.be/catalogue-donnees?search-text=occupation+du+sol`.
2. Govern the current public Walloon flood-hazard vector/raster products and
retain their model scenario semantics separately from observed floods.
Official record:
`https://geoportail.wallonie.be/catalogue/14084108-2c7b-4091-b62d-ff0fc235213a.html`.
3. Add the public UrbIS Land Cover product (regional situation 2024) for
Brussels through its official WFS/download contract. Keep it separate from
cadastral parcels and buildings. Product specification:
`https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_FR20240401.pdf`.
4. Add a common Belgium-wide topographic baseline with normalized theme
semantics across NGI, Flanders, Wallonia and Brussels.
5. Govern comparable Walloon and Brussels historical editions before exposing
evolution for buildings, roads, land cover, soil, elevation or flood risk.
6. Add nationally comparable land-cover history with explicit class crosswalks
and uncertainty; never compare incompatible legends silently.
7. Add multi-epoch marine bathymetry and survey-footprint metadata before
presenting seabed evolution.
8. Expand persisted raster partition manifests beyond the regression regions
only where repeated use justifies caching; bounded acquisition remains the
default for one-off selections.
9. Add source freshness probes only for publishers with stable official edition
contracts. Do not infer a new observation from an import or HTTP date.
## Acceptance rules for a new source
A source is visible as operational only after its licence, authority,
observation time, CRS, spatial coverage, schema, units and limitations are
validated. It must persist through DatasetService and the canonical feature or
raster flow, retain provenance/checksums, return semantic metrics, and have a
selection-level regression test. Historical support additionally requires at
least two distinct, methodologically comparable official observation dates.
+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,
@@ -206,9 +204,9 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
bbox,
area_id: areaId,
}))
: acquiredAsPartitions && dataset.dataset_type !== 'raster'
: selectedDatasetIds.length > 1 && dataset.dataset_type !== 'raster'
? await datasetsApi.selectVectorFeaturePartitions(selectedProjectId, {
dataset_ids: acquiredDatasetIds,
dataset_ids: selectedDatasetIds,
bbox,
area_id: areaId,
limit: resultLimit,
@@ -223,8 +221,23 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
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] : []))