Persist map selections as derived datasets
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-25 02:25:49 +02:00
parent 1e42302f62
commit b92faafa74
16 changed files with 587 additions and 2 deletions
+7
View File
@@ -7,6 +7,13 @@
# Changelog # 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) ## 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. - Added `vector_selection` GeoJSON export support to persist bbox-selected map features as normal export artifacts.
+9 -2
View File
@@ -580,12 +580,19 @@ builder, live provider fetching or new analysis behavior.
read-only EPSG:4326 bbox query against persisted PostGIS `vector_features` and read-only EPSG:4326 bbox query against persisted PostGIS `vector_features` and
returns a canonical-envelope GeoJSON FeatureCollection. It is intended for the returns a canonical-envelope GeoJSON FeatureCollection. It is intended for the
Map workspace area-extract flow and does not create derived datasets or export 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 `POST /api/v1/exports/geojson` with `export_kind="vector_selection"` persists
the same bbox-selected FeatureCollection as a normal export record with the same bbox-selected FeatureCollection as a normal export record with
`export_type="vector_selection_geojson"`. This creates a handoff artifact only; `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 ## Helpful repository scripts
+23
View File
@@ -25,6 +25,7 @@ from app.schemas import (
VectorClipRequest, VectorClipRequest,
VectorIntersectRequest, VectorIntersectRequest,
VectorSelectionBBox, VectorSelectionBBox,
VectorSelectionDeriveRequest,
VectorSelectionRequest, VectorSelectionRequest,
VectorSelectionResponse, VectorSelectionResponse,
) )
@@ -205,6 +206,28 @@ def select_vector_features(
return envelope(VectorSelectionResponse(**result).model_dump()) 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) @router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
def clip_vector_dataset( def clip_vector_dataset(
project_id: UUID, project_id: UUID,
+2
View File
@@ -72,6 +72,7 @@ from .operations import (
VectorOperationRequest, VectorOperationRequest,
VectorOperationResult, VectorOperationResult,
VectorSelectionBBox, VectorSelectionBBox,
VectorSelectionDeriveRequest,
VectorSelectionRequest, VectorSelectionRequest,
VectorSelectionResponse, VectorSelectionResponse,
VectorStatsRequest, VectorStatsRequest,
@@ -126,6 +127,7 @@ __all__ = [
"VectorOperationRequest", "VectorOperationRequest",
"VectorOperationResult", "VectorOperationResult",
"VectorSelectionBBox", "VectorSelectionBBox",
"VectorSelectionDeriveRequest",
"VectorSelectionRequest", "VectorSelectionRequest",
"VectorSelectionResponse", "VectorSelectionResponse",
"RasterClipRequest", "RasterClipRequest",
+4
View File
@@ -213,6 +213,10 @@ class VectorSelectionRequest(BaseModel):
limit: int = Field(default=100, ge=1, le=1000) limit: int = Field(default=100, ge=1, le=1000)
class VectorSelectionDeriveRequest(VectorSelectionRequest):
output_name: str | None = None
class VectorSelectionResponse(BaseModel): class VectorSelectionResponse(BaseModel):
selection_bbox: VectorSelectionBBox selection_bbox: VectorSelectionBBox
feature_count: int feature_count: int
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import uuid import uuid
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,9 +16,11 @@ from sqlalchemy.orm import Session
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Area, Dataset from app.models import Area, Dataset
from app.schemas.dataset import DatasetCreateResponse
from app.schemas.operations import VectorOperationResult from app.schemas.operations import VectorOperationResult
from app.services.geojson_service import parse_geojson_payload from app.services.geojson_service import parse_geojson_payload
from app.services.storage_service import StorageService from app.services.storage_service import StorageService
from app.services.vector_feature_service import VectorFeatureService
class VectorOperationsService: class VectorOperationsService:
@@ -276,6 +279,125 @@ class VectorOperationsService:
default_name="vector_intersect", 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 @staticmethod
def _persist_derived_dataset( def _persist_derived_dataset(
db: Session, db: Session,
@@ -285,6 +407,12 @@ class VectorOperationsService:
feature_collection: dict[str, Any], feature_collection: dict[str, Any],
output_name: str | None, output_name: str | None,
default_name: str, 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: ) -> uuid.UUID:
derived_id = uuid.uuid4() derived_id = uuid.uuid4()
output_name_value = f"{(output_name or default_name)}.geojson" 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=(",", ":"))) metadata = parse_geojson_payload(json.dumps(feature_collection, ensure_ascii=False, separators=(",", ":")))
if metadata_extra:
metadata.update(metadata_extra)
derived_dataset = Dataset( derived_dataset = Dataset(
id=derived_id, id=derived_id,
project_id=source_dataset.project_id, project_id=source_dataset.project_id,
@@ -309,6 +439,11 @@ class VectorOperationsService:
name=output_name_value, name=output_name_value,
dataset_type="vector", dataset_type="vector",
source=f"operation:{operation}", 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"], storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"], original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"], stored_filename=storage_info["stored_filename"],
@@ -326,4 +461,10 @@ class VectorOperationsService:
db.add(derived_dataset) db.add(derived_dataset)
db.commit() db.commit()
db.refresh(derived_dataset) 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 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
+34
View File
@@ -406,6 +406,40 @@ Rules:
- Results are generated from persisted PostGIS `vector_features`, not from client-side map data. - 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. - 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` ### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}/content`
Returns stored vector dataset content through the canonical API envelope. Returns stored vector dataset content through the canonical API envelope.
+31
View File
@@ -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) ## Sprint 107 Map selection export handoff (2026-06-25)
Changed: Changed:
+1
View File
@@ -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 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] 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 Export Center handoff artifacts.
- [x] Persist map area selections as reusable derived vector datasets indexed into `vector_features`.
+1
View File
@@ -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. - 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 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. - 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. - 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`. - 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. - 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.
+18
View File
@@ -19,6 +19,7 @@ import { useDemoWorkflow } from './hooks/useDemoWorkflow'
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
import { useExportWorkflow } from './hooks/useExportWorkflow' import { useExportWorkflow } from './hooks/useExportWorkflow'
import { useMapSelectionDataset } from './hooks/useMapSelectionDataset'
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
import { useProviderCapabilities } from './hooks/useProviderCapabilities' import { useProviderCapabilities } from './hooks/useProviderCapabilities'
@@ -385,6 +386,19 @@ function App(): JSX.Element {
selectedDataset, selectedDataset,
isVectorDatasetType, isVectorDatasetType,
}) })
const {
selectionDatasetSaving,
selectionDatasetError,
latestSelectionDataset,
deriveMapSelectionDataset,
} = useMapSelectionDataset({
selectedProjectId,
selectedDataset,
isVectorDatasetType,
loadProjectData,
loadDatasetDetails,
setMapLayerVisible,
})
const { const {
loadingDemoWorkflow, loadingDemoWorkflow,
demoWorkflowMessage, demoWorkflowMessage,
@@ -809,6 +823,9 @@ function App(): JSX.Element {
selectionExporting={selectionExporting} selectionExporting={selectionExporting}
selectionExportError={selectionExportError} selectionExportError={selectionExportError}
latestSelectionExportPath={latestSelectionExport?.path ?? null} latestSelectionExportPath={latestSelectionExport?.path ?? null}
selectionDatasetSaving={selectionDatasetSaving}
selectionDatasetError={selectionDatasetError}
latestSelectionDatasetName={latestSelectionDataset?.name ?? null}
availableMapDatasets={availableMapDatasets} availableMapDatasets={availableMapDatasets}
selectedFeature={selectedMapFeature} selectedFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId} onSelectMapArea={setSelectedMapAreaId}
@@ -822,6 +839,7 @@ function App(): JSX.Element {
onRunMapSelectionExtract={runMapSelectionExtract} onRunMapSelectionExtract={runMapSelectionExtract}
onClearMapSelectionExtract={resetMapSelectionExtract} onClearMapSelectionExtract={resetMapSelectionExtract}
onExportMapSelection={exportMapSelectionGeoJson} onExportMapSelection={exportMapSelectionGeoJson}
onDeriveMapSelectionDataset={deriveMapSelectionDataset}
/> />
) : null} ) : null}
@@ -194,6 +194,9 @@ interface MapWorkspaceProps {
selectionExporting: boolean selectionExporting: boolean
selectionExportError: string | null selectionExportError: string | null
latestSelectionExportPath: string | null latestSelectionExportPath: string | null
selectionDatasetSaving: boolean
selectionDatasetError: string | null
latestSelectionDatasetName: string | null
availableMapDatasets: DatasetCreateResponse[] availableMapDatasets: DatasetCreateResponse[]
onSelectMapArea: (areaId: string) => void onSelectMapArea: (areaId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
@@ -206,6 +209,7 @@ interface MapWorkspaceProps {
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void
onClearMapSelectionExtract: () => void onClearMapSelectionExtract: () => void
onExportMapSelection: (bbox: VectorSelectionBBox) => void onExportMapSelection: (bbox: VectorSelectionBBox) => void
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => void
} }
export function MapWorkspace({ export function MapWorkspace({
@@ -231,6 +235,9 @@ export function MapWorkspace({
selectionExporting, selectionExporting,
selectionExportError, selectionExportError,
latestSelectionExportPath, latestSelectionExportPath,
selectionDatasetSaving,
selectionDatasetError,
latestSelectionDatasetName,
availableMapDatasets, availableMapDatasets,
onSelectMapArea, onSelectMapArea,
onOpenDatasetInMap, onOpenDatasetInMap,
@@ -243,6 +250,7 @@ export function MapWorkspace({
onRunMapSelectionExtract, onRunMapSelectionExtract,
onClearMapSelectionExtract, onClearMapSelectionExtract,
onExportMapSelection, onExportMapSelection,
onDeriveMapSelectionDataset,
}: MapWorkspaceProps): JSX.Element { }: MapWorkspaceProps): JSX.Element {
const [bboxSelectionMode, setBboxSelectionMode] = useState(false) const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
@@ -338,6 +346,14 @@ export function MapWorkspace({
onExportMapSelection(bbox) onExportMapSelection(bbox)
} }
const saveAreaSelectionDataset = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
onDeriveMapSelectionDataset(bbox)
}
return ( return (
<section className="map-workspace-shell" data-testid="map-workspace"> <section className="map-workspace-shell" data-testid="map-workspace">
<div className="panel-title-row"> <div className="panel-title-row">
@@ -611,11 +627,23 @@ export function MapWorkspace({
> >
{selectionExporting ? 'Saving export...' : 'Save area export'} {selectionExporting ? 'Saving export...' : 'Save area export'}
</button> </button>
<button
className="secondary-action"
disabled={!currentSelectionBbox || selectionDatasetSaving}
type="button"
onClick={saveAreaSelectionDataset}
>
{selectionDatasetSaving ? 'Saving dataset...' : 'Save as dataset'}
</button>
</div> </div>
{selectionExportError ? <p className="error">{selectionExportError}</p> : null} {selectionExportError ? <p className="error">{selectionExportError}</p> : null}
{latestSelectionExportPath ? ( {latestSelectionExportPath ? (
<p className="muted">Saved selection artifact: {latestSelectionExportPath}</p> <p className="muted">Saved selection artifact: {latestSelectionExportPath}</p>
) : null} ) : null}
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
{latestSelectionDatasetName ? (
<p className="muted">Saved derived dataset: {latestSelectionDatasetName}</p>
) : null}
{areaSelectionPreviewFeatures.length > 0 ? ( {areaSelectionPreviewFeatures.length > 0 ? (
<div className="table-scroll feature-property-table" aria-label="Area selection feature table"> <div className="table-scroll feature-property-table" aria-label="Area selection feature table">
<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,
}
}
+3
View File
@@ -10,6 +10,7 @@ import type {
VectorBBoxResponse, VectorBBoxResponse,
VectorStatsResponse, VectorStatsResponse,
VectorSelectionRequest, VectorSelectionRequest,
VectorSelectionDeriveRequest,
VectorSelectionResponse, VectorSelectionResponse,
VectorSummary, VectorSummary,
RasterNdviRequest, RasterNdviRequest,
@@ -74,6 +75,8 @@ export const datasetsApi = {
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`), apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> => selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload), apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload),
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 }) => vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/clip`, payload), 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 }) => vectorBuffer: (projectId: string, datasetId: string, payload: { distance_m: number; dissolve?: boolean; output_name?: string }) =>
+4
View File
@@ -287,6 +287,10 @@ export interface VectorSelectionRequest {
limit?: number limit?: number
} }
export interface VectorSelectionDeriveRequest extends VectorSelectionRequest {
output_name?: string
}
export interface VectorSelectionResponse { export interface VectorSelectionResponse {
selection_bbox: VectorSelectionBBox selection_bbox: VectorSelectionBBox
feature_count: number feature_count: number