Persist map area selection exports
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Selection exports query persisted PostGIS `vector_features`, write a `vector_selection_geojson` FeatureCollection and store selection bbox/count metadata in the export record.
|
||||||
|
- Added `Save area export` to the Map workspace after an area extract, including loading/error state and latest artifact path feedback.
|
||||||
|
- Updated frontend export typing/API hook wiring so saved selections appear in the existing Export Center history.
|
||||||
|
- No migrations, provider fetching, AI behavior, real model dependencies or new product domains were introduced.
|
||||||
|
|
||||||
## Sprint 106 Map area selection extract (2026-06-25)
|
## Sprint 106 Map area selection extract (2026-06-25)
|
||||||
|
|
||||||
- Added a read-only bbox selection endpoint for vector datasets: `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select`.
|
- Added a read-only bbox selection endpoint for vector datasets: `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select`.
|
||||||
|
|||||||
@@ -582,6 +582,11 @@ 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.
|
||||||
|
|
||||||
|
`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`.
|
||||||
|
|
||||||
## Helpful repository scripts
|
## Helpful repository scripts
|
||||||
|
|
||||||
- `bash scripts/backend_install.sh`
|
- `bash scripts/backend_install.sh`
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ router = APIRouter(prefix="/exports", tags=["exports"])
|
|||||||
|
|
||||||
@router.post("/geojson", response_model=dict)
|
@router.post("/geojson", response_model=dict)
|
||||||
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
|
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
|
||||||
|
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
|
||||||
|
return envelope(
|
||||||
|
ExportService.export_vector_selection_geojson(
|
||||||
|
db,
|
||||||
|
payload.dataset_id,
|
||||||
|
payload.bbox.model_dump(),
|
||||||
|
limit=payload.limit,
|
||||||
|
name=payload.name,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
)
|
||||||
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
|
if payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
|
||||||
return envelope(
|
return envelope(
|
||||||
ExportService.export_detection_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
|
ExportService.export_detection_run_geojson(db, payload.analysis_run_id, payload.name).model_dump(mode="json")
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from uuid import UUID
|
|||||||
|
|
||||||
from pydantic import BaseModel, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
|
from app.schemas.operations import VectorSelectionBBox
|
||||||
|
|
||||||
ExportKind = Literal["dataset", "detection_run", "segmentation_run"]
|
|
||||||
|
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
|
||||||
|
|
||||||
|
|
||||||
class GeoJsonExportRequest(BaseModel):
|
class GeoJsonExportRequest(BaseModel):
|
||||||
@@ -15,11 +17,18 @@ class GeoJsonExportRequest(BaseModel):
|
|||||||
analysis_run_id: UUID | None = None
|
analysis_run_id: UUID | None = None
|
||||||
export_kind: ExportKind = "dataset"
|
export_kind: ExportKind = "dataset"
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
bbox: VectorSelectionBBox | None = None
|
||||||
|
limit: int = 250
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_target(self) -> "GeoJsonExportRequest":
|
def validate_target(self) -> "GeoJsonExportRequest":
|
||||||
if self.export_kind == "dataset" and self.dataset_id is None:
|
if self.export_kind == "dataset" and self.dataset_id is None:
|
||||||
raise ValueError("dataset_id is required for dataset GeoJSON exports")
|
raise ValueError("dataset_id is required for dataset GeoJSON exports")
|
||||||
|
if self.export_kind == "vector_selection":
|
||||||
|
if self.dataset_id is None:
|
||||||
|
raise ValueError("dataset_id is required for vector selection GeoJSON exports")
|
||||||
|
if self.bbox is None:
|
||||||
|
raise ValueError("bbox is required for vector selection GeoJSON exports")
|
||||||
if self.export_kind in {"detection_run", "segmentation_run"} and self.analysis_run_id is None:
|
if self.export_kind in {"detection_run", "segmentation_run"} and self.analysis_run_id is None:
|
||||||
raise ValueError("analysis_run_id is required for run GeoJSON exports")
|
raise ValueError("analysis_run_id is required for run GeoJSON exports")
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -16,9 +16,54 @@ from app.services.dataset_service import DatasetService
|
|||||||
from app.services.detection_service import DetectionService
|
from app.services.detection_service import DetectionService
|
||||||
from app.services.segmentation_service import SegmentationService
|
from app.services.segmentation_service import SegmentationService
|
||||||
from app.services.storage_service import StorageService
|
from app.services.storage_service import StorageService
|
||||||
|
from app.services.vector_feature_service import VectorFeatureService
|
||||||
|
|
||||||
|
|
||||||
class ExportService:
|
class ExportService:
|
||||||
|
@staticmethod
|
||||||
|
def export_vector_selection_geojson(
|
||||||
|
db: Session,
|
||||||
|
dataset_id: uuid.UUID,
|
||||||
|
bbox: dict[str, Any],
|
||||||
|
limit: int = 250,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> ExportCreateResponse:
|
||||||
|
dataset = db.get(Dataset, dataset_id)
|
||||||
|
if not dataset:
|
||||||
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||||
|
if dataset.dataset_type not in DatasetService.VECTOR_TYPES:
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_DATASET_TYPE",
|
||||||
|
message="Vector selection export requires a vector dataset",
|
||||||
|
details={"dataset_type": dataset.dataset_type},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
|
||||||
|
filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson")
|
||||||
|
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
|
||||||
|
metadata = {
|
||||||
|
"source": "vector_selection",
|
||||||
|
"project_id": str(dataset.project_id),
|
||||||
|
"dataset_id": str(dataset.id),
|
||||||
|
"dataset_type": dataset.dataset_type,
|
||||||
|
"selection_bbox": selection["selection_bbox"],
|
||||||
|
"feature_count": selection["feature_count"],
|
||||||
|
"limit": selection["limit"],
|
||||||
|
"truncated": selection["truncated"],
|
||||||
|
"source_table": "vector_features",
|
||||||
|
}
|
||||||
|
export = ExportService._write_json_export(
|
||||||
|
db,
|
||||||
|
project_id=dataset.project_id,
|
||||||
|
analysis_run_id=None,
|
||||||
|
export_type="vector_selection_geojson",
|
||||||
|
storage_path=export_path,
|
||||||
|
content=selection["geojson"],
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return ExportService._create_response(export)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def export_dataset_geojson(db: Session, dataset_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
def export_dataset_geojson(db: Session, dataset_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
||||||
dataset = db.get(Dataset, dataset_id)
|
dataset = db.get(Dataset, dataset_id)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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, Export
|
||||||
|
from app.schemas.export import ExportCreateResponse
|
||||||
|
from app.services.export_service import ExportService
|
||||||
|
from app.services.storage_service import StorageService
|
||||||
|
from app.services.vector_feature_service import VectorFeatureService
|
||||||
|
|
||||||
|
|
||||||
|
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_geojson_export_persists_handoff_artifact(tmp_path, monkeypatch) -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
dataset_id = uuid4()
|
||||||
|
export_path = tmp_path / "exports" / "selection.geojson"
|
||||||
|
dataset = Dataset(
|
||||||
|
id=dataset_id,
|
||||||
|
project_id=project_id,
|
||||||
|
name="candidate.geojson",
|
||||||
|
dataset_type="vector",
|
||||||
|
source="fixture",
|
||||||
|
status="ready",
|
||||||
|
)
|
||||||
|
db = FakeSession({(Dataset, dataset_id): 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",
|
||||||
|
"geometry": {"type": "Point", "coordinates": [5.0, 51.0]},
|
||||||
|
"properties": {"vector_feature_id": "vf-1"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
|
||||||
|
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", lambda *_args, **_kwargs: selection_payload)
|
||||||
|
|
||||||
|
response = ExportService.export_vector_selection_geojson(db, dataset_id, selection_bbox, limit=250, name="selected-buildings")
|
||||||
|
|
||||||
|
exports = [item for item in db.added if isinstance(item, Export)]
|
||||||
|
assert len(exports) == 1
|
||||||
|
assert response.export_id == exports[0].id
|
||||||
|
assert response.export_type == "vector_selection_geojson"
|
||||||
|
assert response.metadata_json["source"] == "vector_selection"
|
||||||
|
assert response.metadata_json["dataset_id"] == str(dataset_id)
|
||||||
|
assert response.metadata_json["selection_bbox"] == selection_bbox
|
||||||
|
assert response.metadata_json["feature_count"] == 1
|
||||||
|
assert response.metadata_json["source_table"] == "vector_features"
|
||||||
|
assert json.loads(export_path.read_text(encoding="utf-8"))["features"][0]["properties"]["vector_feature_id"] == "vf-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(monkeypatch) -> None:
|
||||||
|
export_id = uuid4()
|
||||||
|
dataset_id = uuid4()
|
||||||
|
expected_bbox = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ExportService,
|
||||||
|
"export_vector_selection_geojson",
|
||||||
|
lambda *_args, **_kwargs: ExportCreateResponse(
|
||||||
|
export_id=export_id,
|
||||||
|
path="storage/exports/demo-selection.geojson",
|
||||||
|
status="ready",
|
||||||
|
export_type="vector_selection_geojson",
|
||||||
|
metadata_json={"source": "vector_selection", "selection_bbox": expected_bbox},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/api/v1/exports/geojson",
|
||||||
|
json={"dataset_id": str(dataset_id), "export_kind": "vector_selection", "bbox": expected_bbox, "limit": 250},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert set(payload) == {"data"}
|
||||||
|
assert payload["data"]["export_id"] == str(export_id)
|
||||||
|
assert payload["data"]["export_type"] == "vector_selection_geojson"
|
||||||
|
assert payload["data"]["metadata_json"]["selection_bbox"] == expected_bbox
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_exposes_map_selection_export_action() -> None:
|
||||||
|
types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
|
||||||
|
exports_api = (ROOT / "frontend" / "src" / "services" / "api" / "exports.ts").read_text(encoding="utf-8")
|
||||||
|
export_hook = (ROOT / "frontend" / "src" / "hooks" / "useExportWorkflow.ts").read_text(encoding="utf-8")
|
||||||
|
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "'vector_selection'" in types
|
||||||
|
assert "bbox?: VectorSelectionBBox" in exports_api
|
||||||
|
assert "exportMapSelectionGeoJson" in export_hook
|
||||||
|
assert "vector_selection" in export_hook
|
||||||
|
assert "Save area export" in map_workspace
|
||||||
|
assert "onExportMapSelection" in map_workspace
|
||||||
|
assert "selectionExportError" in map_workspace
|
||||||
|
assert "onExportMapSelection={exportMapSelectionGeoJson}" in app
|
||||||
+23
-2
@@ -1013,6 +1013,24 @@ Dataset vector export request:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Map vector selection export request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"export_kind": "vector_selection",
|
||||||
|
"dataset_id": "uuid",
|
||||||
|
"bbox": {
|
||||||
|
"min_x": 5.0,
|
||||||
|
"min_y": 51.0,
|
||||||
|
"max_x": 5.1,
|
||||||
|
"max_y": 51.1,
|
||||||
|
"crs": "EPSG:4326"
|
||||||
|
},
|
||||||
|
"limit": 250,
|
||||||
|
"name": "optional-basename"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Detection run export request:
|
Detection run export request:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1050,8 +1068,11 @@ Response persists an `exports` row and writes a deterministic JSON artifact:
|
|||||||
|
|
||||||
Vector dataset exports use the stored dataset GeoJSON. Detection and
|
Vector dataset exports use the stored dataset GeoJSON. Detection and
|
||||||
segmentation exports use persisted first-class geometry records and the
|
segmentation exports use persisted first-class geometry records and the
|
||||||
existing Detection/Segmentation GeoJSON conversion services. Raster datasets
|
existing Detection/Segmentation GeoJSON conversion services. Vector selection
|
||||||
are rejected for dataset GeoJSON export.
|
exports query persisted PostGIS `vector_features` with the supplied EPSG:4326
|
||||||
|
bbox, write the selected FeatureCollection as a `vector_selection_geojson`
|
||||||
|
artifact, and persist bbox/feature-count metadata in the export record. Raster
|
||||||
|
datasets are rejected for dataset and selection GeoJSON export.
|
||||||
|
|
||||||
### POST `/api/v1/exports/metadata`
|
### POST `/api/v1/exports/metadata`
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,34 @@
|
|||||||
|
## Sprint 107 Map selection export handoff (2026-06-25)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Added `vector_selection` to the GeoJSON export contract.
|
||||||
|
- Added `ExportService.export_vector_selection_geojson`, which queries persisted PostGIS `vector_features` through `VectorFeatureService.select_features_by_bbox`, writes the selected FeatureCollection and persists an `exports` row with `export_type="vector_selection_geojson"`.
|
||||||
|
- Extended `POST /api/v1/exports/geojson` to accept `export_kind="vector_selection"` with EPSG:4326 bbox and feature limit.
|
||||||
|
- Added frontend export API typing for bbox/limit and `useExportWorkflow.exportMapSelectionGeoJson`.
|
||||||
|
- Added `Save area export` to the Map workspace selection result state with loading/error/latest-path feedback.
|
||||||
|
- Updated `docs/API_CONTRACTS.md`, `backend/README.md`, `frontend/README.md`, `CHANGELOG.md` and `docs/TODO.md`.
|
||||||
|
- Added regression coverage in `backend/tests/test_sprint107_map_selection_export.py`.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- RED: `python -m pytest backend\tests\test_sprint107_map_selection_export.py -q` failed before implementation because the selection export service, route contract and frontend wiring were absent.
|
||||||
|
- `python -m pytest backend\tests\test_sprint107_map_selection_export.py -q` passed: 3 tests.
|
||||||
|
- `python -m compileall backend/app` passed.
|
||||||
|
- `cd backend && python -m pytest -q` passed: 341 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 exports are bbox-only and reuse the same EPSG:4326 constraints as the Map area selection endpoint.
|
||||||
|
- Saving a selection creates an export artifact, not a derived dataset.
|
||||||
|
- No migrations, live provider fetching, AI dependency, real model behavior or new product domain were added.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Add a live browser smoke that exercises `Save area export`, confirms the export appears in Export Center history and previews/downloads the persisted `vector_selection_geojson` artifact.
|
||||||
|
|
||||||
## Sprint 106 Map area selection extract (2026-06-25)
|
## Sprint 106 Map area selection extract (2026-06-25)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
@@ -373,3 +373,4 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Add AI Lab run-readiness checks for Detection and Segmentation before job submission.
|
- [x] Add AI Lab run-readiness checks for Detection and Segmentation before job submission.
|
||||||
- [x] Add AI Lab action guardrails so explicit fixture models are not exposed as normal operator runs.
|
- [x] Add 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.
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst
|
|||||||
- The Map workspace shows active layer source/provenance/draw-state context and selected-feature property chips before the raw JSON inspector.
|
- The Map workspace shows active layer source/provenance/draw-state context and selected-feature property chips before the raw JSON inspector.
|
||||||
- When the Map workspace has no active result layer, it lists ready vector/GeoJSON datasets as direct quick actions so populated demo projects can jump straight from the empty state to map inspection.
|
- 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.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -325,11 +325,15 @@ function App(): JSX.Element {
|
|||||||
exportError,
|
exportError,
|
||||||
loadingExports,
|
loadingExports,
|
||||||
exporting,
|
exporting,
|
||||||
|
selectionExporting,
|
||||||
|
selectionExportError,
|
||||||
|
latestSelectionExport,
|
||||||
exportPreview,
|
exportPreview,
|
||||||
loadExports,
|
loadExports,
|
||||||
exportSelectedDatasetGeoJson,
|
exportSelectedDatasetGeoJson,
|
||||||
exportSelectedDetectionRunGeoJson,
|
exportSelectedDetectionRunGeoJson,
|
||||||
exportSelectedSegmentationRunGeoJson,
|
exportSelectedSegmentationRunGeoJson,
|
||||||
|
exportMapSelectionGeoJson,
|
||||||
exportProjectMetadata,
|
exportProjectMetadata,
|
||||||
exportProjectReport,
|
exportProjectReport,
|
||||||
previewExportContent,
|
previewExportContent,
|
||||||
@@ -802,6 +806,9 @@ function App(): JSX.Element {
|
|||||||
mapSelectionResult={mapSelectionResult}
|
mapSelectionResult={mapSelectionResult}
|
||||||
mapSelectionLoading={mapSelectionLoading}
|
mapSelectionLoading={mapSelectionLoading}
|
||||||
mapSelectionError={mapSelectionError}
|
mapSelectionError={mapSelectionError}
|
||||||
|
selectionExporting={selectionExporting}
|
||||||
|
selectionExportError={selectionExportError}
|
||||||
|
latestSelectionExportPath={latestSelectionExport?.path ?? null}
|
||||||
availableMapDatasets={availableMapDatasets}
|
availableMapDatasets={availableMapDatasets}
|
||||||
selectedFeature={selectedMapFeature}
|
selectedFeature={selectedMapFeature}
|
||||||
onSelectMapArea={setSelectedMapAreaId}
|
onSelectMapArea={setSelectedMapAreaId}
|
||||||
@@ -814,6 +821,7 @@ function App(): JSX.Element {
|
|||||||
onSetMapSelectionBbox={setMapSelectionBbox}
|
onSetMapSelectionBbox={setMapSelectionBbox}
|
||||||
onRunMapSelectionExtract={runMapSelectionExtract}
|
onRunMapSelectionExtract={runMapSelectionExtract}
|
||||||
onClearMapSelectionExtract={resetMapSelectionExtract}
|
onClearMapSelectionExtract={resetMapSelectionExtract}
|
||||||
|
onExportMapSelection={exportMapSelectionGeoJson}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ interface MapWorkspaceProps {
|
|||||||
mapSelectionResult: VectorSelectionResponse | null
|
mapSelectionResult: VectorSelectionResponse | null
|
||||||
mapSelectionLoading: boolean
|
mapSelectionLoading: boolean
|
||||||
mapSelectionError: string | null
|
mapSelectionError: string | null
|
||||||
|
selectionExporting: boolean
|
||||||
|
selectionExportError: string | null
|
||||||
|
latestSelectionExportPath: string | null
|
||||||
availableMapDatasets: DatasetCreateResponse[]
|
availableMapDatasets: DatasetCreateResponse[]
|
||||||
onSelectMapArea: (areaId: string) => void
|
onSelectMapArea: (areaId: string) => void
|
||||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||||
@@ -202,6 +205,7 @@ interface MapWorkspaceProps {
|
|||||||
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
|
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
|
||||||
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void
|
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void
|
||||||
onClearMapSelectionExtract: () => void
|
onClearMapSelectionExtract: () => void
|
||||||
|
onExportMapSelection: (bbox: VectorSelectionBBox) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapWorkspace({
|
export function MapWorkspace({
|
||||||
@@ -224,6 +228,9 @@ export function MapWorkspace({
|
|||||||
mapSelectionResult,
|
mapSelectionResult,
|
||||||
mapSelectionLoading,
|
mapSelectionLoading,
|
||||||
mapSelectionError,
|
mapSelectionError,
|
||||||
|
selectionExporting,
|
||||||
|
selectionExportError,
|
||||||
|
latestSelectionExportPath,
|
||||||
availableMapDatasets,
|
availableMapDatasets,
|
||||||
onSelectMapArea,
|
onSelectMapArea,
|
||||||
onOpenDatasetInMap,
|
onOpenDatasetInMap,
|
||||||
@@ -235,6 +242,7 @@ export function MapWorkspace({
|
|||||||
onSetMapSelectionBbox,
|
onSetMapSelectionBbox,
|
||||||
onRunMapSelectionExtract,
|
onRunMapSelectionExtract,
|
||||||
onClearMapSelectionExtract,
|
onClearMapSelectionExtract,
|
||||||
|
onExportMapSelection,
|
||||||
}: 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)
|
||||||
@@ -322,6 +330,14 @@ export function MapWorkspace({
|
|||||||
copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
|
copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveAreaSelectionExport = () => {
|
||||||
|
const bbox = parseBboxInput(bboxInput)
|
||||||
|
if (!bbox) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onExportMapSelection(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">
|
||||||
@@ -587,7 +603,19 @@ export function MapWorkspace({
|
|||||||
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
|
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
|
||||||
Copy area GeoJSON
|
Copy area GeoJSON
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-action"
|
||||||
|
disabled={!currentSelectionBbox || selectionExporting}
|
||||||
|
type="button"
|
||||||
|
onClick={saveAreaSelectionExport}
|
||||||
|
>
|
||||||
|
{selectionExporting ? 'Saving export...' : 'Save area export'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
||||||
|
{latestSelectionExportPath ? (
|
||||||
|
<p className="muted">Saved selection artifact: {latestSelectionExportPath}</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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { exportsApi } from '../services/api'
|
import { exportsApi } from '../services/api'
|
||||||
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../types'
|
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead, VectorSelectionBBox } from '../types'
|
||||||
import { formatError } from '../lib/formatError'
|
import { formatError } from '../lib/formatError'
|
||||||
|
|
||||||
interface ExportWorkflowOptions {
|
interface ExportWorkflowOptions {
|
||||||
@@ -23,6 +23,9 @@ export function useExportWorkflow({
|
|||||||
const [exportError, setExportError] = useState<string | null>(null)
|
const [exportError, setExportError] = useState<string | null>(null)
|
||||||
const [loadingExports, setLoadingExports] = useState(false)
|
const [loadingExports, setLoadingExports] = useState(false)
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const [selectionExporting, setSelectionExporting] = useState(false)
|
||||||
|
const [selectionExportError, setSelectionExportError] = useState<string | null>(null)
|
||||||
|
const [latestSelectionExport, setLatestSelectionExport] = useState<ExportCreateResponse | null>(null)
|
||||||
const [exportPreview, setExportPreview] = useState<Record<string, unknown> | null>(null)
|
const [exportPreview, setExportPreview] = useState<Record<string, unknown> | null>(null)
|
||||||
|
|
||||||
const loadExports = async (projectId = selectedProjectId) => {
|
const loadExports = async (projectId = selectedProjectId) => {
|
||||||
@@ -106,6 +109,31 @@ export function useExportWorkflow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportMapSelectionGeoJson = async (bbox: VectorSelectionBBox) => {
|
||||||
|
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
|
||||||
|
setSelectionExportError('Select a vector dataset before saving an area export.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectionExporting(true)
|
||||||
|
setSelectionExportError(null)
|
||||||
|
try {
|
||||||
|
const response = await exportsApi.exportGeojson({
|
||||||
|
dataset_id: selectedDataset.id,
|
||||||
|
export_kind: 'vector_selection',
|
||||||
|
bbox: { ...bbox, crs: 'EPSG:4326' },
|
||||||
|
limit: 250,
|
||||||
|
name: `${selectedDataset.name.replace(/\.(geo)?json$/i, '')}-selection`,
|
||||||
|
})
|
||||||
|
setLatestExport(response)
|
||||||
|
setLatestSelectionExport(response)
|
||||||
|
await loadExports(selectedDataset.project_id)
|
||||||
|
} catch (error) {
|
||||||
|
setSelectionExportError(formatError(error, 'Failed to save area export'))
|
||||||
|
} finally {
|
||||||
|
setSelectionExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exportProjectMetadata = async () => {
|
const exportProjectMetadata = async () => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
setExportError('Select a project before exporting metadata.')
|
setExportError('Select a project before exporting metadata.')
|
||||||
@@ -159,6 +187,8 @@ export function useExportWorkflow({
|
|||||||
const resetExportsForProject = () => {
|
const resetExportsForProject = () => {
|
||||||
setExports([])
|
setExports([])
|
||||||
setLatestExport(null)
|
setLatestExport(null)
|
||||||
|
setLatestSelectionExport(null)
|
||||||
|
setSelectionExportError(null)
|
||||||
setExportPreview(null)
|
setExportPreview(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +198,15 @@ export function useExportWorkflow({
|
|||||||
exportError,
|
exportError,
|
||||||
loadingExports,
|
loadingExports,
|
||||||
exporting,
|
exporting,
|
||||||
|
selectionExporting,
|
||||||
|
selectionExportError,
|
||||||
|
latestSelectionExport,
|
||||||
exportPreview,
|
exportPreview,
|
||||||
loadExports,
|
loadExports,
|
||||||
exportSelectedDatasetGeoJson,
|
exportSelectedDatasetGeoJson,
|
||||||
exportSelectedDetectionRunGeoJson,
|
exportSelectedDetectionRunGeoJson,
|
||||||
exportSelectedSegmentationRunGeoJson,
|
exportSelectedSegmentationRunGeoJson,
|
||||||
|
exportMapSelectionGeoJson,
|
||||||
exportProjectMetadata,
|
exportProjectMetadata,
|
||||||
exportProjectReport,
|
exportProjectReport,
|
||||||
previewExportContent,
|
previewExportContent,
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { apiGet, apiPost, apiUrl } from './client'
|
import { apiGet, apiPost, apiUrl } from './client'
|
||||||
import type { ExportContentResponse, ExportCreateResponse, ExportKind, ExportListResponse, ExportRead } from '../../types'
|
import type {
|
||||||
|
ExportContentResponse,
|
||||||
|
ExportCreateResponse,
|
||||||
|
ExportKind,
|
||||||
|
ExportListResponse,
|
||||||
|
ExportRead,
|
||||||
|
VectorSelectionBBox,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
export const exportsApi = {
|
export const exportsApi = {
|
||||||
exportGeojson: (
|
exportGeojson: (
|
||||||
payload: { dataset_id?: string; analysis_run_id?: string; export_kind?: ExportKind; name?: string } | string,
|
payload:
|
||||||
|
| {
|
||||||
|
dataset_id?: string
|
||||||
|
analysis_run_id?: string
|
||||||
|
export_kind?: ExportKind
|
||||||
|
name?: string
|
||||||
|
bbox?: VectorSelectionBBox
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
| string,
|
||||||
): Promise<ExportCreateResponse> => {
|
): Promise<ExportCreateResponse> => {
|
||||||
const body = typeof payload === 'string' ? { dataset_id: payload, export_kind: 'dataset' } : payload
|
const body = typeof payload === 'string' ? { dataset_id: payload, export_kind: 'dataset' } : payload
|
||||||
return apiPost<ExportCreateResponse>(`/api/v1/exports/geojson`, body)
|
return apiPost<ExportCreateResponse>(`/api/v1/exports/geojson`, body)
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ export interface QualityCheckListResponse {
|
|||||||
offset: number
|
offset: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run'
|
export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run' | 'vector_selection'
|
||||||
|
|
||||||
export interface ExportRead {
|
export interface ExportRead {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user