diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0dcd6b..d899cab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ # Changelog +## Sprint 108 Map selection derived datasets (2026-06-25) + +- Added `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive` to persist a map bbox selection as a reusable derived vector dataset. +- Derived selection datasets keep source provenance, write a GeoJSON artifact and index their features back into `vector_features`. +- Added `Save as dataset` to the Map workspace after area extract, including loading/error/latest dataset feedback. +- Added regression coverage for service persistence, empty-selection failure, canonical API envelope and frontend wiring. + ## Sprint 107 Map selection export handoff (2026-06-25) - Added `vector_selection` GeoJSON export support to persist bbox-selected map features as normal export artifacts. diff --git a/backend/README.md b/backend/README.md index e38185fe..dfdb2030 100644 --- a/backend/README.md +++ b/backend/README.md @@ -580,12 +580,19 @@ builder, live provider fetching or new analysis behavior. 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. +records by itself. + +`POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive` +uses the same persisted `vector_features` selection but writes the result as a +new derived vector dataset. The created dataset uses +`source="operation:selection"`, `source_name="map_selection"` and +`derived_from_dataset_id` for source provenance, stores a GeoJSON artifact and +indexes its features back into `vector_features` for later QA/QC and analysis. `POST /api/v1/exports/geojson` with `export_kind="vector_selection"` persists the same bbox-selected FeatureCollection as a normal export record with `export_type="vector_selection_geojson"`. This creates a handoff artifact only; -it does not create a derived dataset or mutate `vector_features`. +it does not create a derived dataset. ## Helpful repository scripts diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index 56433074..889ad8d1 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -25,6 +25,7 @@ from app.schemas import ( VectorClipRequest, VectorIntersectRequest, VectorSelectionBBox, + VectorSelectionDeriveRequest, VectorSelectionRequest, VectorSelectionResponse, ) @@ -205,6 +206,28 @@ def select_vector_features( return envelope(VectorSelectionResponse(**result).model_dump()) +@router.post("/datasets/{dataset_id}/vector/select/derive", status_code=201, response_model=dict) +def derive_vector_selection_dataset( + project_id: UUID, + dataset_id: UUID, + payload: VectorSelectionDeriveRequest, + 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) + derived = VectorOperationsService.derive_selection_dataset( + db=db, + dataset_id=dataset_id, + bbox=payload.bbox.model_dump(), + limit=payload.limit, + output_name=payload.output_name, + ) + return envelope(derived.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 7997b813..1865d357 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -72,6 +72,7 @@ from .operations import ( VectorOperationRequest, VectorOperationResult, VectorSelectionBBox, + VectorSelectionDeriveRequest, VectorSelectionRequest, VectorSelectionResponse, VectorStatsRequest, @@ -126,6 +127,7 @@ __all__ = [ "VectorOperationRequest", "VectorOperationResult", "VectorSelectionBBox", + "VectorSelectionDeriveRequest", "VectorSelectionRequest", "VectorSelectionResponse", "RasterClipRequest", diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py index 86f02986..d2c3dc30 100644 --- a/backend/app/schemas/operations.py +++ b/backend/app/schemas/operations.py @@ -213,6 +213,10 @@ class VectorSelectionRequest(BaseModel): limit: int = Field(default=100, ge=1, le=1000) +class VectorSelectionDeriveRequest(VectorSelectionRequest): + output_name: str | None = None + + class VectorSelectionResponse(BaseModel): selection_bbox: VectorSelectionBBox feature_count: int diff --git a/backend/app/services/vector_operations_service.py b/backend/app/services/vector_operations_service.py index 7bb63341..1aff6222 100644 --- a/backend/app/services/vector_operations_service.py +++ b/backend/app/services/vector_operations_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import uuid +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -15,9 +16,11 @@ from sqlalchemy.orm import Session from app.core.errors import AppError from app.models import Area, Dataset +from app.schemas.dataset import DatasetCreateResponse from app.schemas.operations import VectorOperationResult from app.services.geojson_service import parse_geojson_payload from app.services.storage_service import StorageService +from app.services.vector_feature_service import VectorFeatureService class VectorOperationsService: @@ -276,6 +279,125 @@ class VectorOperationsService: default_name="vector_intersect", ) + @staticmethod + def derive_selection_dataset( + db: Session, + dataset_id: uuid.UUID, + bbox: dict[str, Any], + limit: int = 250, + output_name: str | None = None, + ) -> DatasetCreateResponse: + source_dataset = db.get(Dataset, dataset_id) + if not source_dataset: + raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404) + VectorOperationsService._require_vector_dataset(source_dataset) + + selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit) + if selection["feature_count"] <= 0: + raise AppError( + code="VECTOR_OPERATION_EMPTY_RESULT", + message="Selection produced no output features", + status_code=422, + ) + + feature_collection = VectorOperationsService._selection_geojson_for_derived_dataset( + selection["geojson"], + source_dataset_id=dataset_id, + ) + derived_id = VectorOperationsService._persist_derived_dataset( + db=db, + source_dataset=source_dataset, + source_id=dataset_id, + operation="selection", + feature_collection=feature_collection, + output_name=output_name, + default_name="map_selection", + dataset_role="derived", + source_name="map_selection", + source_metadata={ + "selection_bbox": selection["selection_bbox"], + "feature_count": selection["feature_count"], + "limit": selection["limit"], + "truncated": selection["truncated"], + "source_table": "vector_features", + }, + provenance_metadata={ + "operation": "map_bbox_selection", + "source_dataset_id": str(dataset_id), + "source_table": "vector_features", + "selection_bbox": selection["selection_bbox"], + }, + metadata_extra={ + "selection_bbox": selection["selection_bbox"], + "source_feature_count": selection["feature_count"], + "selection_limit": selection["limit"], + "selection_truncated": selection["truncated"], + "source_dataset_id": str(dataset_id), + "source_table": "vector_features", + }, + persist_vector_features=True, + ) + derived = db.get(Dataset, derived_id) + if not derived: + raise AppError(code="DATASET_NOT_FOUND", message="Derived dataset was not persisted", status_code=500) + metadata = derived.metadata_json or {} + return DatasetCreateResponse( + id=derived.id, + name=derived.name, + dataset_type=derived.dataset_type, + source=derived.source, + dataset_role=derived.dataset_role, + source_name=derived.source_name, + reference_layer_name=derived.reference_layer_name, + source_metadata=derived.source_metadata, + provenance_metadata=derived.provenance_metadata, + imported_at=derived.imported_at, + project_id=derived.project_id, + area_id=derived.area_id, + storage_path=derived.storage_path, + original_filename=derived.original_filename, + stored_filename=derived.stored_filename, + content_type=derived.content_type, + size_bytes=derived.size_bytes, + checksum_sha256=derived.checksum_sha256, + crs=derived.crs, + bounds_json=derived.bounds_json, + resolution_json=derived.resolution_json, + bands_json=derived.bands_json, + metadata_json=derived.metadata_json, + vector_summary=None, + status=derived.status, + derived_from_dataset_id=derived.derived_from_dataset_id, + created_at=derived.created_at, + feature_count=metadata.get("feature_count") if isinstance(metadata, dict) else None, + ) + + @staticmethod + def _selection_geojson_for_derived_dataset(payload: dict[str, Any], source_dataset_id: uuid.UUID) -> dict[str, Any]: + features = payload.get("features") + if payload.get("type") != "FeatureCollection" or not isinstance(features, list): + raise AppError(code="INVALID_GEOJSON", message="Selection payload must be a FeatureCollection", status_code=500) + + output_features: list[dict[str, Any]] = [] + for feature in features: + if not isinstance(feature, dict): + continue + properties = dict(feature.get("properties") or {}) + source_vector_feature_id = properties.pop("vector_feature_id", feature.get("id")) + properties.pop("dataset_id", None) + properties["source_dataset_id"] = str(source_dataset_id) + if source_vector_feature_id is not None: + properties["source_vector_feature_id"] = str(source_vector_feature_id) + output_features.append( + { + "type": "Feature", + "geometry": feature.get("geometry"), + "properties": properties, + } + ) + + return {"type": "FeatureCollection", "features": output_features} + @staticmethod def _persist_derived_dataset( db: Session, @@ -285,6 +407,12 @@ class VectorOperationsService: feature_collection: dict[str, Any], output_name: str | None, default_name: str, + dataset_role: str = "derived", + source_name: str | None = None, + source_metadata: dict[str, Any] | None = None, + provenance_metadata: dict[str, Any] | None = None, + metadata_extra: dict[str, Any] | None = None, + persist_vector_features: bool = False, ) -> uuid.UUID: derived_id = uuid.uuid4() output_name_value = f"{(output_name or default_name)}.geojson" @@ -302,6 +430,8 @@ class VectorOperationsService: ) metadata = parse_geojson_payload(json.dumps(feature_collection, ensure_ascii=False, separators=(",", ":"))) + if metadata_extra: + metadata.update(metadata_extra) derived_dataset = Dataset( id=derived_id, project_id=source_dataset.project_id, @@ -309,6 +439,11 @@ class VectorOperationsService: name=output_name_value, dataset_type="vector", source=f"operation:{operation}", + dataset_role=dataset_role, + source_name=source_name, + source_metadata=source_metadata, + provenance_metadata=provenance_metadata, + imported_at=datetime.now(timezone.utc), storage_path=storage_info["storage_path"], original_filename=storage_info["original_filename"], stored_filename=storage_info["stored_filename"], @@ -326,4 +461,10 @@ class VectorOperationsService: db.add(derived_dataset) db.commit() db.refresh(derived_dataset) + if persist_vector_features: + VectorFeatureService.persist_geojson_features( + db=db, + dataset_id=derived_dataset.id, + payload=feature_collection, + ) return derived_id diff --git a/backend/tests/test_sprint108_map_selection_derived_dataset.py b/backend/tests/test_sprint108_map_selection_derived_dataset.py new file mode 100644 index 00000000..bfbec9da --- /dev/null +++ b/backend/tests/test_sprint108_map_selection_derived_dataset.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.main import app +from app.models import Dataset, VectorFeature +from app.schemas.dataset import DatasetCreateResponse +from app.services.storage_service import StorageService +from app.services.vector_feature_service import VectorFeatureService +from app.services.vector_operations_service import VectorOperationsService + + +ROOT = Path(__file__).resolve().parents[2] + + +class FakeSession: + def __init__(self, rows): + self.rows = rows + self.added = [] + + def get(self, model, row_id): + row = self.rows.get((model, row_id)) + if row is not None: + return row + for item in self.added: + if isinstance(item, model) and item.id == row_id: + return item + return None + + def add(self, row): + self.added.append(row) + + def commit(self): + return None + + def refresh(self, row): + return row + + +def test_vector_selection_derive_persists_queryable_derived_dataset(tmp_path, monkeypatch) -> None: + project_id = uuid4() + dataset_id = uuid4() + output_path = tmp_path / "selection-derived.geojson" + source_dataset = Dataset( + id=dataset_id, + project_id=project_id, + area_id=None, + name="candidate.geojson", + dataset_type="vector", + source="fixture", + dataset_role="source", + source_name="fixture", + storage_path=str(tmp_path / "candidate.geojson"), + status="ready", + ) + db = FakeSession({(Dataset, dataset_id): source_dataset}) + selection_bbox = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"} + selection_payload = { + "selection_bbox": selection_bbox, + "feature_count": 1, + "limit": 250, + "truncated": False, + "geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "source-row-1", + "geometry": {"type": "Point", "coordinates": [5.0, 51.0]}, + "properties": { + "vector_feature_id": "source-row-1", + "dataset_id": str(dataset_id), + "source_feature_id": "pred-1", + "feature_class": "building", + "confidence": 0.8, + }, + } + ], + }, + } + persisted_features = [] + + def _persist_dataset_file(project_id: str, dataset_id: str, dataset_type: str, original_filename: str, content: bytes, content_type: str | None): + output_path.write_bytes(content) + return { + "original_filename": original_filename, + "stored_filename": output_path.name, + "content_type": content_type or "application/geo+json", + "size_bytes": len(content), + "checksum_sha256": "selection-checksum", + "storage_path": str(output_path), + } + + def _persist_geojson_features(db, dataset_id, payload, feature_class=None, *, commit=True): + persisted_features.append({"dataset_id": dataset_id, "payload": payload, "feature_class": feature_class, "commit": commit}) + return [] + + monkeypatch.setattr(StorageService, "persist_dataset_file", _persist_dataset_file) + monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", lambda *_args, **_kwargs: selection_payload) + monkeypatch.setattr(VectorFeatureService, "persist_geojson_features", _persist_geojson_features) + + response = VectorOperationsService.derive_selection_dataset( + db=db, + dataset_id=dataset_id, + bbox=selection_bbox, + limit=250, + output_name="selected-buildings", + ) + + derived = [item for item in db.added if isinstance(item, Dataset)][0] + assert response.id == derived.id + assert response.project_id == project_id + assert response.dataset_role == "derived" + assert response.source == "operation:selection" + assert response.source_name == "map_selection" + assert response.derived_from_dataset_id == dataset_id + assert response.feature_count == 1 + assert response.metadata_json["selection_bbox"] == selection_bbox + assert response.metadata_json["source_feature_count"] == 1 + assert response.provenance_metadata["source_dataset_id"] == str(dataset_id) + assert response.provenance_metadata["source_table"] == "vector_features" + assert persisted_features[0]["dataset_id"] == derived.id + assert persisted_features[0]["commit"] is True + derived_payload = json.loads(output_path.read_text(encoding="utf-8")) + props = derived_payload["features"][0]["properties"] + assert props["source_vector_feature_id"] == "source-row-1" + assert props["source_dataset_id"] == str(dataset_id) + assert "vector_feature_id" not in props + + +def test_vector_selection_derive_rejects_empty_selection(monkeypatch, tmp_path) -> None: + dataset_id = uuid4() + source_dataset = Dataset( + id=dataset_id, + project_id=uuid4(), + name="candidate.geojson", + dataset_type="vector", + source="fixture", + storage_path=str(tmp_path / "candidate.geojson"), + status="ready", + ) + db = FakeSession({(Dataset, dataset_id): source_dataset}) + monkeypatch.setattr( + VectorFeatureService, + "select_features_by_bbox", + lambda *_args, **_kwargs: { + "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": 250, + "truncated": False, + "geojson": {"type": "FeatureCollection", "features": []}, + }, + ) + + try: + VectorOperationsService.derive_selection_dataset( + db=db, + 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=250, + output_name="empty-selection", + ) + except Exception as exc: + assert getattr(exc, "code") == "VECTOR_OPERATION_EMPTY_RESULT" + else: + raise AssertionError("Empty selection should not create a derived dataset") + + +def test_vector_selection_derive_endpoint_returns_canonical_dataset_envelope(monkeypatch) -> None: + project_id = uuid4() + dataset_id = uuid4() + derived_id = uuid4() + bbox = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"} + + monkeypatch.setattr( + "app.api.routes.datasets.DatasetService.get_dataset", + lambda _db, requested_id: Dataset(id=requested_id, project_id=project_id, name="source.geojson", dataset_type="vector", source="fixture"), + ) + monkeypatch.setattr( + "app.api.routes.datasets.VectorOperationsService.derive_selection_dataset", + lambda *_args, **_kwargs: DatasetCreateResponse( + id=derived_id, + name="selected-buildings.geojson", + dataset_type="vector", + source="operation:selection", + dataset_role="derived", + source_name="map_selection", + project_id=project_id, + status="ready", + derived_from_dataset_id=dataset_id, + feature_count=1, + metadata_json={"selection_bbox": bbox, "source_feature_count": 1}, + ), + ) + + response = TestClient(app).post( + f"/api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive", + json={"bbox": bbox, "limit": 250, "output_name": "selected-buildings"}, + ) + + assert response.status_code == 201 + payload = response.json() + assert set(payload) == {"data"} + assert payload["data"]["id"] == str(derived_id) + assert payload["data"]["dataset_role"] == "derived" + assert payload["data"]["source_name"] == "map_selection" + assert payload["data"]["derived_from_dataset_id"] == str(dataset_id) + + +def test_frontend_exposes_map_selection_derive_action() -> None: + types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") + datasets_api = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8") + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") + + assert "VectorSelectionDeriveRequest" in types + assert "deriveVectorSelection" in datasets_api + assert "deriveMapSelectionDataset" in app + assert "Save as dataset" in map_workspace + assert "selectionDatasetError" in map_workspace diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 0fb2f2a2..be7ff465 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -406,6 +406,40 @@ Rules: - 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. +### 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 +selected output into `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, + "output_name": "selected-buildings" +} +``` + +Response: `DatasetRead` in the canonical API envelope. + +Rules: + +- Only vector/GeoJSON datasets are supported. +- Coordinates are EPSG:4326 longitude/latitude. +- The new dataset uses `dataset_role="derived"`, `source="operation:selection"`, + `source_name="map_selection"` and `derived_from_dataset_id` pointing to the + source dataset. +- The persisted GeoJSON properties retain source provenance as + `source_dataset_id` and `source_vector_feature_id`. +- Empty selections return `VECTOR_OPERATION_EMPTY_RESULT` and do not create a dataset. + ### 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 a215a7b1..006aabf0 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,34 @@ +## Sprint 108 Map selection derived datasets (2026-06-25) + +Changed: +- Added `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive`. +- Added `VectorSelectionDeriveRequest` for bbox selection-to-derived-dataset requests. +- Added `VectorOperationsService.derive_selection_dataset`, which selects persisted PostGIS `vector_features`, writes a derived GeoJSON dataset artifact, stores source provenance and re-indexes the derived features into `vector_features`. +- Added `Save as dataset` to the Map workspace selection result state with loading/error/latest-dataset feedback. +- Updated frontend dataset API typing and wiring for selection-derived datasets. +- Updated `docs/API_CONTRACTS.md`, `backend/README.md`, `frontend/README.md`, `CHANGELOG.md` and `docs/TODO.md`. +- Added regression coverage in `backend/tests/test_sprint108_map_selection_derived_dataset.py`. + +Validation: +- RED: `python -m pytest backend\tests\test_sprint108_map_selection_derived_dataset.py -q` failed before implementation because the service, route and frontend contracts were absent. +- `python -m pytest backend\tests\test_sprint108_map_selection_derived_dataset.py -q` passed: 4 tests. +- `python -m compileall backend/app` passed. +- `cd backend && python -m pytest -q` passed: 345 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` 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. +- `bash scripts/run_readiness_check.sh` passed. + +Limitations: +- Selection-derived datasets are bbox-only and use EPSG:4326 coordinates. +- Empty selections are rejected with `VECTOR_OPERATION_EMPTY_RESULT`. +- No migrations, live provider fetching, AI dependency, real model behavior or new product domain were added. + +Next recommended pass: +- Run full release validation, deploy to `http://192.168.10.150:1202` and live-smoke `Save as dataset` from the Map workspace. + ## Sprint 107 Map selection export handoff (2026-06-25) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 1ddf15ab..3f4efb0d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -374,3 +374,4 @@ This file now starts with the current implementation status. Older preparation/b - [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. - [x] Persist map area selections as Export Center handoff artifacts. +- [x] Persist map area selections as reusable derived vector datasets indexed into `vector_features`. diff --git a/frontend/README.md b/frontend/README.md index 87608d5a..d65970d0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -279,6 +279,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst - 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. - After an area extract, `Save area export` persists the selected FeatureCollection as a normal Export Center artifact (`vector_selection_geojson`) so the handoff remains in project export history. +- `Save as dataset` persists the same selected FeatureCollection as a derived vector dataset, selects it in the workbench and keeps it queryable through backend `vector_features` for later QA/QC or analysis. - 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 ff540cd7..01eeaf30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { useDemoWorkflow } from './hooks/useDemoWorkflow' import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow' +import { useMapSelectionDataset } from './hooks/useMapSelectionDataset' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useProviderCapabilities } from './hooks/useProviderCapabilities' @@ -385,6 +386,19 @@ function App(): JSX.Element { selectedDataset, isVectorDatasetType, }) + const { + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDataset, + deriveMapSelectionDataset, + } = useMapSelectionDataset({ + selectedProjectId, + selectedDataset, + isVectorDatasetType, + loadProjectData, + loadDatasetDetails, + setMapLayerVisible, + }) const { loadingDemoWorkflow, demoWorkflowMessage, @@ -809,6 +823,9 @@ function App(): JSX.Element { selectionExporting={selectionExporting} selectionExportError={selectionExportError} latestSelectionExportPath={latestSelectionExport?.path ?? null} + selectionDatasetSaving={selectionDatasetSaving} + selectionDatasetError={selectionDatasetError} + latestSelectionDatasetName={latestSelectionDataset?.name ?? null} availableMapDatasets={availableMapDatasets} selectedFeature={selectedMapFeature} onSelectMapArea={setSelectedMapAreaId} @@ -822,6 +839,7 @@ function App(): JSX.Element { onRunMapSelectionExtract={runMapSelectionExtract} onClearMapSelectionExtract={resetMapSelectionExtract} onExportMapSelection={exportMapSelectionGeoJson} + onDeriveMapSelectionDataset={deriveMapSelectionDataset} /> ) : null} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 8eaac968..9384a36d 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -194,6 +194,9 @@ interface MapWorkspaceProps { selectionExporting: boolean selectionExportError: string | null latestSelectionExportPath: string | null + selectionDatasetSaving: boolean + selectionDatasetError: string | null + latestSelectionDatasetName: string | null availableMapDatasets: DatasetCreateResponse[] onSelectMapArea: (areaId: string) => void onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void @@ -206,6 +209,7 @@ interface MapWorkspaceProps { onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void onClearMapSelectionExtract: () => void onExportMapSelection: (bbox: VectorSelectionBBox) => void + onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => void } export function MapWorkspace({ @@ -231,6 +235,9 @@ export function MapWorkspace({ selectionExporting, selectionExportError, latestSelectionExportPath, + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDatasetName, availableMapDatasets, onSelectMapArea, onOpenDatasetInMap, @@ -243,6 +250,7 @@ export function MapWorkspace({ onRunMapSelectionExtract, onClearMapSelectionExtract, onExportMapSelection, + onDeriveMapSelectionDataset, }: MapWorkspaceProps): JSX.Element { const [bboxSelectionMode, setBboxSelectionMode] = useState(false) const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) @@ -338,6 +346,14 @@ export function MapWorkspace({ onExportMapSelection(bbox) } + const saveAreaSelectionDataset = () => { + const bbox = parseBboxInput(bboxInput) + if (!bbox) { + return + } + onDeriveMapSelectionDataset(bbox) + } + return (
@@ -611,11 +627,23 @@ export function MapWorkspace({ > {selectionExporting ? 'Saving export...' : 'Save area export'} +
{selectionExportError ?

{selectionExportError}

: null} {latestSelectionExportPath ? (

Saved selection artifact: {latestSelectionExportPath}

) : null} + {selectionDatasetError ?

{selectionDatasetError}

: null} + {latestSelectionDatasetName ? ( +

Saved derived dataset: {latestSelectionDatasetName}

+ ) : null} {areaSelectionPreviewFeatures.length > 0 ? (
diff --git a/frontend/src/hooks/useMapSelectionDataset.ts b/frontend/src/hooks/useMapSelectionDataset.ts new file mode 100644 index 00000000..920f61f6 --- /dev/null +++ b/frontend/src/hooks/useMapSelectionDataset.ts @@ -0,0 +1,57 @@ +import { useState } from 'react' +import type { DatasetCreateResponse, VectorSelectionBBox } from '../types' +import { formatError } from '../lib/formatError' +import { datasetsApi } from '../services/api' + +interface UseMapSelectionDatasetOptions { + selectedProjectId: string | null + selectedDataset: DatasetCreateResponse | null + isVectorDatasetType: (datasetType: string) => boolean + loadProjectData: (projectId: string) => Promise + loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise + setMapLayerVisible: (visible: boolean) => void +} + +export function useMapSelectionDataset({ + selectedProjectId, + selectedDataset, + isVectorDatasetType, + loadProjectData, + loadDatasetDetails, + setMapLayerVisible, +}: UseMapSelectionDatasetOptions) { + const [selectionDatasetSaving, setSelectionDatasetSaving] = useState(false) + const [selectionDatasetError, setSelectionDatasetError] = useState(null) + const [latestSelectionDataset, setLatestSelectionDataset] = useState(null) + + const deriveMapSelectionDataset = async (bbox: VectorSelectionBBox) => { + if (!selectedProjectId || !selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) { + setSelectionDatasetError('Select a vector dataset before saving the area as a dataset.') + return + } + setSelectionDatasetSaving(true) + setSelectionDatasetError(null) + try { + const derived = await datasetsApi.deriveVectorSelection(selectedProjectId, selectedDataset.id, { + bbox: { ...bbox, crs: 'EPSG:4326' }, + limit: 250, + output_name: `${selectedDataset.name.replace(/\.(geo)?json$/i, '')}-selection-dataset`, + }) + setLatestSelectionDataset(derived) + await loadProjectData(selectedProjectId) + await loadDatasetDetails(selectedProjectId, derived) + setMapLayerVisible(true) + } catch (error) { + setSelectionDatasetError(formatError(error, 'Failed to save area as dataset')) + } finally { + setSelectionDatasetSaving(false) + } + } + + return { + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDataset, + deriveMapSelectionDataset, + } +} diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts index 45b9a678..db53283a 100644 --- a/frontend/src/services/api/datasets.ts +++ b/frontend/src/services/api/datasets.ts @@ -10,6 +10,7 @@ import type { VectorBBoxResponse, VectorStatsResponse, VectorSelectionRequest, + VectorSelectionDeriveRequest, VectorSelectionResponse, VectorSummary, RasterNdviRequest, @@ -74,6 +75,8 @@ export const datasetsApi = { apiGet(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`), selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise => apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload), + deriveVectorSelection: (projectId: string, datasetId: string, payload: VectorSelectionDeriveRequest): Promise => + apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload), vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) => 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/types.ts b/frontend/src/types.ts index 35cb1f92..7549589a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -287,6 +287,10 @@ export interface VectorSelectionRequest { limit?: number } +export interface VectorSelectionDeriveRequest extends VectorSelectionRequest { + output_name?: string +} + export interface VectorSelectionResponse { selection_bbox: VectorSelectionBBox feature_count: number