Persist map area selection exports
This commit is contained in:
@@ -7,6 +7,14 @@
|
||||
|
||||
# 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)
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
- `bash scripts/backend_install.sh`
|
||||
|
||||
@@ -16,6 +16,16 @@ router = APIRouter(prefix="/exports", tags=["exports"])
|
||||
|
||||
@router.post("/geojson", response_model=dict)
|
||||
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:
|
||||
return envelope(
|
||||
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 app.schemas.operations import VectorSelectionBBox
|
||||
|
||||
ExportKind = Literal["dataset", "detection_run", "segmentation_run"]
|
||||
|
||||
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
|
||||
|
||||
|
||||
class GeoJsonExportRequest(BaseModel):
|
||||
@@ -15,11 +17,18 @@ class GeoJsonExportRequest(BaseModel):
|
||||
analysis_run_id: UUID | None = None
|
||||
export_kind: ExportKind = "dataset"
|
||||
name: str | None = None
|
||||
bbox: VectorSelectionBBox | None = None
|
||||
limit: int = 250
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target(self) -> "GeoJsonExportRequest":
|
||||
if self.export_kind == "dataset" and self.dataset_id is None:
|
||||
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:
|
||||
raise ValueError("analysis_run_id is required for run GeoJSON exports")
|
||||
return self
|
||||
|
||||
@@ -16,9 +16,54 @@ from app.services.dataset_service import DatasetService
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.services.storage_service import StorageService
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
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
|
||||
def export_dataset_geojson(db: Session, dataset_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
||||
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:
|
||||
|
||||
```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
|
||||
segmentation exports use persisted first-class geometry records and the
|
||||
existing Detection/Segmentation GeoJSON conversion services. Raster datasets
|
||||
are rejected for dataset GeoJSON export.
|
||||
existing Detection/Segmentation GeoJSON conversion services. Vector selection
|
||||
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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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 action guardrails so explicit fixture models are not exposed as normal operator runs.
|
||||
- [x] Add persisted vector area selection from the Map workspace with bbox extract and GeoJSON download.
|
||||
- [x] Persist map area selections as Export Center handoff artifacts.
|
||||
|
||||
@@ -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.
|
||||
- When the Map workspace has no active result layer, it lists ready vector/GeoJSON datasets as direct quick actions so populated demo projects can jump straight from the empty state to map inspection.
|
||||
- The Map workspace can extract persisted vector features by area: open a ready vector dataset, use `Start map bbox` and click two map corners or enter EPSG:4326 bbox values, then run `Run area extract` to query backend `vector_features`. Results are highlighted on the map and can be downloaded as GeoJSON.
|
||||
- After an area extract, `Save area export` persists the selected FeatureCollection as a normal Export Center artifact (`vector_selection_geojson`) so the handoff remains in project export history.
|
||||
- The Data catalog shows a compact selected/reference/candidate/source summary and scan-friendly badges. Persisted `reference` datasets are shown as Reference, non-reference vector/GeoJSON layers are shown as QA Candidates for workbench scanning, and raster/other uploads remain Source.
|
||||
- Dataset cards explain the recommended next action and use compact two-line action buttons for inspect, map, export/QA and metadata refresh. Disabled actions keep a visible reason, such as `Vector/GeoJSON only`.
|
||||
- Raster controls show the latest generated tile manifest path from persisted `raster.tile` jobs and can hand that path directly to Detection Lab or Segmentation Lab with the selected raster dataset.
|
||||
|
||||
@@ -325,11 +325,15 @@ function App(): JSX.Element {
|
||||
exportError,
|
||||
loadingExports,
|
||||
exporting,
|
||||
selectionExporting,
|
||||
selectionExportError,
|
||||
latestSelectionExport,
|
||||
exportPreview,
|
||||
loadExports,
|
||||
exportSelectedDatasetGeoJson,
|
||||
exportSelectedDetectionRunGeoJson,
|
||||
exportSelectedSegmentationRunGeoJson,
|
||||
exportMapSelectionGeoJson,
|
||||
exportProjectMetadata,
|
||||
exportProjectReport,
|
||||
previewExportContent,
|
||||
@@ -802,6 +806,9 @@ function App(): JSX.Element {
|
||||
mapSelectionResult={mapSelectionResult}
|
||||
mapSelectionLoading={mapSelectionLoading}
|
||||
mapSelectionError={mapSelectionError}
|
||||
selectionExporting={selectionExporting}
|
||||
selectionExportError={selectionExportError}
|
||||
latestSelectionExportPath={latestSelectionExport?.path ?? null}
|
||||
availableMapDatasets={availableMapDatasets}
|
||||
selectedFeature={selectedMapFeature}
|
||||
onSelectMapArea={setSelectedMapAreaId}
|
||||
@@ -814,6 +821,7 @@ function App(): JSX.Element {
|
||||
onSetMapSelectionBbox={setMapSelectionBbox}
|
||||
onRunMapSelectionExtract={runMapSelectionExtract}
|
||||
onClearMapSelectionExtract={resetMapSelectionExtract}
|
||||
onExportMapSelection={exportMapSelectionGeoJson}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -191,6 +191,9 @@ interface MapWorkspaceProps {
|
||||
mapSelectionResult: VectorSelectionResponse | null
|
||||
mapSelectionLoading: boolean
|
||||
mapSelectionError: string | null
|
||||
selectionExporting: boolean
|
||||
selectionExportError: string | null
|
||||
latestSelectionExportPath: string | null
|
||||
availableMapDatasets: DatasetCreateResponse[]
|
||||
onSelectMapArea: (areaId: string) => void
|
||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||
@@ -202,6 +205,7 @@ interface MapWorkspaceProps {
|
||||
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
|
||||
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void
|
||||
onClearMapSelectionExtract: () => void
|
||||
onExportMapSelection: (bbox: VectorSelectionBBox) => void
|
||||
}
|
||||
|
||||
export function MapWorkspace({
|
||||
@@ -224,6 +228,9 @@ export function MapWorkspace({
|
||||
mapSelectionResult,
|
||||
mapSelectionLoading,
|
||||
mapSelectionError,
|
||||
selectionExporting,
|
||||
selectionExportError,
|
||||
latestSelectionExportPath,
|
||||
availableMapDatasets,
|
||||
onSelectMapArea,
|
||||
onOpenDatasetInMap,
|
||||
@@ -235,6 +242,7 @@ export function MapWorkspace({
|
||||
onSetMapSelectionBbox,
|
||||
onRunMapSelectionExtract,
|
||||
onClearMapSelectionExtract,
|
||||
onExportMapSelection,
|
||||
}: MapWorkspaceProps): JSX.Element {
|
||||
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
||||
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))
|
||||
}
|
||||
|
||||
const saveAreaSelectionExport = () => {
|
||||
const bbox = parseBboxInput(bboxInput)
|
||||
if (!bbox) {
|
||||
return
|
||||
}
|
||||
onExportMapSelection(bbox)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
||||
<div className="panel-title-row">
|
||||
@@ -587,7 +603,19 @@ export function MapWorkspace({
|
||||
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
|
||||
Copy area GeoJSON
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!currentSelectionBbox || selectionExporting}
|
||||
type="button"
|
||||
onClick={saveAreaSelectionExport}
|
||||
>
|
||||
{selectionExporting ? 'Saving export...' : 'Save area export'}
|
||||
</button>
|
||||
</div>
|
||||
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
||||
{latestSelectionExportPath ? (
|
||||
<p className="muted">Saved selection artifact: {latestSelectionExportPath}</p>
|
||||
) : null}
|
||||
{areaSelectionPreviewFeatures.length > 0 ? (
|
||||
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
|
||||
<table>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
||||
interface ExportWorkflowOptions {
|
||||
@@ -23,6 +23,9 @@ export function useExportWorkflow({
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [loadingExports, setLoadingExports] = 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 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 () => {
|
||||
if (!selectedProjectId) {
|
||||
setExportError('Select a project before exporting metadata.')
|
||||
@@ -159,6 +187,8 @@ export function useExportWorkflow({
|
||||
const resetExportsForProject = () => {
|
||||
setExports([])
|
||||
setLatestExport(null)
|
||||
setLatestSelectionExport(null)
|
||||
setSelectionExportError(null)
|
||||
setExportPreview(null)
|
||||
}
|
||||
|
||||
@@ -168,11 +198,15 @@ export function useExportWorkflow({
|
||||
exportError,
|
||||
loadingExports,
|
||||
exporting,
|
||||
selectionExporting,
|
||||
selectionExportError,
|
||||
latestSelectionExport,
|
||||
exportPreview,
|
||||
loadExports,
|
||||
exportSelectedDatasetGeoJson,
|
||||
exportSelectedDetectionRunGeoJson,
|
||||
exportSelectedSegmentationRunGeoJson,
|
||||
exportMapSelectionGeoJson,
|
||||
exportProjectMetadata,
|
||||
exportProjectReport,
|
||||
previewExportContent,
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
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 = {
|
||||
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> => {
|
||||
const body = typeof payload === 'string' ? { dataset_id: payload, export_kind: 'dataset' } : payload
|
||||
return apiPost<ExportCreateResponse>(`/api/v1/exports/geojson`, body)
|
||||
|
||||
@@ -635,7 +635,7 @@ export interface QualityCheckListResponse {
|
||||
offset: number
|
||||
}
|
||||
|
||||
export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run'
|
||||
export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run' | 'vector_selection'
|
||||
|
||||
export interface ExportRead {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user