Persist map area selection exports
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-25 02:12:09 +02:00
parent f773225b98
commit 46fe3edfc5
15 changed files with 359 additions and 7 deletions
+8
View File
@@ -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`.
+5
View File
@@ -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`
+10
View File
@@ -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")
+10 -1
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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`
+31
View File
@@ -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:
+1
View File
@@ -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.
+1
View File
@@ -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.
+8
View File
@@ -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>
+35 -1
View File
@@ -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,
+18 -2
View File
@@ -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)
+1 -1
View File
@@ -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