Persist map selections as derived datasets
This commit is contained in:
@@ -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.
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
||||
<div className="panel-title-row">
|
||||
@@ -611,11 +627,23 @@ export function MapWorkspace({
|
||||
>
|
||||
{selectionExporting ? 'Saving export...' : 'Save area export'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!currentSelectionBbox || selectionDatasetSaving}
|
||||
type="button"
|
||||
onClick={saveAreaSelectionDataset}
|
||||
>
|
||||
{selectionDatasetSaving ? 'Saving dataset...' : 'Save as dataset'}
|
||||
</button>
|
||||
</div>
|
||||
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
||||
{latestSelectionExportPath ? (
|
||||
<p className="muted">Saved selection artifact: {latestSelectionExportPath}</p>
|
||||
) : null}
|
||||
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
|
||||
{latestSelectionDatasetName ? (
|
||||
<p className="muted">Saved derived dataset: {latestSelectionDatasetName}</p>
|
||||
) : null}
|
||||
{areaSelectionPreviewFeatures.length > 0 ? (
|
||||
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
|
||||
<table>
|
||||
|
||||
@@ -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<unknown>
|
||||
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
|
||||
setMapLayerVisible: (visible: boolean) => void
|
||||
}
|
||||
|
||||
export function useMapSelectionDataset({
|
||||
selectedProjectId,
|
||||
selectedDataset,
|
||||
isVectorDatasetType,
|
||||
loadProjectData,
|
||||
loadDatasetDetails,
|
||||
setMapLayerVisible,
|
||||
}: UseMapSelectionDatasetOptions) {
|
||||
const [selectionDatasetSaving, setSelectionDatasetSaving] = useState(false)
|
||||
const [selectionDatasetError, setSelectionDatasetError] = useState<string | null>(null)
|
||||
const [latestSelectionDataset, setLatestSelectionDataset] = useState<DatasetCreateResponse | null>(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,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
VectorBBoxResponse,
|
||||
VectorStatsResponse,
|
||||
VectorSelectionRequest,
|
||||
VectorSelectionDeriveRequest,
|
||||
VectorSelectionResponse,
|
||||
VectorSummary,
|
||||
RasterNdviRequest,
|
||||
@@ -74,6 +75,8 @@ export const datasetsApi = {
|
||||
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
|
||||
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
|
||||
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload),
|
||||
deriveVectorSelection: (projectId: string, datasetId: string, payload: VectorSelectionDeriveRequest): Promise<DatasetCreateResponse> =>
|
||||
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select/derive`, payload),
|
||||
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
|
||||
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/clip`, payload),
|
||||
vectorBuffer: (projectId: string, datasetId: string, payload: { distance_m: number; dissolve?: boolean; output_name?: string }) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user