fix(map): make area analysis scale-aware
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-21 21:59:56 +02:00
parent 80631607f7
commit 20829f1a24
20 changed files with 619 additions and 56 deletions
+1
View File
@@ -1,4 +1,5 @@
*.sh text eol=lf *.sh text eol=lf
deploy/unraid/gosu-setpriv text eol=lf
*.py text eol=lf *.py text eol=lf
*.yml text eol=lf *.yml text eol=lf
*.yaml 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
View File
@@ -11,7 +11,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse 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.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.core.logging import configure_logging 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(qa.router, prefix=settings.api_prefix)
app.include_router(detection.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(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(temporal.router, prefix=settings.api_prefix)
app.include_router(assistant.router, prefix=settings.api_prefix) app.include_router(assistant.router, prefix=settings.api_prefix)
+1
View File
@@ -58,6 +58,7 @@ class TerrainSelectionRequest(BaseModel):
class TerrainPartitionSelectionRequest(TerrainSelectionRequest): class TerrainPartitionSelectionRequest(TerrainSelectionRequest):
product_key: str = "dtm_1m" product_key: str = "dtm_1m"
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=16)
class TerrainMetric(BaseModel): class TerrainMetric(BaseModel):
+1
View File
@@ -61,6 +61,7 @@ class FloodHazardSelectionRequest(BaseModel):
class FloodHazardPartitionSelectionRequest(FloodHazardSelectionRequest): class FloodHazardPartitionSelectionRequest(FloodHazardSelectionRequest):
product_key: str = "pluviaal_current_t100" product_key: str = "pluviaal_current_t100"
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=16)
class FloodHazardMetric(BaseModel): 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, selection_geometry_4326=selection_4326,
nodata=FloodHazardAcquisitionService.NODATA, nodata=FloodHazardAcquisitionService.NODATA,
max_pixels=resolved_settings.flood_hazard_max_pixels, max_pixels=resolved_settings.flood_hazard_max_pixels,
dataset_ids=payload.dataset_ids,
) )
try: try:
import numpy as np import numpy as np
@@ -51,17 +51,17 @@ class RasterPartitionAnalysisService:
source_name: str, source_name: str,
product_key: str, product_key: str,
bbox: tuple[float, float, float, float], bbox: tuple[float, float, float, float],
dataset_ids: list[UUID] | None = None,
) -> list[Dataset]: ) -> list[Dataset]:
rows = ( query = db.query(Dataset).filter(
db.query(Dataset) Dataset.project_id == project_id,
.filter( Dataset.source_name == source_name,
Dataset.project_id == project_id, Dataset.dataset_type == "raster",
Dataset.source_name == source_name, Dataset.status == "ready",
Dataset.dataset_type == "raster",
Dataset.status == "ready",
)
.all()
) )
if dataset_ids is not None:
query = query.filter(Dataset.id.in_(dataset_ids))
rows = query.all()
candidates = [ candidates = [
dataset dataset
for dataset in rows for dataset in rows
@@ -78,6 +78,13 @@ class RasterPartitionAnalysisService:
details={"source_name": source_name, "product_key": product_key}, details={"source_name": source_name, "product_key": product_key},
status_code=404, 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: if len(candidates) > RasterPartitionAnalysisService.MAX_PARTITIONS:
raise AppError( raise AppError(
code="RASTER_PARTITION_LIMIT_EXCEEDED", code="RASTER_PARTITION_LIMIT_EXCEEDED",
@@ -100,6 +107,7 @@ class RasterPartitionAnalysisService:
selection_geometry_4326, selection_geometry_4326,
nodata: float, nodata: float,
max_pixels: int, max_pixels: int,
dataset_ids: list[UUID] | None = None,
) -> RasterPartitionSelection: ) -> RasterPartitionSelection:
try: try:
import numpy as np import numpy as np
@@ -120,6 +128,7 @@ class RasterPartitionAnalysisService:
source_name=source_name, source_name=source_name,
product_key=product_key, product_key=product_key,
bbox=bbox, bbox=bbox,
dataset_ids=dataset_ids,
) )
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
selection_metric = shapely_transform(transformer.transform, selection_geometry_4326) selection_metric = shapely_transform(transformer.transform, selection_geometry_4326)
@@ -235,6 +235,7 @@ class TerrainAnalysisService:
selection_geometry_4326=selection_4326, selection_geometry_4326=selection_4326,
nodata=DhmvAcquisitionService.NODATA, nodata=DhmvAcquisitionService.NODATA,
max_pixels=resolved_settings.dhmv_max_pixels, max_pixels=resolved_settings.dhmv_max_pixels,
dataset_ids=payload.dataset_ids,
) )
surface_models = { surface_models = {
str((dataset.source_metadata or {}).get("surface_model") or "") str((dataset.source_metadata or {}).get("surface_model") or "")
+12 -3
View File
@@ -11,7 +11,7 @@ from geoalchemy2.shape import to_shape
from shapely.geometry import box, mapping, shape from shapely.geometry import box, mapping, shape
from shapely.ops import transform as transform_geometry from shapely.ops import transform as transform_geometry
from shapely.validation import make_valid 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.core.errors import AppError
from app.models import Dataset, VectorFeature from app.models import Dataset, VectorFeature
@@ -548,6 +548,8 @@ class VectorFeatureService:
selection_area_id: UUID | None = None, selection_area_id: UUID | None = None,
full_dataset_area: bool = False, full_dataset_area: bool = False,
preclipped_partition_filter: tuple[str, str] | None = None, preclipped_partition_filter: tuple[str, str] | None = None,
dataset_ids: list[UUID] | None = None,
deduplicate_source_features: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox) normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
safe_limit = max(1, min(int(limit), 1000)) safe_limit = max(1, min(int(limit), 1000))
@@ -561,13 +563,19 @@ class VectorFeatureService:
4326, 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: if preclipped_partition_filter is not None:
partition_property, partition_value = preclipped_partition_filter partition_property, partition_value = preclipped_partition_filter
query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value) query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value)
if not full_dataset_area: if not full_dataset_area:
query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape)) 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()) total_feature_count = int(query.count())
else: # Lightweight unit-test sessions do not always implement Query.count(). else: # Lightweight unit-test sessions do not always implement Query.count().
total_feature_count = len(query.all()) total_feature_count = len(query.all())
@@ -585,6 +593,7 @@ class VectorFeatureService:
summary = VectorFeatureService.summarize_features_by_bbox( summary = VectorFeatureService.summarize_features_by_bbox(
db, db,
dataset=dataset, dataset=dataset,
dataset_ids=selected_dataset_ids,
bbox=normalized_bbox, bbox=normalized_bbox,
total_feature_count=total_feature_count, total_feature_count=total_feature_count,
selection_geometry=selection_geometry, 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"
+18
View File
@@ -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. - `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. - 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` ### 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 Persists a bbox selection as a new derived vector dataset and indexes the
+37
View File
@@ -10953,3 +10953,40 @@ Validation:
themes through acquisition or persisted national data and semantic themes through acquisition or persisted national data and semantic
selection metrics; selection metrics;
- browser verification follows against the deployed commit on port 1202. - 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.
+11
View File
@@ -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. uncertainty tests exist; never merge TAW, LAT and mDNG implicitly.
- [ ] Add water volume only when bed and water-surface inputs share a governed - [ ] Add water volume only when bed and water-surface inputs share a governed
time, datum and coverage contract. 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.
+97 -7
View File
@@ -41,8 +41,11 @@ import {
safeFileStem, safeFileStem,
selectedAreaCoverageZones, selectedAreaCoverageZones,
selectedFeatureCollection, selectedFeatureCollection,
selectionAnalysisScale,
selectionAreaSquareMetres, selectionAreaSquareMetres,
selectionDimensions,
selectionMetricLabel, selectionMetricLabel,
splitSelectionBbox,
} from './mapWorkspaceUtils' } from './mapWorkspaceUtils'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
@@ -91,6 +94,25 @@ interface OnDemandMapProduct extends MapThemeAcquisition {
limitationMessage: string 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[] = [ const DATA_THEMES: DataTheme[] = [
{ {
id: 'administrative', id: 'administrative',
@@ -836,6 +858,7 @@ export function MapWorkspace({
loading: officialMapProductsLoading, loading: officialMapProductsLoading,
error: officialMapProductsError, error: officialMapProductsError,
resolveCoverage, resolveCoverage,
resolveCoveragePartitions,
} = useOfficialMapProducts(selectedProjectId) } = useOfficialMapProducts(selectedProjectId)
const { const {
temporalComparison, temporalComparison,
@@ -1114,12 +1137,18 @@ export function MapWorkspace({
} }
return result return result
}, [onDemandProductsForZones, selectedCoverageZones]) }, [onDemandProductsForZones, selectedCoverageZones])
const mapSelectionScale = mapSelectionBbox ? selectionAnalysisScale(mapSelectionBbox) : null
const selectionRelevantThemes = useMemo(() => { const selectionRelevantThemes = useMemo(() => {
if (!mapSelectionBbox || !coverage) { if (!mapSelectionBbox || !coverage) {
return DATA_THEMES return DATA_THEMES
} }
const boundedThemes = new Set( 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) => { return DATA_THEMES.filter((theme) => {
if (boundedThemes.has(theme.id)) { if (boundedThemes.has(theme.id)) {
@@ -1133,9 +1162,9 @@ export function MapWorkspace({
(item) => item.theme === coverageTheme && item.status === 'operational', (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 unavailableSelectionThemeCount = Math.max(DATA_THEMES.length - selectionRelevantThemes.length, 0)
const activeOnDemandMapProduct = themeDatasetMap[activeTheme.id] const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id]
? null ? null
: onDemandProductMap.get(activeTheme.id) ?? null : onDemandProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id] const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
@@ -1366,6 +1395,20 @@ export function MapWorkspace({
: selectionAreaSquareMetres(mapSelectionBbox), : selectionAreaSquareMetres(mapSelectionBbox),
[mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2], [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 selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0 const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000) ? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
@@ -1727,6 +1770,7 @@ export function MapWorkspace({
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
let resolvedZones = selectedCoverageZones let resolvedZones = selectedCoverageZones
const scale = selectionAnalysisScale(bbox)
if (analysisMode === 'current' && selectedProjectId) { if (analysisMode === 'current' && selectedProjectId) {
const resolvedCoverage = await resolveCoverage({ const resolvedCoverage = await resolveCoverage({
minx: bbox.min_x, minx: bbox.min_x,
@@ -1740,9 +1784,49 @@ export function MapWorkspace({
} }
resolvedZones = resolvedCoverage.intersected_zones resolvedZones = resolvedCoverage.intersected_zones
} }
const resolvedProducts = analysisMode === 'current' let resolvedProducts: PlannedOnDemandMapProduct[] = []
? onDemandProductsForZones(resolvedZones) 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>> = [] const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
for (const theme of DATA_THEMES) { for (const theme of DATA_THEMES) {
const dataset = themeDatasetMap[theme.id] const dataset = themeDatasetMap[theme.id]
@@ -1765,6 +1849,7 @@ export function MapWorkspace({
productKey: onDemandProduct.productKey, productKey: onDemandProduct.productKey,
displayName: onDemandProduct.displayName, displayName: onDemandProduct.displayName,
}, },
acquisitionBboxes: onDemandProduct.acquisitionBboxes,
}) })
} }
continue continue
@@ -2560,9 +2645,14 @@ export function MapWorkspace({
) )
}) })
})} })}
{selectionScaleNotice ? (
<p className="geo-data-notice">{selectionScaleNotice}</p>
) : null}
{unavailableSelectionThemeCount > 0 ? ( {unavailableSelectionThemeCount > 0 ? (
<p className="geo-data-notice"> <p className="geo-data-notice">
{unavailableSelectionThemeCount} themas 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> </p>
) : null} ) : null}
</div> </div>
@@ -8,9 +8,12 @@ import {
productCoversZones, productCoversZones,
resultMetricLabel, resultMetricLabel,
selectedAreaCoverageZones, selectedAreaCoverageZones,
selectionAnalysisScale,
selectionAreaSquareMetres, selectionAreaSquareMetres,
selectionDimensions,
splitSelectionBbox,
} from './mapWorkspaceUtils' } from './mapWorkspaceUtils'
import type { VectorSelectionResponse } from '../../types' import type { VectorSelectionBBox, VectorSelectionResponse } from '../../types'
describe('map workspace selection guards', () => { describe('map workspace selection guards', () => {
it('normalizes drag corners into an EPSG:4326 bbox', () => { it('normalizes drag corners into an EPSG:4326 bbox', () => {
@@ -90,4 +93,49 @@ describe('map workspace selection guards', () => {
geometry_clipped_to_selection: true, geometry_clipped_to_selection: true,
})).toBe(false) })).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) 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 { export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) { if (!left || !right) {
return false return false
@@ -26,6 +26,7 @@ export interface MapThemeQuery<TThemeId extends string> {
dataset?: DatasetCreateResponse dataset?: DatasetCreateResponse
partitioned?: boolean partitioned?: boolean
acquisition?: MapThemeAcquisition acquisition?: MapThemeAcquisition
acquisitionBboxes?: VectorSelectionBBox[]
} }
export interface MapThemeInsight<TThemeId extends string> { export interface MapThemeInsight<TThemeId extends string> {
@@ -103,51 +104,63 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
const settled = await settleWithConcurrency( const settled = await settleWithConcurrency(
queries, queries,
3, 3,
async ({ themeId, dataset: existingDataset, partitioned, acquisition }) => { async ({ themeId, dataset: existingDataset, partitioned, acquisition, acquisitionBboxes }) => {
let dataset = existingDataset let dataset = existingDataset
let acquiredDatasets: DatasetCreateResponse[] = []
if (acquisition) { if (acquisition) {
const commonPayload = { const requestedBboxes = acquisitionBboxes?.length ? acquisitionBboxes : [bbox]
bbox, const acquisitionResults = await settleWithConcurrency(requestedBboxes, 1, async (acquisitionBbox) => {
area_id: areaId, const commonPayload = {
force_refresh: false, bbox: acquisitionBbox,
} area_id: areaId,
const acquisitionJob = acquisition.kind === 'thematic_raster' force_refresh: false,
? await datasetsApi.acquireThematicRaster(selectedProjectId, { }
...commonPayload, const acquisitionJob = acquisition.kind === 'thematic_raster'
product_key: acquisition.productKey, ? await datasetsApi.acquireThematicRaster(selectedProjectId, {
})
: acquisition.kind === 'dhmv'
? await datasetsApi.acquireDhmv(selectedProjectId, {
...commonPayload, ...commonPayload,
product_key: acquisition.productKey as 'dtm_1m' | 'dsm_1m', product_key: acquisition.productKey,
}) })
: acquisition.kind === 'flood_hazard' : acquisition.kind === 'dhmv'
? await datasetsApi.acquireFloodHazard(selectedProjectId, { ? await datasetsApi.acquireDhmv(selectedProjectId, {
...commonPayload, ...commonPayload,
product_key: acquisition.productKey, product_key: acquisition.productKey as 'dtm_1m' | 'dsm_1m',
}) })
: acquisition.kind === 'grb' : acquisition.kind === 'flood_hazard'
? await datasetsApi.acquireGrb(selectedProjectId, { ? await datasetsApi.acquireFloodHazard(selectedProjectId, {
...commonPayload, ...commonPayload,
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels', product_key: acquisition.productKey,
}) })
: acquisition.kind === 'bathymetry_profiles' : acquisition.kind === 'grb'
? await datasetsApi.acquireBathymetryProfiles(selectedProjectId, commonPayload) ? await datasetsApi.acquireGrb(selectedProjectId, {
: await datasetsApi.acquireOfficialVector(selectedProjectId, {
...commonPayload, ...commonPayload,
product_key: acquisition.productKey, product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
}) })
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) { : acquisition.kind === 'bathymetry_profiles'
throw new Error( ? await datasetsApi.acquireBathymetryProfiles(selectedProjectId, commonPayload)
acquisitionJob.error_message : await datasetsApi.acquireOfficialVector(selectedProjectId, {
|| `De officiële kaartbron ${acquisition.displayName} kon niet worden ingeladen.`, ...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) { if (!dataset) {
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`) throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
} }
const acquiredDatasetIds = acquiredDatasets.map((item) => item.id)
const acquiredAsPartitions = acquiredDatasetIds.length > 1
return { return {
themeId, themeId,
dataset, dataset,
@@ -155,11 +168,12 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
acquisition, acquisition,
result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv' result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
? terrainSelectionToMapSelection( ? terrainSelectionToMapSelection(
partitioned partitioned || acquiredAsPartitions
? await datasetsApi.selectTerrainPartitions(selectedProjectId, { ? await datasetsApi.selectTerrainPartitions(selectedProjectId, {
bbox, bbox,
area_id: areaId, area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'dtm_1m'), product_key: String(dataset.source_metadata?.['product_key'] ?? 'dtm_1m'),
...(acquiredAsPartitions ? { dataset_ids: acquiredDatasetIds } : {}),
}) })
: await datasetsApi.selectTerrain(selectedProjectId, dataset.id, { : await datasetsApi.selectTerrain(selectedProjectId, dataset.id, {
bbox, bbox,
@@ -168,11 +182,12 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
) )
: dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard' : dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard'
? floodHazardSelectionToMapSelection( ? floodHazardSelectionToMapSelection(
partitioned partitioned || acquiredAsPartitions
? await datasetsApi.selectFloodHazardPartitions(selectedProjectId, { ? await datasetsApi.selectFloodHazardPartitions(selectedProjectId, {
bbox, bbox,
area_id: areaId, area_id: areaId,
product_key: String(dataset.source_metadata?.['product_key'] ?? 'pluviaal_current_t100'), product_key: String(dataset.source_metadata?.['product_key'] ?? 'pluviaal_current_t100'),
...(acquiredAsPartitions ? { dataset_ids: acquiredDatasetIds } : {}),
}) })
: await datasetsApi.selectFloodHazard(selectedProjectId, dataset.id, { : await datasetsApi.selectFloodHazard(selectedProjectId, dataset.id, {
bbox, bbox,
@@ -189,6 +204,13 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
bbox, bbox,
area_id: areaId, 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 : dataset.source_name === 'vmm_vha_bathymetry_profiles' && partitioned
? await datasetsApi.selectBathymetryProfilePartitions(selectedProjectId, { ? await datasetsApi.selectBathymetryProfilePartitions(selectedProjectId, {
bbox, bbox,
+45 -1
View File
@@ -3,11 +3,13 @@ import { datasetsApi, externalApi } from '../services/api'
import { formatError } from '../lib/formatError' import { formatError } from '../lib/formatError'
import type { import type {
BathymetrySourceRead, BathymetrySourceRead,
CoverageResolveResponse,
DhmvProductRead, DhmvProductRead,
FloodHazardProductRead, FloodHazardProductRead,
GrbProductRead, GrbProductRead,
OfficialVectorProductRead, OfficialVectorProductRead,
ThematicRasterProductRead, ThematicRasterProductRead,
VectorSelectionBBox,
} from '../types' } from '../types'
export interface OfficialMapProducts { export interface OfficialMapProducts {
@@ -104,5 +106,47 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
[selectedProjectId], [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 }
} }
+10 -2
View File
@@ -171,7 +171,7 @@ export const datasetsApi = {
apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/select`, payload), apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/select`, payload),
selectTerrainPartitions: ( selectTerrainPartitions: (
projectId: string, 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> => ): Promise<TerrainSelectionResponse> =>
apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/terrain/select`, payload), apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/terrain/select`, payload),
acquireFloodHazard: (projectId: string, payload: FloodHazardAcquireRequest): Promise<JobRead> => 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), apiPost<FloodHazardSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/select`, payload),
selectFloodHazardPartitions: ( selectFloodHazardPartitions: (
projectId: string, 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> => ): Promise<FloodHazardSelectionResponse> =>
apiPost<FloodHazardSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/flood-hazard/select`, payload), apiPost<FloodHazardSelectionResponse>(`/api/v1/projects/${projectId}/datasets/raster/flood-hazard/select`, payload),
listBathymetrySources: (projectId: string): Promise<{ items: BathymetrySourceRead[]; total: number }> => 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`), apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> => selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload), 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> => deriveVectorSelection: (projectId: string, datasetId: string, payload: VectorSelectionDeriveRequest): Promise<DatasetCreateResponse> =>
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload), apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload),
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) => vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>