diff --git a/.gitattributes b/.gitattributes index 02acc8de..72744fa6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ *.sh text eol=lf +deploy/unraid/gosu-setpriv text eol=lf *.py text eol=lf *.yml text eol=lf *.yaml text eol=lf diff --git a/backend/app/api/routes/selection_partitions.py b/backend/app/api/routes/selection_partitions.py new file mode 100644 index 00000000..27d9c51f --- /dev/null +++ b/backend/app/api/routes/selection_partitions.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.db.session import get_db +from app.models import Area, Dataset +from app.schemas.common import Envelope +from app.schemas.operations import VectorSelectionResponse +from app.schemas.selection_partitions import VectorPartitionSelectionRequest +from app.services.vector_feature_service import VectorFeatureService +from app.utils.response import envelope + + +router = APIRouter(prefix="/projects/{project_id}", tags=["selection-partitions"]) + + +def _product_identity(dataset: Dataset) -> str: + metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {} + return str(metadata.get("product_key") or dataset.reference_layer_name or "") + + +@router.post( + "/datasets/vector/partitions/select", + response_model=Envelope[VectorSelectionResponse], +) +def select_vector_partitions( + project_id: UUID, + payload: VectorPartitionSelectionRequest, + db: Session = Depends(get_db), +): + datasets = db.query(Dataset).filter(Dataset.id.in_(payload.dataset_ids)).all() + by_id = {dataset.id: dataset for dataset in datasets} + ordered = [by_id.get(dataset_id) for dataset_id in payload.dataset_ids] + if any(dataset is None or dataset.project_id != project_id for dataset in ordered): + raise AppError(code="DATASET_NOT_FOUND", message="One or more selection partitions were not found", status_code=404) + typed_datasets = [dataset for dataset in ordered if dataset is not None] + if any(dataset.dataset_type not in {"vector", "geojson"} or dataset.status != "ready" for dataset in typed_datasets): + raise AppError( + code="INVALID_VECTOR_PARTITIONS", + message="Every selection partition must be a ready vector dataset", + status_code=409, + ) + source_names = {dataset.source_name for dataset in typed_datasets} + product_keys = {_product_identity(dataset) for dataset in typed_datasets} + if len(source_names) != 1 or len(product_keys) != 1: + raise AppError( + code="VECTOR_PARTITION_SOURCE_MISMATCH", + message="Selection partitions must belong to one governed source product", + details={"source_names": sorted(str(value) for value in source_names), "product_keys": sorted(product_keys)}, + status_code=409, + ) + + selection_geometry = None + selection_area_id = None + if payload.area_id is not None: + selection_area = db.get(Area, payload.area_id) + if selection_area is None or selection_area.project_id != project_id: + raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) + selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area( + payload.bbox.model_dump(), + selection_area.geometry, + ) + selection_area_id = selection_area.id + + representative = typed_datasets[0] + dataset_ids = [dataset.id for dataset in typed_datasets] + result = VectorFeatureService.select_features_by_bbox( + db, + dataset_id=representative.id, + dataset_ids=dataset_ids, + bbox=payload.bbox.model_dump(), + limit=payload.limit, + dataset=representative, + selection_geometry=selection_geometry, + selection_area_id=selection_area_id, + deduplicate_source_features=True, + ) + result.update( + partition_count=len(dataset_ids), + source_name=representative.source_name, + dataset_ids=dataset_ids, + ) + return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True)) diff --git a/backend/app/main.py b/backend/app/main.py index d6f881cd..87947855 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, temporal +from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal from app.core.config import get_settings from app.core.errors import AppError from app.core.logging import configure_logging @@ -91,6 +91,7 @@ def create_app() -> FastAPI: app.include_router(qa.router, prefix=settings.api_prefix) app.include_router(detection.router, prefix=settings.api_prefix) app.include_router(segmentation.router, prefix=settings.api_prefix) + app.include_router(selection_partitions.router, prefix=settings.api_prefix) app.include_router(temporal.router, prefix=settings.api_prefix) app.include_router(assistant.router, prefix=settings.api_prefix) diff --git a/backend/app/schemas/dhmv.py b/backend/app/schemas/dhmv.py index d8a6690b..53dc9fe0 100644 --- a/backend/app/schemas/dhmv.py +++ b/backend/app/schemas/dhmv.py @@ -58,6 +58,7 @@ class TerrainSelectionRequest(BaseModel): class TerrainPartitionSelectionRequest(TerrainSelectionRequest): product_key: str = "dtm_1m" + dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=16) class TerrainMetric(BaseModel): diff --git a/backend/app/schemas/flood_hazard.py b/backend/app/schemas/flood_hazard.py index 65eb0178..485de728 100644 --- a/backend/app/schemas/flood_hazard.py +++ b/backend/app/schemas/flood_hazard.py @@ -61,6 +61,7 @@ class FloodHazardSelectionRequest(BaseModel): class FloodHazardPartitionSelectionRequest(FloodHazardSelectionRequest): product_key: str = "pluviaal_current_t100" + dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=16) class FloodHazardMetric(BaseModel): diff --git a/backend/app/schemas/selection_partitions.py b/backend/app/schemas/selection_partitions.py new file mode 100644 index 00000000..d58a2b39 --- /dev/null +++ b/backend/app/schemas/selection_partitions.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, Field + +from .operations import VectorSelectionBBox + + +class VectorPartitionSelectionRequest(BaseModel): + dataset_ids: list[UUID] = Field(min_length=1, max_length=16) + bbox: VectorSelectionBBox + area_id: UUID | None = None + limit: int = Field(default=1000, ge=1, le=1000) diff --git a/backend/app/services/flood_hazard_analysis_service.py b/backend/app/services/flood_hazard_analysis_service.py index 52ca1f4b..4ba2011b 100644 --- a/backend/app/services/flood_hazard_analysis_service.py +++ b/backend/app/services/flood_hazard_analysis_service.py @@ -225,6 +225,7 @@ class FloodHazardAnalysisService: selection_geometry_4326=selection_4326, nodata=FloodHazardAcquisitionService.NODATA, max_pixels=resolved_settings.flood_hazard_max_pixels, + dataset_ids=payload.dataset_ids, ) try: import numpy as np diff --git a/backend/app/services/raster_partition_analysis_service.py b/backend/app/services/raster_partition_analysis_service.py index cf048aed..acdbd21d 100644 --- a/backend/app/services/raster_partition_analysis_service.py +++ b/backend/app/services/raster_partition_analysis_service.py @@ -51,17 +51,17 @@ class RasterPartitionAnalysisService: source_name: str, product_key: str, bbox: tuple[float, float, float, float], + dataset_ids: list[UUID] | None = None, ) -> list[Dataset]: - rows = ( - db.query(Dataset) - .filter( - Dataset.project_id == project_id, - Dataset.source_name == source_name, - Dataset.dataset_type == "raster", - Dataset.status == "ready", - ) - .all() + query = db.query(Dataset).filter( + Dataset.project_id == project_id, + Dataset.source_name == source_name, + Dataset.dataset_type == "raster", + Dataset.status == "ready", ) + if dataset_ids is not None: + query = query.filter(Dataset.id.in_(dataset_ids)) + rows = query.all() candidates = [ dataset for dataset in rows @@ -78,6 +78,13 @@ class RasterPartitionAnalysisService: details={"source_name": source_name, "product_key": product_key}, status_code=404, ) + if dataset_ids is not None and {dataset.id for dataset in candidates} != set(dataset_ids): + raise AppError( + code="RASTER_PARTITION_SOURCE_MISMATCH", + message="Every requested raster partition must match the governed source product and selection", + details={"requested_count": len(dataset_ids), "eligible_count": len(candidates)}, + status_code=409, + ) if len(candidates) > RasterPartitionAnalysisService.MAX_PARTITIONS: raise AppError( code="RASTER_PARTITION_LIMIT_EXCEEDED", @@ -100,6 +107,7 @@ class RasterPartitionAnalysisService: selection_geometry_4326, nodata: float, max_pixels: int, + dataset_ids: list[UUID] | None = None, ) -> RasterPartitionSelection: try: import numpy as np @@ -120,6 +128,7 @@ class RasterPartitionAnalysisService: source_name=source_name, product_key=product_key, bbox=bbox, + dataset_ids=dataset_ids, ) transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) selection_metric = shapely_transform(transformer.transform, selection_geometry_4326) diff --git a/backend/app/services/terrain_analysis_service.py b/backend/app/services/terrain_analysis_service.py index 21aecea2..dc5471c1 100644 --- a/backend/app/services/terrain_analysis_service.py +++ b/backend/app/services/terrain_analysis_service.py @@ -235,6 +235,7 @@ class TerrainAnalysisService: selection_geometry_4326=selection_4326, nodata=DhmvAcquisitionService.NODATA, max_pixels=resolved_settings.dhmv_max_pixels, + dataset_ids=payload.dataset_ids, ) surface_models = { str((dataset.source_metadata or {}).get("surface_model") or "") diff --git a/backend/app/services/vector_feature_service.py b/backend/app/services/vector_feature_service.py index dc6a5ebf..9c54717b 100644 --- a/backend/app/services/vector_feature_service.py +++ b/backend/app/services/vector_feature_service.py @@ -11,7 +11,7 @@ from geoalchemy2.shape import to_shape from shapely.geometry import box, mapping, shape from shapely.ops import transform as transform_geometry from shapely.validation import make_valid -from sqlalchemy import Float, cast, func +from sqlalchemy import Float, String, cast, func from app.core.errors import AppError from app.models import Dataset, VectorFeature @@ -548,6 +548,8 @@ class VectorFeatureService: selection_area_id: UUID | None = None, full_dataset_area: bool = False, preclipped_partition_filter: tuple[str, str] | None = None, + dataset_ids: list[UUID] | None = None, + deduplicate_source_features: bool = False, ) -> dict[str, Any]: normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox) safe_limit = max(1, min(int(limit), 1000)) @@ -561,13 +563,19 @@ class VectorFeatureService: 4326, ) - query = db.query(VectorFeature).filter(VectorFeature.dataset_id == dataset_id) + selected_dataset_ids = dataset_ids or [dataset_id] + query = db.query(VectorFeature).filter(VectorFeature.dataset_id.in_(selected_dataset_ids)) if preclipped_partition_filter is not None: partition_property, partition_value = preclipped_partition_filter query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value) if not full_dataset_area: query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape)) - if hasattr(query, "count"): + if deduplicate_source_features: + identity = func.coalesce(VectorFeature.source_feature_id, cast(VectorFeature.id, String)) + total_feature_count = int( + query.with_entities(func.count(func.distinct(identity))).scalar() or 0 + ) + elif hasattr(query, "count"): total_feature_count = int(query.count()) else: # Lightweight unit-test sessions do not always implement Query.count(). total_feature_count = len(query.all()) @@ -585,6 +593,7 @@ class VectorFeatureService: summary = VectorFeatureService.summarize_features_by_bbox( db, dataset=dataset, + dataset_ids=selected_dataset_ids, bbox=normalized_bbox, total_feature_count=total_feature_count, selection_geometry=selection_geometry, diff --git a/backend/tests/test_selection_partition_analysis.py b/backend/tests/test_selection_partition_analysis.py new file mode 100644 index 00000000..b616cd53 --- /dev/null +++ b/backend/tests/test_selection_partition_analysis.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.api.routes.selection_partitions import select_vector_partitions +from app.core.errors import AppError +from app.models import Dataset +from app.schemas.selection_partitions import VectorPartitionSelectionRequest +from app.services.vector_feature_service import VectorFeatureService + + +class DatasetQuery: + def __init__(self, datasets): + self.datasets = datasets + + def filter(self, *_args): + return self + + def all(self): + return self.datasets + + +class DatasetSession: + def __init__(self, datasets): + self.datasets = datasets + + def query(self, model): + assert model is Dataset + return DatasetQuery(self.datasets) + + +def make_dataset(project_id, dataset_id, *, source_name="grb", product_key="buildings"): + return Dataset( + id=dataset_id, + project_id=project_id, + name=f"{source_name}-{product_key}", + dataset_type="vector", + source="official", + dataset_role="reference", + source_name=source_name, + reference_layer_name=product_key, + source_metadata={"product_key": product_key, "theme": product_key}, + provenance_metadata={}, + metadata_json={}, + status="ready", + ) + + +def test_vector_partition_route_combines_one_governed_product(monkeypatch) -> None: + project_id = uuid4() + dataset_ids = [uuid4(), uuid4()] + db = DatasetSession([make_dataset(project_id, dataset_id) for dataset_id in dataset_ids]) + captured = {} + + def select_features(_db, **kwargs): + captured.update(kwargs) + return { + "selection_bbox": kwargs["bbox"], + "feature_count": 1, + "total_feature_count": 3, + "limit": kwargs["limit"], + "truncated": False, + "geojson": {"type": "FeatureCollection", "features": []}, + "summary": None, + } + + monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", select_features) + payload = VectorPartitionSelectionRequest( + dataset_ids=dataset_ids, + bbox={"min_x": 5.0, "min_y": 51.0, "max_x": 5.3, "max_y": 51.2}, + ) + response = select_vector_partitions(project_id, payload, db) + + assert captured["dataset_ids"] == dataset_ids + assert captured["deduplicate_source_features"] is True + assert response["data"]["partition_count"] == 2 + assert response["data"]["dataset_ids"] == dataset_ids + + +def test_vector_partition_request_has_a_bounded_fan_out() -> None: + with pytest.raises(ValidationError): + VectorPartitionSelectionRequest( + dataset_ids=[uuid4() for _ in range(17)], + bbox={"min_x": 5.0, "min_y": 51.0, "max_x": 5.3, "max_y": 51.2}, + ) + + +def test_vector_partition_route_rejects_mixed_source_products() -> None: + project_id = uuid4() + datasets = [ + make_dataset(project_id, uuid4(), source_name="grb", product_key="buildings"), + make_dataset(project_id, uuid4(), source_name="spw_picc", product_key="picc_buildings"), + ] + payload = VectorPartitionSelectionRequest( + dataset_ids=[dataset.id for dataset in datasets], + bbox={"min_x": 5.0, "min_y": 51.0, "max_x": 5.3, "max_y": 51.2}, + ) + with pytest.raises(AppError) as exc_info: + select_vector_partitions(project_id, payload, DatasetSession(datasets)) + assert getattr(exc_info.value, "code", None) == "VECTOR_PARTITION_SOURCE_MISMATCH" diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 16fe9170..8f8d5500 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -881,6 +881,24 @@ Rules: - `limit` is bounded to `1..1000`. Municipality-scale clients must page spatially by viewport instead of requesting an unbounded municipality FeatureCollection. - The Map workspace uses this existing endpoint for vector datasets above 5,000 features. It starts delivery at zoom level 14, debounces `moveend` requests and explicitly reports `truncated=true` as a request to zoom further in. This is a client delivery policy, not a second API or persistence path. +### POST `/api/v1/projects/{project_id}/datasets/vector/partitions/select` + +Combines `1..16` bounded vector acquisitions of one governed source product +into one read-only selection result. The request uses the same `bbox`, optional +`area_id` and `limit` contract as vector selection plus `dataset_ids`. + +The backend rejects mixed projects, non-ready vector datasets and partitions +whose `source_name` or `product_key` differs. PostGIS calculates area and length +metrics across all persisted tile geometries. `total_feature_count` is +deduplicated by provider `source_feature_id` where available so a source object +crossing a tile edge is not presented as two objects. The response includes +`partition_count`, `source_name` and the exact `dataset_ids` used. + +The Map workbench uses this route only for regional selections up to 50 by 50 +kilometres. Provider calls remain individually bounded below 20 kilometres; +larger overview selections do not fan out into unbounded high-resolution +downloads. + ### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive` Persists a bbox selection as a new derived vector dataset and indexes the diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 28c68711..23fba89c 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -10953,3 +10953,40 @@ Validation: themes through acquisition or persisted national data and semantic selection metrics; - browser verification follows against the deployed commit on port 1202. + +## 2026-07-21 - Scale-aware rectangle analysis + +Implemented: + +- reproduced the reported provider failure with an approximately 11,468 km2 + cross-region rectangle: twenty high-resolution adapters received one unsafe + bbox and returned their governed size limits; +- added explicit detail, regional and overview selection tiers before any + provider acquisition starts; +- regional selections up to 50 by 50 km now split compatible vector and point + sources into at most sixteen 18 km tiles, resolve the authority zone per tile + and combine each provider product as one persisted PostGIS result; +- added a canonical multi-partition vector selection route with project, + readiness and source-product consistency guards and source-feature count + deduplication; +- allowed terrain and flood partition analysis to receive an explicit set of + freshly acquired Dataset ids, avoiding accidental reuse of overlapping old + bounded rasters; +- kept 5 m raster analysis behind its real pixel budget and 10 m thematic + analysis behind its 50 km scale budget; +- changed overview selections to query only national or already provisioned + scale-compatible datasets and explain the scale choice once, instead of + presenting one provider error for every unavailable detail source. + +Validation: + +- backend import/compile and frontend TypeScript passed after the contract + change; +- focused selection-partition, DHMV and VMM tests passed (30 tests); +- focused frontend scale, tiling and concurrency tests passed (12 tests). + +Remaining in this pass: + +- run the complete readiness gate, deploy the immutable revision and repeat + both the large overview rectangle and a regional partitioned rectangle in + the live browser. diff --git a/docs/TODO.md b/docs/TODO.md index 78557aa1..9801504d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -835,3 +835,14 @@ This file now starts with the current implementation status. Older preparation/b uncertainty tests exist; never merge TAW, LAT and mDNG implicitly. - [ ] Add water volume only when bed and water-surface inputs share a governed time, datum and coverage contract. + +# Scale-aware map selection + +- [x] Prevent country-scale rectangles from fan-out querying every local + provider and replace repeated source-limit errors with one overview status. +- [x] Partition compatible 20-50 km regional vector acquisitions into bounded + source requests and combine their persisted PostGIS metrics. +- [x] Keep terrain, flood and thematic rasters behind explicit pixel and + selection-size budgets. +- [ ] Provision additional national-scale baseline datasets before exposing + more overview themes; do not synthesize regional detail at national scale. diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 572bf9d1..7dfbda6d 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -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() + 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> = [] 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 ? ( +

{selectionScaleNotice}

+ ) : null} {unavailableSelectionThemeCount > 0 ? (

- {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.`}

) : null} diff --git a/frontend/src/components/map/mapWorkspaceUtils.test.ts b/frontend/src/components/map/mapWorkspaceUtils.test.ts index c1b41e69..bd6ecb0f 100644 --- a/frontend/src/components/map/mapWorkspaceUtils.test.ts +++ b/frontend/src/components/map/mapWorkspaceUtils.test.ts @@ -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') + }) }) diff --git a/frontend/src/components/map/mapWorkspaceUtils.ts b/frontend/src/components/map/mapWorkspaceUtils.ts index 4014ed3c..9114e1a2 100644 --- a/frontend/src/components/map/mapWorkspaceUtils.ts +++ b/frontend/src/components/map/mapWorkspaceUtils.ts @@ -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 diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts index a9d47afc..642a715f 100644 --- a/frontend/src/hooks/useMapThemeSelectionInsights.ts +++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts @@ -26,6 +26,7 @@ export interface MapThemeQuery { dataset?: DatasetCreateResponse partitioned?: boolean acquisition?: MapThemeAcquisition + acquisitionBboxes?: VectorSelectionBBox[] } export interface MapThemeInsight { @@ -103,51 +104,63 @@ export function useMapThemeSelectionInsights( 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( 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( ) : 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( 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, diff --git a/frontend/src/hooks/useOfficialMapProducts.ts b/frontend/src/hooks/useOfficialMapProducts.ts index 0a91a707..b22e5015 100644 --- a/frontend/src/hooks/useOfficialMapProducts.ts +++ b/frontend/src/hooks/useOfficialMapProducts.ts @@ -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 | 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 } } diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts index f06ad2b6..c6d42023 100644 --- a/frontend/src/services/api/datasets.ts +++ b/frontend/src/services/api/datasets.ts @@ -171,7 +171,7 @@ export const datasetsApi = { apiPost(`/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 => apiPost(`/api/v1/projects/${projectId}/datasets/raster/terrain/select`, payload), acquireFloodHazard: (projectId: string, payload: FloodHazardAcquireRequest): Promise => @@ -186,7 +186,7 @@ export const datasetsApi = { apiPost(`/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 => apiPost(`/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(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`), selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise => apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload), + selectVectorFeaturePartitions: ( + projectId: string, + payload: VectorSelectionRequest & { dataset_ids: string[] }, + ): Promise => + apiPost( + `/api/v1/projects/${projectId}/datasets/vector/partitions/select`, + payload, + ), deriveVectorSelection: (projectId: string, datasetId: string, payload: VectorSelectionDeriveRequest): Promise => apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload), vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>