Persist map selections as derived datasets
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user