diff --git a/CHANGELOG.md b/CHANGELOG.md index 348efca7..670402aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ # Changelog +## Sprint 106 Map area selection extract (2026-06-25) + +- Added a read-only bbox selection endpoint for vector datasets: `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select`. +- The endpoint queries persisted PostGIS `vector_features` and returns a canonical-envelope GeoJSON FeatureCollection with selection bbox, feature count, limit and truncation state. +- Added Map workspace area selection with two-click bbox drawing, manual EPSG:4326 bbox inputs, selected-feature/AOI/layer bbox shortcuts and client-side GeoJSON download/copy. +- Added MapLibre overlays for the active bbox and extracted selection result. +- Added regression coverage for backend selection behavior, route envelope and frontend wiring. +- No migrations, provider fetching, AI behavior, real model dependencies or new product domains were introduced. + ## Sprint 105 Map feature extract (2026-06-25) - Added a `Selection & extract` panel to the Map workspace for clicked map features. diff --git a/backend/README.md b/backend/README.md index ec4a66e7..5b36c273 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index fc7b4a11..56433074 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -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, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index d327303a..7997b813 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py index 7d329dd5..86f02986 100644 --- a/backend/app/schemas/operations.py +++ b/backend/app/schemas/operations.py @@ -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 diff --git a/backend/app/services/vector_feature_service.py b/backend/app/services/vector_feature_service.py index e9f2e94b..f7818bc4 100644 --- a/backend/app/services/vector_feature_service.py +++ b/backend/app/services/vector_feature_service.py @@ -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, diff --git a/backend/tests/test_sprint106_map_bbox_extract.py b/backend/tests/test_sprint106_map_bbox_extract.py new file mode 100644 index 00000000..302d5b23 --- /dev/null +++ b/backend/tests/test_sprint106_map_bbox_extract.py @@ -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 diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index bce7159c..ab86f78e 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -357,6 +357,55 @@ Return vector stats (feature counts and geometry summary). Return vector bounds and feature count. +### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select` + +Read-only spatial selection over persisted `vector_features`. + +Request: + +```json +{ + "bbox": { + "min_x": 5.0, + "min_y": 51.0, + "max_x": 5.1, + "max_y": 51.1, + "crs": "EPSG:4326" + }, + "limit": 250 +} +``` + +Response: + +```json +{ + "data": { + "selection_bbox": { + "min_x": 5.0, + "min_y": 51.0, + "max_x": 5.1, + "max_y": 51.1, + "crs": "EPSG:4326" + }, + "feature_count": 2, + "limit": 250, + "truncated": false, + "geojson": { + "type": "FeatureCollection", + "features": [] + } + } +} +``` + +Rules: + +- Only vector/GeoJSON datasets are supported. +- Coordinates are EPSG:4326 longitude/latitude. +- Results are generated from persisted PostGIS `vector_features`, not from client-side map data. +- The response is capped by `limit` and returns `truncated=true` when more matching rows exist. + ### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}/content` Returns stored vector dataset content through the canonical API envelope. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 1d803415..760a9cc6 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,35 @@ +## Sprint 106 Map area selection extract (2026-06-25) + +Changed: +- Added `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select` for read-only bbox selection over persisted PostGIS `vector_features`. +- Added `VectorSelectionBBox`, `VectorSelectionRequest` and `VectorSelectionResponse` schemas and exported them through the backend schema module. +- Added `VectorFeatureService.select_features_by_bbox`, including EPSG:4326 bbox validation, feature limit capping, PostGIS `ST_Intersects` query and GeoJSON FeatureCollection conversion from persisted geometries. +- Added frontend `selectVectorFeatures` API client support and `useMapSelectionExtract`. +- Extended the Map workspace with an `Area selection` panel, two-click map bbox selection, manual bbox inputs, selected-feature/AOI/active-layer bbox shortcuts, area GeoJSON download/copy actions and a compact selected-feature table. +- Extended `GeoMap` with `selection-bbox` and `selection-result` MapLibre GeoJSON overlays. +- Updated `docs/API_CONTRACTS.md`, `frontend/README.md`, `backend/README.md`, `CHANGELOG.md` and `docs/TODO.md`. +- Added regression coverage in `backend/tests/test_sprint106_map_bbox_extract.py`. + +Validation: +- RED: `python -m pytest backend\tests\test_sprint106_map_bbox_extract.py -q` failed before implementation because the vector selection service, route and frontend contracts were absent. +- `python -m pytest backend\tests\test_sprint106_map_bbox_extract.py -q` passed: 5 tests. +- `python -m compileall backend/app` passed. +- `cd backend && python -m pytest -q` passed: 338 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. +- `bash scripts/run_readiness_check.sh` passed. +- `cd backend && python -m alembic heads` passed: `202606120900 (head)`. +- `cd backend && python -m alembic upgrade head --sql` passed. +- `bash -n scripts/live_migration_smoke.sh` passed. + +Limitations: +- Selection shape is bbox-only in EPSG:4326. Polygon/lasso selection remains future work. +- The endpoint is read-only and does not create exports or derived datasets; operators can download the selected GeoJSON client-side. +- No migrations, live provider fetching, AI dependency, real model behavior or new product domain were added. + +Next recommended pass: +- Add a browser/live smoke around the area selection panel after deploy, then consider export-center handoff for persisted selection artifacts if V1 needs server-side audit retention. + ## Sprint 105 Map feature extract (2026-06-25) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index faa8064b..5c73ce99 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -372,3 +372,4 @@ This file now starts with the current implementation status. Older preparation/b - [x] Make raster tile handoff to Detection Lab auto-select the configured YOLO run form. - [x] Add AI Lab run-readiness checks for Detection and Segmentation before job submission. - [x] Add AI Lab action guardrails so explicit fixture models are not exposed as normal operator runs. +- [x] Add persisted vector area selection from the Map workspace with bbox extract and GeoJSON download. diff --git a/frontend/README.md b/frontend/README.md index 0299cb7c..b029331f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -277,6 +277,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst - Mobile workbench navigation uses horizontal rails for the primary nav and command bar, avoiding a tall menu stack before the active workspace content. - The Map workspace shows active layer source/provenance/draw-state context and selected-feature property chips before the raw JSON inspector. - When the Map workspace has no active result layer, it lists ready vector/GeoJSON datasets as direct quick actions so populated demo projects can jump straight from the empty state to map inspection. +- The Map workspace can extract persisted vector features by area: open a ready vector dataset, use `Start map bbox` and click two map corners or enter EPSG:4326 bbox values, then run `Run area extract` to query backend `vector_features`. Results are highlighted on the map and can be downloaded as GeoJSON. - The Data catalog shows a compact selected/reference/candidate/source summary and scan-friendly badges. Persisted `reference` datasets are shown as Reference, non-reference vector/GeoJSON layers are shown as QA Candidates for workbench scanning, and raster/other uploads remain Source. - Dataset cards explain the recommended next action and use compact two-line action buttons for inspect, map, export/QA and metadata refresh. Disabled actions keep a visible reason, such as `Vector/GeoJSON only`. - Raster controls show the latest generated tile manifest path from persisted `raster.tile` jobs and can hand that path directly to Detection Lab or Segmentation Lab with the selected raster dataset. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8e172b1..8691b482 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' +import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useProviderCapabilities } from './hooks/useProviderCapabilities' import { useProjectWorkspace } from './hooks/useProjectWorkspace' import { useQualityWorkflow } from './hooks/useQualityWorkflow' @@ -367,6 +368,19 @@ function App(): JSX.Element { datasetContent, selectedDataset, }) + const { + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, + runMapSelectionExtract, + resetMapSelectionExtract, + setMapSelectionBbox, + } = useMapSelectionExtract({ + selectedProjectId, + selectedDataset, + isVectorDatasetType, + }) const { loadingDemoWorkflow, demoWorkflowMessage, @@ -784,6 +798,10 @@ function App(): JSX.Element { mapFeatureCount={mapFeatureCount} areaFeatureCount={areaFeatureCount} selectedMapFeature={selectedMapFeature} + mapSelectionBbox={mapSelectionBbox} + mapSelectionResult={mapSelectionResult} + mapSelectionLoading={mapSelectionLoading} + mapSelectionError={mapSelectionError} availableMapDatasets={availableMapDatasets} selectedFeature={selectedMapFeature} onSelectMapArea={setSelectedMapAreaId} @@ -793,6 +811,9 @@ function App(): JSX.Element { onSetMapLayerVisible={setMapLayerVisible} onSetMapLayerOpacity={setMapLayerOpacity} onSelectMapFeature={setSelectedMapFeature} + onSetMapSelectionBbox={setMapSelectionBbox} + onRunMapSelectionExtract={runMapSelectionExtract} + onClearMapSelectionExtract={resetMapSelectionExtract} /> ) : null} diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index 36e5310f..c4950087 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -6,11 +6,15 @@ interface GeoMapProps { data: GeoJSON.FeatureCollection | null areaData?: GeoJSON.FeatureCollection | null selectedFeature?: GeoJSON.Feature | null + selectionData?: GeoJSON.FeatureCollection | null + selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null + bboxSelectionMode?: boolean visible?: boolean opacity?: number areaVisible?: boolean areaOpacity?: number onFeatureSelect?: (feature: GeoJSON.Feature | null) => void + onMapCoordinateSelect?: (coordinate: [number, number]) => void } const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = { @@ -57,25 +61,73 @@ function mergeFeatureCollections(collections: Array 0 ? { type: 'FeatureCollection', features } : null } +function bboxToFeatureCollection( + bbox: { min_x: number; min_y: number; max_x: number; max_y: number } | null | undefined, +): GeoJSON.FeatureCollection { + if (!bbox) { + return EMPTY_FEATURE_COLLECTION + } + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [bbox.min_x, bbox.min_y], + [bbox.max_x, bbox.min_y], + [bbox.max_x, bbox.max_y], + [bbox.min_x, bbox.max_y], + [bbox.min_x, bbox.min_y], + ], + ], + }, + properties: { + layer_type: 'selection_bbox', + }, + }, + ], + } +} + function GeoMap({ data, areaData = null, selectedFeature = null, + selectionData = null, + selectionBbox = null, + bboxSelectionMode = false, visible = true, opacity = 0.4, areaVisible = true, areaOpacity = 0.18, onFeatureSelect, + onMapCoordinateSelect, }: GeoMapProps): JSX.Element { const containerRef = useRef(null) const mapRef = useRef(null) const onFeatureSelectRef = useRef(onFeatureSelect) + const onMapCoordinateSelectRef = useRef(onMapCoordinateSelect) + const bboxSelectionModeRef = useRef(bboxSelectionMode) const [mapStyleReady, setMapStyleReady] = useState(false) useEffect(() => { onFeatureSelectRef.current = onFeatureSelect }, [onFeatureSelect]) + useEffect(() => { + onMapCoordinateSelectRef.current = onMapCoordinateSelect + }, [onMapCoordinateSelect]) + + useEffect(() => { + bboxSelectionModeRef.current = bboxSelectionMode + if (mapRef.current) { + mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : '' + } + }, [bboxSelectionMode]) + useEffect(() => { if (!containerRef.current || mapRef.current) { return @@ -92,6 +144,10 @@ function GeoMap({ setMapStyleReady(true) }) map.on('click', (event) => { + if (bboxSelectionModeRef.current) { + onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat]) + return + } const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId)) if (layers.length === 0) { onFeatureSelectRef.current?.(null) @@ -327,6 +383,86 @@ function GeoMap({ }) }, [selectedFeature, mapStyleReady]) + useEffect(() => { + const map = mapRef.current + if (!map || !mapStyleReady || !map.isStyleLoaded()) { + return + } + + const bboxCollection = bboxToFeatureCollection(selectionBbox) + if (map.getSource('selection-bbox')) { + ;(map.getSource('selection-bbox') as maplibregl.GeoJSONSource).setData(bboxCollection) + } else { + map.addSource('selection-bbox', { type: 'geojson', data: bboxCollection }) + map.addLayer({ + id: 'selection-bbox-fill', + type: 'fill', + source: 'selection-bbox', + paint: { + 'fill-color': '#38bdf8', + 'fill-opacity': 0.12, + }, + }) + map.addLayer({ + id: 'selection-bbox-line', + type: 'line', + source: 'selection-bbox', + paint: { + 'line-color': '#0369a1', + 'line-width': 2, + 'line-dasharray': [2, 1], + }, + }) + } + }, [selectionBbox, mapStyleReady]) + + useEffect(() => { + const map = mapRef.current + if (!map || !mapStyleReady || !map.isStyleLoaded()) { + return + } + + const resultCollection = selectionData ?? EMPTY_FEATURE_COLLECTION + if (map.getSource('selection-result')) { + ;(map.getSource('selection-result') as maplibregl.GeoJSONSource).setData(resultCollection) + return + } + + map.addSource('selection-result', { type: 'geojson', data: resultCollection }) + map.addLayer({ + id: 'selection-result-fill', + type: 'fill', + source: 'selection-result', + filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], + paint: { + 'fill-color': '#7c3aed', + 'fill-opacity': 0.24, + }, + }) + map.addLayer({ + id: 'selection-result-line', + type: 'line', + source: 'selection-result', + filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], + paint: { + 'line-color': '#5b21b6', + 'line-width': 3, + }, + }) + map.addLayer({ + id: 'selection-result-circle', + type: 'circle', + source: 'selection-result', + filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], + paint: { + 'circle-color': '#7c3aed', + 'circle-radius': 6, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2, + }, + }) + }, [selectionData, mapStyleReady]) + return
} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 060dcdcd..7cdab30e 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -1,7 +1,9 @@ +import { useEffect, useState } from 'react' import GeoMap from '../GeoMap' -import type { AreaRead, DatasetCreateResponse } from '../../types' +import type { AreaRead, DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../../types' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' +const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> { const points: Array<[number, number]> = [] @@ -52,6 +54,75 @@ function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) { } } +function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null { + const points = collection?.features.flatMap((feature) => collectGeometryPoints(feature.geometry)) ?? [] + if (points.length === 0) { + return null + } + const xs = points.map((point) => point[0]) + const ys = points.map((point) => point[1]) + return { + min_x: Math.min(...xs), + min_y: Math.min(...ys), + max_x: Math.max(...xs), + max_y: Math.max(...ys), + crs: 'EPSG:4326', + } +} + +function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null { + const points = collectGeometryPoints(feature?.geometry) + if (points.length === 0) { + return null + } + const xs = points.map((point) => point[0]) + const ys = points.map((point) => point[1]) + return { + min_x: Math.min(...xs), + min_y: Math.min(...ys), + max_x: Math.max(...xs), + max_y: Math.max(...ys), + crs: 'EPSG:4326', + } +} + +function normalizeBboxFromCorners(first: [number, number], second: [number, number]): VectorSelectionBBox { + return { + min_x: Math.min(first[0], second[0]), + min_y: Math.min(first[1], second[1]), + max_x: Math.max(first[0], second[0]), + max_y: Math.max(first[1], second[1]), + crs: 'EPSG:4326', + } +} + +function formatBboxLabel(bbox: VectorSelectionBBox | null): string { + if (!bbox) { + return 'n/a' + } + return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}` +} + +function bboxToInputState(bbox: VectorSelectionBBox | null) { + return { + min_x: bbox ? String(bbox.min_x) : '', + min_y: bbox ? String(bbox.min_y) : '', + max_x: bbox ? String(bbox.max_x) : '', + max_y: bbox ? String(bbox.max_y) : '', + } +} + +function parseBboxInput(input: ReturnType): VectorSelectionBBox | null { + const min_x = Number(input.min_x) + const min_y = Number(input.min_y) + const max_x = Number(input.max_x) + const max_y = Number(input.max_y) + if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) { + return null + } + return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' } +} + function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', @@ -116,6 +187,10 @@ interface MapWorkspaceProps { areaFeatureCount: number selectedMapFeature: GeoJSON.Feature | null selectedFeature?: GeoJSON.Feature | null + mapSelectionBbox: VectorSelectionBBox | null + mapSelectionResult: VectorSelectionResponse | null + mapSelectionLoading: boolean + mapSelectionError: string | null availableMapDatasets: DatasetCreateResponse[] onSelectMapArea: (areaId: string) => void onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void @@ -124,6 +199,9 @@ interface MapWorkspaceProps { onSetMapLayerVisible: (visible: boolean) => void onSetMapLayerOpacity: (opacity: number) => void onSelectMapFeature: (feature: GeoJSON.Feature | null) => void + onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void + onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void + onClearMapSelectionExtract: () => void } export function MapWorkspace({ @@ -142,6 +220,10 @@ export function MapWorkspace({ areaFeatureCount, selectedMapFeature, selectedFeature = selectedMapFeature, + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, availableMapDatasets, onSelectMapArea, onOpenDatasetInMap, @@ -150,7 +232,13 @@ export function MapWorkspace({ onSetMapLayerVisible, onSetMapLayerOpacity, onSelectMapFeature, + onSetMapSelectionBbox, + onRunMapSelectionExtract, + onClearMapSelectionExtract, }: MapWorkspaceProps): JSX.Element { + const [bboxSelectionMode, setBboxSelectionMode] = useState(false) + const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) + const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox)) const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) const featureProperties = selectedMapFeature?.properties ?? null const featureSummaryEntries = featureProperties @@ -161,11 +249,21 @@ export function MapWorkspace({ const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : [] const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature) const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null + const selectedFeatureBbox = getFeatureBBox(selectedMapFeature) + const activeLayerBbox = getFeatureCollectionBBox(mapFeatureCollection) + const selectedAreaBbox = getFeatureCollectionBBox(areaFeatureCollection) + const currentSelectionBbox = parseBboxInput(bboxInput) + const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? [] + const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12) const selectedFeatureStem = safeFileStem( featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature', ) const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson` + useEffect(() => { + setBboxInput(bboxToInputState(mapSelectionBbox)) + }, [mapSelectionBbox]) + const downloadSelectedMapFeature = () => { if (!selectedFeatureGeoJson) { return @@ -177,6 +275,53 @@ export function MapWorkspace({ copyText(JSON.stringify(featureProperties ?? {}, null, 2)) } + const setSelectionBbox = (bbox: VectorSelectionBBox | null) => { + onSetMapSelectionBbox(bbox) + setBboxInput(bboxToInputState(bbox)) + } + + const startBboxSelection = () => { + setFirstSelectionCorner(null) + setBboxSelectionMode(true) + } + + const handleMapCoordinateSelect = (coordinate: [number, number]) => { + if (!firstSelectionCorner) { + setFirstSelectionCorner(coordinate) + return + } + const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate) + setSelectionBbox(bbox) + setFirstSelectionCorner(null) + setBboxSelectionMode(false) + } + + const runAreaExtract = () => { + const bbox = parseBboxInput(bboxInput) + if (!bbox) { + return + } + onRunMapSelectionExtract(bbox) + } + + const clearAreaSelection = () => { + setBboxSelectionMode(false) + setFirstSelectionCorner(null) + setBboxInput(bboxToInputState(null)) + onClearMapSelectionExtract() + } + + const downloadAreaSelection = () => { + if (!mapSelectionResult) { + return + } + downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson) + } + + const copyAreaSelection = () => { + copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) + } + return (
@@ -318,15 +463,158 @@ export function MapWorkspace({ data={mapFeatureCollection} areaData={areaFeatureCollection} selectedFeature={selectedFeature} + selectionData={mapSelectionResult?.geojson ?? null} + selectionBbox={mapSelectionBbox} + bboxSelectionMode={bboxSelectionMode} visible={mapLayerVisible} opacity={mapLayerOpacity} areaVisible={areaLayerVisible} areaOpacity={areaLayerOpacity} onFeatureSelect={onSelectMapFeature} + onMapCoordinateSelect={handleMapCoordinateSelect} />
+
+
+
+

Persisted vector query

+

Area selection

+
+ + {mapSelectionResult ? `${mapSelectionResult.feature_count} selected` : bboxSelectionMode ? 'selecting' : 'ready'} + +
+
+ {bboxSelectionMode ? (firstSelectionCorner ? 'Click the opposite corner' : 'Click the first corner on the map') : 'BBox EPSG:4326'} + {formatBboxLabel(currentSelectionBbox)} +
+
+ + + + +
+
+ + + + + + +
+ {mapSelectionError ?

{mapSelectionError}

: null} + {mapSelectionResult ? ( +
+
+
+ Features + {mapSelectionResult.feature_count} +
+
+ Limit + {mapSelectionResult.limit} +
+
+ Truncated + {mapSelectionResult.truncated ? 'yes' : 'no'} +
+
+ Source + vector_features +
+
+
+ + +
+ {areaSelectionPreviewFeatures.length > 0 ? ( +
+ + + + + + + + + + {areaSelectionPreviewFeatures.map((feature, index) => ( + + + + + + ))} + +
FeatureClassSource id
{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}{String(feature.properties?.['feature_class'] ?? 'n/a')}{String(feature.properties?.['source_feature_id'] ?? 'n/a')}
+
+ ) : ( +

No persisted vector features intersect this selection.

+ )} +
+ ) : null} +
diff --git a/frontend/src/hooks/useMapSelectionExtract.ts b/frontend/src/hooks/useMapSelectionExtract.ts new file mode 100644 index 00000000..e667ac29 --- /dev/null +++ b/frontend/src/hooks/useMapSelectionExtract.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react' +import { datasetsApi } from '../services/api' +import { formatError } from '../lib/formatError' +import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types' + +interface MapSelectionExtractOptions { + selectedProjectId: string | null + selectedDataset: DatasetCreateResponse | null + isVectorDatasetType: (datasetType: string) => boolean +} + +export function useMapSelectionExtract({ + selectedProjectId, + selectedDataset, + isVectorDatasetType, +}: MapSelectionExtractOptions) { + const [mapSelectionBbox, setMapSelectionBbox] = useState(null) + const [mapSelectionResult, setMapSelectionResult] = useState(null) + const [mapSelectionLoading, setMapSelectionLoading] = useState(false) + const [mapSelectionError, setMapSelectionError] = useState(null) + + useEffect(() => { + setMapSelectionBbox(null) + setMapSelectionResult(null) + setMapSelectionError(null) + }, [selectedProjectId, selectedDataset?.id]) + + const runMapSelectionExtract = async (bbox: VectorSelectionBBox) => { + if (!selectedProjectId || !selectedDataset) { + setMapSelectionError('Open a vector dataset before extracting a map area.') + return + } + if (!isVectorDatasetType(selectedDataset.dataset_type)) { + setMapSelectionError('Area extraction requires an active vector dataset.') + return + } + + setMapSelectionLoading(true) + setMapSelectionError(null) + setMapSelectionBbox(bbox) + try { + const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, { + bbox: { ...bbox, crs: 'EPSG:4326' }, + limit: 250, + }) + setMapSelectionResult(response) + } catch (error) { + setMapSelectionResult(null) + setMapSelectionError(formatError(error, 'Area extraction failed')) + } finally { + setMapSelectionLoading(false) + } + } + + const resetMapSelectionExtract = () => { + setMapSelectionBbox(null) + setMapSelectionResult(null) + setMapSelectionError(null) + } + + return { + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, + runMapSelectionExtract, + resetMapSelectionExtract, + setMapSelectionBbox, + } +} diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts index d1b8877a..45b9a678 100644 --- a/frontend/src/services/api/datasets.ts +++ b/frontend/src/services/api/datasets.ts @@ -9,6 +9,8 @@ import type { JobRead, VectorBBoxResponse, VectorStatsResponse, + VectorSelectionRequest, + VectorSelectionResponse, VectorSummary, RasterNdviRequest, RasterNdwiRequest, @@ -70,6 +72,8 @@ export const datasetsApi = { apiGet(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/summary`), vectorStats: (projectId: string, datasetId: string): Promise => apiGet(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`), + selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise => + apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload), vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) => apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/clip`, payload), vectorBuffer: (projectId: string, datasetId: string, payload: { distance_m: number; dissolve?: boolean; output_name?: string }) => diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index d6a69016..dfff2274 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -2599,6 +2599,76 @@ button.entity-card { margin-bottom: 0.75rem; } +.bbox-select-surface { + display: grid; + gap: 0.72rem; + min-width: 0; + margin-bottom: 0.85rem; + border: 1px solid rgba(3, 105, 161, 0.24); + border-left: 4px solid #0369a1; + border-radius: 8px; + padding: 0.72rem; + background: linear-gradient(180deg, #ffffff, #f6fbff); +} + +.bbox-select-surface .panel-title-row { + margin-bottom: 0; +} + +.bbox-select-status { + display: grid; + gap: 0.22rem; + border: 1px solid var(--line); + border-radius: 7px; + padding: 0.58rem 0.66rem; + background: #ffffff; +} + +.bbox-select-status span { + color: var(--muted); + font-size: 0.72rem; + font-weight: 850; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.bbox-select-status strong { + overflow-wrap: anywhere; + font-size: 0.9rem; + line-height: 1.3; +} + +.bbox-select-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 0.5rem; +} + +.bbox-select-grid label { + min-width: 0; +} + +.bbox-select-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.bbox-select-actions button { + width: fit-content; + max-width: 100%; + margin-top: 0; +} + +.bbox-selection-result { + display: grid; + gap: 0.64rem; + min-width: 0; + border-top: 1px solid var(--line); + padding-top: 0.68rem; +} + .feature-extract-surface { display: grid; gap: 0.7rem; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 284eab9b..6ebf6d9f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -274,6 +274,27 @@ export interface VectorStatsResponse { crs?: string | null } +export interface VectorSelectionBBox { + min_x: number + min_y: number + max_x: number + max_y: number + crs?: 'EPSG:4326' +} + +export interface VectorSelectionRequest { + bbox: VectorSelectionBBox + limit?: number +} + +export interface VectorSelectionResponse { + selection_bbox: VectorSelectionBBox + feature_count: number + limit: number + truncated: boolean + geojson: GeoJSON.FeatureCollection +} + export interface DatasetListResponse { items: DatasetCreateResponse[] total: number