fix(map): make area analysis scale-aware
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface MapThemeQuery<TThemeId extends string> {
|
||||
dataset?: DatasetCreateResponse
|
||||
partitioned?: boolean
|
||||
acquisition?: MapThemeAcquisition
|
||||
acquisitionBboxes?: VectorSelectionBBox[]
|
||||
}
|
||||
|
||||
export interface MapThemeInsight<TThemeId extends string> {
|
||||
@@ -103,51 +104,63 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
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<TThemeId extends string>(
|
||||
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<TThemeId extends string>(
|
||||
)
|
||||
: 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<TThemeId extends string>(
|
||||
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,
|
||||
|
||||
@@ -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<Array<{
|
||||
bbox: VectorSelectionBBox
|
||||
coverage: CoverageResolveResponse
|
||||
}> | 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 }
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export const datasetsApi = {
|
||||
apiPost<TerrainSelectionResponse>(`/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<TerrainSelectionResponse> =>
|
||||
apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/terrain/select`, payload),
|
||||
acquireFloodHazard: (projectId: string, payload: FloodHazardAcquireRequest): Promise<JobRead> =>
|
||||
@@ -186,7 +186,7 @@ export const datasetsApi = {
|
||||
apiPost<FloodHazardSelectionResponse>(`/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<FloodHazardSelectionResponse> =>
|
||||
apiPost<FloodHazardSelectionResponse>(`/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<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
|
||||
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
|
||||
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload),
|
||||
selectVectorFeaturePartitions: (
|
||||
projectId: string,
|
||||
payload: VectorSelectionRequest & { dataset_ids: string[] },
|
||||
): Promise<VectorSelectionResponse> =>
|
||||
apiPost<VectorSelectionResponse>(
|
||||
`/api/v1/projects/${projectId}/datasets/vector/partitions/select`,
|
||||
payload,
|
||||
),
|
||||
deriveVectorSelection: (projectId: string, datasetId: string, payload: VectorSelectionDeriveRequest): Promise<DatasetCreateResponse> =>
|
||||
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload),
|
||||
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
|
||||
|
||||
Reference in New Issue
Block a user