Add map area selection extraction
This commit is contained in:
@@ -574,6 +574,14 @@ history, known limitations and print-friendly CSS.
|
||||
This remains a simple HTML export. It does not add a PDF designer, report
|
||||
builder, live provider fetching or new analysis behavior.
|
||||
|
||||
## Vector area selection
|
||||
|
||||
`POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select` runs a
|
||||
read-only EPSG:4326 bbox query against persisted PostGIS `vector_features` and
|
||||
returns a canonical-envelope GeoJSON FeatureCollection. It is intended for the
|
||||
Map workspace area-extract flow and does not create derived datasets or export
|
||||
records.
|
||||
|
||||
## Helpful repository scripts
|
||||
|
||||
- `bash scripts/backend_install.sh`
|
||||
|
||||
@@ -24,6 +24,9 @@ from app.schemas import (
|
||||
VectorBufferRequest,
|
||||
VectorClipRequest,
|
||||
VectorIntersectRequest,
|
||||
VectorSelectionBBox,
|
||||
VectorSelectionRequest,
|
||||
VectorSelectionResponse,
|
||||
)
|
||||
from app.schemas.job import JobCreate
|
||||
from app.schemas.dataset import DatasetCreateResponse
|
||||
@@ -31,6 +34,7 @@ from app.schemas.operations import VectorOperationResult
|
||||
from app.services.job_service import JobService
|
||||
from app.services.raster_operations_service import RasterOperationsService
|
||||
from app.services.vector_operations_service import VectorOperationsService
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.utils.response import envelope
|
||||
|
||||
@@ -180,6 +184,27 @@ def vector_stats(
|
||||
return envelope(VectorOperationsService.stats(db, dataset_id))
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/select", response_model=dict)
|
||||
def select_vector_features(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
payload: VectorSelectionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||
if dataset.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
if dataset.dataset_type not in {"vector", "geojson"}:
|
||||
raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400)
|
||||
result = VectorFeatureService.select_features_by_bbox(
|
||||
db,
|
||||
dataset_id=dataset_id,
|
||||
bbox=payload.bbox.model_dump(),
|
||||
limit=payload.limit,
|
||||
)
|
||||
return envelope(VectorSelectionResponse(**result).model_dump())
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
|
||||
def clip_vector_dataset(
|
||||
project_id: UUID,
|
||||
|
||||
@@ -71,6 +71,9 @@ from .operations import (
|
||||
VectorIntersectRequest,
|
||||
VectorOperationRequest,
|
||||
VectorOperationResult,
|
||||
VectorSelectionBBox,
|
||||
VectorSelectionRequest,
|
||||
VectorSelectionResponse,
|
||||
VectorStatsRequest,
|
||||
VectorStatsResponse,
|
||||
)
|
||||
@@ -122,6 +125,9 @@ __all__ = [
|
||||
"VectorIntersectRequest",
|
||||
"VectorOperationRequest",
|
||||
"VectorOperationResult",
|
||||
"VectorSelectionBBox",
|
||||
"VectorSelectionRequest",
|
||||
"VectorSelectionResponse",
|
||||
"RasterClipRequest",
|
||||
"RasterStatsResponse",
|
||||
"RasterReprojectRequest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class VectorOperationResult(BaseModel):
|
||||
@@ -191,3 +191,31 @@ class VectorStatsResponse(BaseModel):
|
||||
geometry_type_summary: dict[str, int]
|
||||
bounds_json: dict | None
|
||||
crs: str | None = None
|
||||
|
||||
|
||||
class VectorSelectionBBox(BaseModel):
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
crs: str = "EPSG:4326"
|
||||
|
||||
@field_validator("crs")
|
||||
@classmethod
|
||||
def validate_crs(cls, value: str) -> str:
|
||||
if value.upper() != "EPSG:4326":
|
||||
raise ValueError("Only EPSG:4326 bbox selection is supported")
|
||||
return "EPSG:4326"
|
||||
|
||||
|
||||
class VectorSelectionRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
|
||||
|
||||
class VectorSelectionResponse(BaseModel):
|
||||
selection_bbox: VectorSelectionBBox
|
||||
feature_count: int
|
||||
limit: int
|
||||
truncated: bool
|
||||
geojson: dict
|
||||
|
||||
@@ -3,7 +3,10 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope
|
||||
from geoalchemy2.shape import from_shape
|
||||
from geoalchemy2.shape import to_shape
|
||||
from shapely.geometry import mapping
|
||||
from shapely.geometry import shape
|
||||
from shapely.validation import make_valid
|
||||
|
||||
@@ -12,6 +15,117 @@ from app.models import VectorFeature
|
||||
|
||||
|
||||
class VectorFeatureService:
|
||||
@staticmethod
|
||||
def _normalize_selection_bbox(bbox: dict[str, Any]) -> dict[str, float | str]:
|
||||
try:
|
||||
min_x = float(bbox["min_x"])
|
||||
min_y = float(bbox["min_y"])
|
||||
max_x = float(bbox["max_x"])
|
||||
max_y = float(bbox["max_y"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise AppError(
|
||||
code="INVALID_SELECTION_BBOX",
|
||||
message="Selection bbox must include numeric min_x, min_y, max_x and max_y values",
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
crs = str(bbox.get("crs") or "EPSG:4326").upper()
|
||||
if crs != "EPSG:4326":
|
||||
raise AppError(
|
||||
code="UNSUPPORTED_SELECTION_CRS",
|
||||
message="Map selection currently supports EPSG:4326 bbox coordinates only",
|
||||
details={"crs": crs},
|
||||
status_code=400,
|
||||
)
|
||||
if min_x >= max_x or min_y >= max_y:
|
||||
raise AppError(
|
||||
code="INVALID_SELECTION_BBOX",
|
||||
message="Selection bbox must have min_x < max_x and min_y < max_y",
|
||||
status_code=400,
|
||||
)
|
||||
if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90:
|
||||
raise AppError(
|
||||
code="INVALID_SELECTION_BBOX",
|
||||
message="Selection bbox is outside EPSG:4326 longitude/latitude bounds",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return {"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"}
|
||||
|
||||
@staticmethod
|
||||
def _row_to_geojson_feature(row: VectorFeature) -> dict[str, Any]:
|
||||
geometry_value = row.geometry
|
||||
try:
|
||||
geometry = geometry_value if hasattr(geometry_value, "__geo_interface__") else to_shape(geometry_value)
|
||||
except Exception as exc:
|
||||
raise AppError(
|
||||
code="INVALID_VECTOR_FEATURE_GEOMETRY",
|
||||
message="Persisted vector feature geometry could not be converted to GeoJSON",
|
||||
details={"vector_feature_id": str(row.id)},
|
||||
status_code=500,
|
||||
) from exc
|
||||
|
||||
properties = dict(row.properties_json or {})
|
||||
properties.update(
|
||||
{
|
||||
"vector_feature_id": str(row.id),
|
||||
"dataset_id": str(row.dataset_id),
|
||||
"source_feature_id": row.source_feature_id,
|
||||
"feature_class": row.feature_class,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "Feature",
|
||||
"id": str(row.id),
|
||||
"geometry": mapping(geometry),
|
||||
"properties": properties,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def select_features_by_bbox(
|
||||
db,
|
||||
dataset_id: UUID,
|
||||
bbox: dict[str, Any],
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
|
||||
safe_limit = max(1, min(int(limit), 1000))
|
||||
|
||||
rows = (
|
||||
db.query(VectorFeature)
|
||||
.filter(VectorFeature.dataset_id == dataset_id)
|
||||
.filter(
|
||||
ST_Intersects(
|
||||
VectorFeature.geometry,
|
||||
ST_MakeEnvelope(
|
||||
normalized_bbox["min_x"],
|
||||
normalized_bbox["min_y"],
|
||||
normalized_bbox["max_x"],
|
||||
normalized_bbox["max_y"],
|
||||
4326,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(VectorFeature.created_at.asc())
|
||||
.limit(safe_limit + 1)
|
||||
.all()
|
||||
)
|
||||
truncated = len(rows) > safe_limit
|
||||
selected_rows = rows[:safe_limit]
|
||||
features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows]
|
||||
|
||||
return {
|
||||
"selection_bbox": normalized_bbox,
|
||||
"feature_count": len(features),
|
||||
"limit": safe_limit,
|
||||
"truncated": truncated,
|
||||
"geojson": {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def persist_geojson_features(
|
||||
db,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import Dataset, VectorFeature
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows: list[VectorFeature]) -> None:
|
||||
self.rows = rows
|
||||
self.limit_value: int | None = None
|
||||
|
||||
def filter(self, *args, **kwargs): # noqa: ANN002, ANN003
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs): # noqa: ANN002, ANN003
|
||||
return self
|
||||
|
||||
def limit(self, value: int):
|
||||
self.limit_value = value
|
||||
return self
|
||||
|
||||
def all(self) -> list[VectorFeature]:
|
||||
if self.limit_value is None:
|
||||
return self.rows
|
||||
return self.rows[: self.limit_value]
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, rows: list[VectorFeature]) -> None:
|
||||
self.rows = rows
|
||||
|
||||
def query(self, model): # noqa: ANN001
|
||||
assert model is VectorFeature
|
||||
return _FakeQuery(self.rows)
|
||||
|
||||
|
||||
def _feature_row(dataset_id: uuid.UUID, *, source_feature_id: str, name: str) -> VectorFeature:
|
||||
return VectorFeature(
|
||||
id=uuid.uuid4(),
|
||||
dataset_id=dataset_id,
|
||||
feature_class="parcel",
|
||||
source_feature_id=source_feature_id,
|
||||
properties_json={"name": name},
|
||||
geometry=from_shape(
|
||||
Polygon(
|
||||
[
|
||||
(5.0, 51.0),
|
||||
(5.001, 51.0),
|
||||
(5.001, 51.001),
|
||||
(5.0, 51.001),
|
||||
(5.0, 51.0),
|
||||
]
|
||||
),
|
||||
srid=4326,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_vector_feature_service_extracts_bbox_geojson_from_persisted_rows() -> None:
|
||||
dataset_id = uuid.uuid4()
|
||||
rows = [_feature_row(dataset_id, source_feature_id="src-1", name="Test parcel")]
|
||||
|
||||
result = VectorFeatureService.select_features_by_bbox(
|
||||
_FakeSession(rows),
|
||||
dataset_id=dataset_id,
|
||||
bbox={"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||
limit=25,
|
||||
)
|
||||
|
||||
assert result["feature_count"] == 1
|
||||
assert result["truncated"] is False
|
||||
assert result["geojson"]["type"] == "FeatureCollection"
|
||||
feature = result["geojson"]["features"][0]
|
||||
assert feature["type"] == "Feature"
|
||||
assert feature["properties"]["vector_feature_id"] == str(rows[0].id)
|
||||
assert feature["properties"]["source_feature_id"] == "src-1"
|
||||
assert feature["properties"]["feature_class"] == "parcel"
|
||||
assert feature["properties"]["name"] == "Test parcel"
|
||||
assert feature["geometry"]["type"] == "Polygon"
|
||||
|
||||
|
||||
def test_vector_feature_service_rejects_invalid_bbox() -> None:
|
||||
try:
|
||||
VectorFeatureService.select_features_by_bbox(
|
||||
_FakeSession([]),
|
||||
dataset_id=uuid.uuid4(),
|
||||
bbox={"min_x": 5.2, "min_y": 50.9, "max_x": 5.0, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||
)
|
||||
except AppError as exc:
|
||||
assert exc.code == "INVALID_SELECTION_BBOX"
|
||||
else: # pragma: no cover
|
||||
raise AssertionError("Expected INVALID_SELECTION_BBOX")
|
||||
|
||||
|
||||
def test_vector_select_route_is_project_scoped_and_enveloped(monkeypatch) -> None:
|
||||
from app.api.routes import datasets as dataset_routes
|
||||
|
||||
project_id = uuid.uuid4()
|
||||
dataset_id = uuid.uuid4()
|
||||
dataset = Dataset(id=dataset_id, project_id=project_id, dataset_type="vector", source="fixture", name="Vector")
|
||||
expected_payload = {
|
||||
"selection_bbox": {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||
"feature_count": 0,
|
||||
"limit": 100,
|
||||
"truncated": False,
|
||||
"geojson": {"type": "FeatureCollection", "features": []},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset)
|
||||
monkeypatch.setattr(
|
||||
dataset_routes.VectorFeatureService,
|
||||
"select_features_by_bbox",
|
||||
lambda db, dataset_id, bbox, limit=100: expected_payload,
|
||||
)
|
||||
|
||||
response = dataset_routes.select_vector_features(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
payload=dataset_routes.VectorSelectionRequest(
|
||||
bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2),
|
||||
limit=100,
|
||||
),
|
||||
db=SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert response == {"data": expected_payload}
|
||||
|
||||
|
||||
def test_vector_select_route_rejects_non_vector_dataset(monkeypatch) -> None:
|
||||
from app.api.routes import datasets as dataset_routes
|
||||
|
||||
project_id = uuid.uuid4()
|
||||
dataset_id = uuid.uuid4()
|
||||
dataset = Dataset(id=dataset_id, project_id=project_id, dataset_type="raster", source="fixture", name="Raster")
|
||||
monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset)
|
||||
|
||||
try:
|
||||
dataset_routes.select_vector_features(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
payload=dataset_routes.VectorSelectionRequest(
|
||||
bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2),
|
||||
limit=100,
|
||||
),
|
||||
db=SimpleNamespace(),
|
||||
)
|
||||
except AppError as exc:
|
||||
assert exc.code == "DATASET_NOT_VECTOR"
|
||||
else: # pragma: no cover
|
||||
raise AssertionError("Expected DATASET_NOT_VECTOR")
|
||||
|
||||
|
||||
def test_frontend_exposes_map_bbox_selection_contracts() -> None:
|
||||
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8")
|
||||
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8")
|
||||
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "selectVectorFeatures" in api_client
|
||||
assert "Area selection" in map_workspace
|
||||
assert "Start map bbox" in map_workspace
|
||||
assert "Run area extract" in map_workspace
|
||||
assert "Download area GeoJSON" in map_workspace
|
||||
assert "bboxSelectionMode" in geomap
|
||||
assert "selection-bbox" in geomap
|
||||
assert "selection-result" in geomap
|
||||
assert "useMapSelectionExtract" in app
|
||||
Reference in New Issue
Block a user