Extract dataset workflow orchestration
This commit is contained in:
@@ -7,6 +7,13 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 28 dataset workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
|
- Moved dataset selection, upload, detail loading, dataset jobs and raster/vector operation orchestration from `App.tsx` into `useDatasetWorkflow`.
|
||||||
|
- Kept project dataset listing in `App.tsx` so project/area loading remains the shared workbench boundary.
|
||||||
|
- Added regression coverage to verify `App.tsx` still wires dataset, raster and vector UI callbacks while operation API ownership stays inside the focused hook.
|
||||||
|
- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign were introduced.
|
||||||
|
|
||||||
## Sprint 27 export and QA workflow hook hardening (2026-06-17)
|
## Sprint 27 export and QA workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
- Moved Export Center orchestration state and API calls from `App.tsx` into `useExportWorkflow`.
|
- Moved Export Center orchestration state and API calls from `App.tsx` into `useExportWorkflow`.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_uses_dataset_workflow_hook() -> None:
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "useDatasetWorkflow" in app
|
||||||
|
assert "from './hooks/useDatasetWorkflow'" in app
|
||||||
|
assert "datasetsApi.upload" not in app
|
||||||
|
assert "jobsApi" not in app
|
||||||
|
assert "datasetsApi.vectorClip" not in app
|
||||||
|
assert "datasetsApi.rasterTile" not in app
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataset_workflow_hook_owns_dataset_api_calls() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useDatasetWorkflow.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "datasetsApi.upload" in hook
|
||||||
|
assert "datasetsApi.getContent" in hook
|
||||||
|
assert "datasetsApi.vectorSummary" in hook
|
||||||
|
assert "datasetsApi.vectorClip" in hook
|
||||||
|
assert "datasetsApi.vectorBuffer" in hook
|
||||||
|
assert "datasetsApi.vectorIntersect" in hook
|
||||||
|
assert "datasetsApi.inspectRaster" in hook
|
||||||
|
assert "datasetsApi.rasterPreview" in hook
|
||||||
|
assert "datasetsApi.rasterStats" in hook
|
||||||
|
assert "datasetsApi.rasterReproject" in hook
|
||||||
|
assert "datasetsApi.rasterClip" in hook
|
||||||
|
assert "datasetsApi.rasterTile" in hook
|
||||||
|
assert "datasetsApi.rasterNdvi" in hook
|
||||||
|
assert "datasetsApi.rasterNdwi" in hook
|
||||||
|
assert "datasetsApi.rasterNdbi" in hook
|
||||||
|
assert "datasetsApi.refreshMetadata" in hook
|
||||||
|
assert "jobsApi.list" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_still_wires_dataset_ui_callbacks() -> None:
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "<form onSubmit={uploadDataset}" in app
|
||||||
|
assert "onClick={() => loadDatasetDetails(selectedProjectId ?? '', dataset)}" in app
|
||||||
|
assert "onClick={() => refreshMetadata(dataset.id)}" in app
|
||||||
|
assert "onClick={runRasterInspect}" in app
|
||||||
|
assert "onClick={runRasterPreview}" in app
|
||||||
|
assert "onClick={runRasterStats}" in app
|
||||||
|
assert "onClick={runRasterReproject}" in app
|
||||||
|
assert "onClick={runRasterClip}" in app
|
||||||
|
assert "onClick={runRasterTile}" in app
|
||||||
|
assert "onClick={runRasterNdvi}" in app
|
||||||
|
assert "onClick={runRasterNdwi}" in app
|
||||||
|
assert "onClick={runRasterNdbi}" in app
|
||||||
|
assert "onClick={runVectorClip}" in app
|
||||||
|
assert "onClick={runVectorBuffer}" in app
|
||||||
|
assert "onClick={() => runVectorIntersect(availableVectorTargets)}" in app
|
||||||
@@ -1,3 +1,26 @@
|
|||||||
|
## Sprint 28 dataset workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Moved dataset selection, upload form state, dataset detail loading, dataset jobs and raster/vector operation orchestration from `frontend/src/App.tsx` into `frontend/src/hooks/useDatasetWorkflow.ts`.
|
||||||
|
- Kept project dataset listing in `App.tsx` so project/area loading remains the shared workbench boundary.
|
||||||
|
- Added regression tests to verify App uses the dataset workflow hook and still wires dataset, raster and vector callbacks.
|
||||||
|
- Updated frontend README, TODO and changelog docs.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `python -m pytest backend/tests/test_sprint28_dataset_workflow_hook.py backend/tests/test_sprint27_frontend_workflow_hooks.py backend/tests/test_sprint26_frontend_workflow_hooks.py` passed: 11 tests.
|
||||||
|
- `python -m compileall backend/app` passed.
|
||||||
|
- `cd backend && python -m pytest` passed: 167 tests.
|
||||||
|
- `cd frontend && npm run typecheck` passed.
|
||||||
|
- `cd frontend && npm run build` passed.
|
||||||
|
- `bash scripts/run_readiness_check.sh` passed: 167 backend tests, frontend typecheck/build, Alembic head check and script syntax checks.
|
||||||
|
- `cd backend && python -m alembic heads` passed: single head `202606120900`.
|
||||||
|
- `cd backend && python -m alembic upgrade head --sql` passed.
|
||||||
|
- `bash -n scripts/live_migration_smoke.sh` passed.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign changed.
|
||||||
|
- Next maintainability pass should split dataset detail, raster controls and vector controls into presentational components fed by the hook state.
|
||||||
|
|
||||||
## Sprint 27 export and QA workflow hook hardening (2026-06-17)
|
## Sprint 27 export and QA workflow hook hardening (2026-06-17)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
+2
-1
@@ -38,7 +38,8 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Real YOLO compatibility smoke with optional AI extras and local model file.
|
- [x] Real YOLO compatibility smoke with optional AI extras and local model file.
|
||||||
- [x] Detection and segmentation workflow hook extraction beyond Sprint 10.
|
- [x] Detection and segmentation workflow hook extraction beyond Sprint 10.
|
||||||
- [x] Export and QA/QC workflow hook extraction beyond Sprint 10.
|
- [x] Export and QA/QC workflow hook extraction beyond Sprint 10.
|
||||||
- [ ] Further frontend state/module decomposition for datasets and raster/vector operations.
|
- [x] Dataset, raster and vector workflow hook extraction beyond Sprint 10.
|
||||||
|
- [ ] Further frontend component decomposition for dataset detail, raster controls and vector controls.
|
||||||
|
|
||||||
## Sprint 8 status
|
## Sprint 8 status
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
|||||||
- Detection and segmentation QA continue to share the same persisted QA/QC refresh path through `loadQualityChecks`.
|
- Detection and segmentation QA continue to share the same persisted QA/QC refresh path through `loadQualityChecks`.
|
||||||
- Export Center and QA/QC UI behavior is unchanged; `App.tsx` still coordinates selected project, dataset and analysis-run state.
|
- Export Center and QA/QC UI behavior is unchanged; `App.tsx` still coordinates selected project, dataset and analysis-run state.
|
||||||
|
|
||||||
|
## Sprint 28 maintainability updates
|
||||||
|
|
||||||
|
- Dataset selection, upload form state, detail loading, dataset jobs and raster/vector operation orchestration moved from `src/App.tsx` into `src/hooks/useDatasetWorkflow.ts`.
|
||||||
|
- Project-scoped dataset listing remains in `App.tsx` because it is still part of the shared project/area load boundary.
|
||||||
|
- Dataset details, raster controls, vector controls and job list behavior are unchanged; the UI still receives the same callbacks and state.
|
||||||
|
|
||||||
## Release hardening updates
|
## Release hardening updates
|
||||||
|
|
||||||
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
||||||
|
|||||||
+70
-426
@@ -4,7 +4,7 @@ import GeoMap from './components/GeoMap'
|
|||||||
import { areasApi } from './services/api/areas'
|
import { areasApi } from './services/api/areas'
|
||||||
import { datasetsApi } from './services/api/datasets'
|
import { datasetsApi } from './services/api/datasets'
|
||||||
import { projectsApi } from './services/api/projects'
|
import { projectsApi } from './services/api/projects'
|
||||||
import { analysisApi, demoApi, jobsApi, externalApi } from './services/api'
|
import { analysisApi, demoApi, externalApi } from './services/api'
|
||||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||||
import { DetectionLab } from './components/detection/DetectionLab'
|
import { DetectionLab } from './components/detection/DetectionLab'
|
||||||
import { ExportCenter } from './components/exports/ExportCenter'
|
import { ExportCenter } from './components/exports/ExportCenter'
|
||||||
@@ -16,12 +16,6 @@ import type {
|
|||||||
ChangeDetectionSummary,
|
ChangeDetectionSummary,
|
||||||
DatasetCreateResponse,
|
DatasetCreateResponse,
|
||||||
DatasetListResponse,
|
DatasetListResponse,
|
||||||
JobRead,
|
|
||||||
RasterMetadataResponse,
|
|
||||||
RasterInspectResponse,
|
|
||||||
RasterStatsResponse,
|
|
||||||
RasterPreviewResponse,
|
|
||||||
VectorSummary,
|
|
||||||
ProjectRead,
|
ProjectRead,
|
||||||
ProjectCreate,
|
ProjectCreate,
|
||||||
AreaCreate,
|
AreaCreate,
|
||||||
@@ -32,6 +26,7 @@ import type {
|
|||||||
import { ProviderPanel } from './components/providers/ProviderPanel'
|
import { ProviderPanel } from './components/providers/ProviderPanel'
|
||||||
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
||||||
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
||||||
|
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
||||||
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
||||||
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||||
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
||||||
@@ -71,19 +66,12 @@ function App(): JSX.Element {
|
|||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||||
const [areas, setAreas] = useState<AreaRead[]>([])
|
const [areas, setAreas] = useState<AreaRead[]>([])
|
||||||
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
|
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
|
||||||
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null)
|
|
||||||
const [selectedDataset, setSelectedDataset] = useState<DatasetCreateResponse | null>(null)
|
|
||||||
const [selectedDatasetSummary, setSelectedDatasetSummary] = useState<VectorSummary | null>(null)
|
|
||||||
const [selectedRasterMetadata, setSelectedRasterMetadata] = useState<RasterMetadataResponse | null>(null)
|
|
||||||
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
|
|
||||||
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
|
|
||||||
const [mapLayerVisible, setMapLayerVisible] = useState(true)
|
const [mapLayerVisible, setMapLayerVisible] = useState(true)
|
||||||
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
|
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
|
||||||
const [selectedMapAreaId, setSelectedMapAreaId] = useState('')
|
const [selectedMapAreaId, setSelectedMapAreaId] = useState('')
|
||||||
const [areaLayerVisible, setAreaLayerVisible] = useState(true)
|
const [areaLayerVisible, setAreaLayerVisible] = useState(true)
|
||||||
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
|
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
|
||||||
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
||||||
const [jobs, setJobs] = useState<JobRead[]>([])
|
|
||||||
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
||||||
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
|
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
|
||||||
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null)
|
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null)
|
||||||
@@ -94,29 +82,12 @@ function App(): JSX.Element {
|
|||||||
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
|
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
|
||||||
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | null>(null)
|
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | null>(null)
|
||||||
const [changeDetectionError, setChangeDetectionError] = useState<string | null>(null)
|
const [changeDetectionError, setChangeDetectionError] = useState<string | null>(null)
|
||||||
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
|
|
||||||
const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('')
|
|
||||||
const [selectedClipAreaId, setSelectedClipAreaId] = useState('')
|
|
||||||
const [rasterTileSize, setRasterTileSize] = useState(512)
|
|
||||||
const [rasterTileOverlap, setRasterTileOverlap] = useState(64)
|
|
||||||
const [rasterTileOutputName, setRasterTileOutputName] = useState('')
|
|
||||||
const [rasterReprojectCrs, setRasterReprojectCrs] = useState('EPSG:31370')
|
|
||||||
const [rasterReprojectResampling, setRasterReprojectResampling] = useState('nearest')
|
|
||||||
const [ndviNirBand, setNdviNirBand] = useState(4)
|
|
||||||
const [ndviRedBand, setNdviRedBand] = useState(3)
|
|
||||||
const [ndwiGreenBand, setNdwiGreenBand] = useState(2)
|
|
||||||
const [ndwiNirBand, setNdwiNirBand] = useState(4)
|
|
||||||
const [ndbiSwirBand, setNdbiSwirBand] = useState(5)
|
|
||||||
const [ndbiNirBand, setNdbiNirBand] = useState(4)
|
|
||||||
|
|
||||||
const [loadingProjects, setLoadingProjects] = useState(false)
|
const [loadingProjects, setLoadingProjects] = useState(false)
|
||||||
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
||||||
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
||||||
const [loadingAreas, setLoadingAreas] = useState(false)
|
const [loadingAreas, setLoadingAreas] = useState(false)
|
||||||
const [loadingDatasets, setLoadingDatasets] = useState(false)
|
const [loadingDatasets, setLoadingDatasets] = useState(false)
|
||||||
const [loadingDatasetDetails, setLoadingDatasetDetails] = useState(false)
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const [datasetDetailError, setDatasetDetailError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [projectForm, setProjectForm] = useState<ProjectCreate>({
|
const [projectForm, setProjectForm] = useState<ProjectCreate>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -129,22 +100,76 @@ function App(): JSX.Element {
|
|||||||
'{"type":"MultiPolygon","coordinates":[[[[4.35,51.28],[4.55,51.28],[4.55,51.46],[4.35,51.46],[4.35,51.28]]]]}',
|
'{"type":"MultiPolygon","coordinates":[[[[4.35,51.28],[4.55,51.28],[4.55,51.46],[4.35,51.46],[4.35,51.28]]]]}',
|
||||||
crs: 'EPSG:4326',
|
crs: 'EPSG:4326',
|
||||||
})
|
})
|
||||||
const [datasetForm, setDatasetForm] = useState({
|
|
||||||
datasetType: 'vector',
|
|
||||||
source: 'user_upload',
|
|
||||||
datasetRole: 'source',
|
|
||||||
sourceName: '',
|
|
||||||
referenceLayerName: '',
|
|
||||||
sourceMetadataJson: '',
|
|
||||||
provenanceMetadataJson: '',
|
|
||||||
areaId: '',
|
|
||||||
file: null as File | null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedProject = useMemo(
|
const selectedProject = useMemo(
|
||||||
() => projects.find((project) => project.id === selectedProjectId) ?? null,
|
() => projects.find((project) => project.id === selectedProjectId) ?? null,
|
||||||
[projects, selectedProjectId],
|
[projects, selectedProjectId],
|
||||||
)
|
)
|
||||||
|
const {
|
||||||
|
selectedDatasetId,
|
||||||
|
selectedDataset,
|
||||||
|
selectedDatasetSummary,
|
||||||
|
selectedRasterMetadata,
|
||||||
|
selectedRasterStats,
|
||||||
|
datasetContent,
|
||||||
|
jobs,
|
||||||
|
rasterPreview,
|
||||||
|
selectedIntersectTargetId,
|
||||||
|
selectedClipAreaId,
|
||||||
|
rasterTileSize,
|
||||||
|
rasterTileOverlap,
|
||||||
|
rasterTileOutputName,
|
||||||
|
rasterReprojectCrs,
|
||||||
|
rasterReprojectResampling,
|
||||||
|
ndviNirBand,
|
||||||
|
ndviRedBand,
|
||||||
|
ndwiGreenBand,
|
||||||
|
ndwiNirBand,
|
||||||
|
ndbiSwirBand,
|
||||||
|
ndbiNirBand,
|
||||||
|
loadingDatasetDetails,
|
||||||
|
datasetDetailError,
|
||||||
|
datasetForm,
|
||||||
|
isRasterTileInputValid,
|
||||||
|
loadDatasetJobs,
|
||||||
|
loadDatasetDetails,
|
||||||
|
uploadDataset,
|
||||||
|
runVectorClip,
|
||||||
|
runVectorBuffer,
|
||||||
|
runVectorIntersect,
|
||||||
|
runRasterInspect,
|
||||||
|
runRasterPreview,
|
||||||
|
runRasterStats,
|
||||||
|
runRasterReproject,
|
||||||
|
runRasterClip,
|
||||||
|
runRasterTile,
|
||||||
|
runRasterNdvi,
|
||||||
|
runRasterNdwi,
|
||||||
|
runRasterNdbi,
|
||||||
|
pickDerivedDataset,
|
||||||
|
refreshMetadata,
|
||||||
|
resetDatasetForProject,
|
||||||
|
setSelectedDatasetId,
|
||||||
|
setSelectedIntersectTargetId,
|
||||||
|
setSelectedClipAreaId,
|
||||||
|
setRasterTileSize,
|
||||||
|
setRasterTileOverlap,
|
||||||
|
setRasterTileOutputName,
|
||||||
|
setRasterReprojectCrs,
|
||||||
|
setRasterReprojectResampling,
|
||||||
|
setNdviNirBand,
|
||||||
|
setNdviRedBand,
|
||||||
|
setNdwiGreenBand,
|
||||||
|
setNdwiNirBand,
|
||||||
|
setNdbiSwirBand,
|
||||||
|
setNdbiNirBand,
|
||||||
|
setDatasetForm,
|
||||||
|
} = useDatasetWorkflow({
|
||||||
|
selectedProjectId,
|
||||||
|
datasets,
|
||||||
|
loadProjectData,
|
||||||
|
setErrorMessage,
|
||||||
|
isVectorDatasetType,
|
||||||
|
})
|
||||||
const rasterUnavailableMessage = useMemo(() => {
|
const rasterUnavailableMessage = useMemo(() => {
|
||||||
if (!selectedDataset || selectedDataset.dataset_type !== 'raster' || !selectedDataset.metadata_json) {
|
if (!selectedDataset || selectedDataset.dataset_type !== 'raster' || !selectedDataset.metadata_json) {
|
||||||
return null
|
return null
|
||||||
@@ -343,18 +368,6 @@ function App(): JSX.Element {
|
|||||||
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
|
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
|
||||||
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
|
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
|
||||||
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
|
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
|
||||||
const isRasterTileInputValid = useMemo(
|
|
||||||
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
|
||||||
[rasterTileSize, rasterTileOverlap],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toRasterMetadata = (metadata: Record<string, unknown> | null | undefined): RasterMetadataResponse | null => {
|
|
||||||
if (!metadata) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return metadata as unknown as RasterMetadataResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatRasterBounds = (bounds: number[] | undefined | null): string => {
|
const formatRasterBounds = (bounds: number[] | undefined | null): string => {
|
||||||
if (!bounds || bounds.length < 4) {
|
if (!bounds || bounds.length < 4) {
|
||||||
return 'n/a'
|
return 'n/a'
|
||||||
@@ -423,47 +436,6 @@ function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDatasetJobs = async (projectId: string, datasetId: string) => {
|
|
||||||
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
|
|
||||||
setJobs(response.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => {
|
|
||||||
setLoadingDatasetDetails(true)
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
setSelectedDataset(dataset)
|
|
||||||
setSelectedDatasetSummary(null)
|
|
||||||
setSelectedRasterMetadata(null)
|
|
||||||
setSelectedRasterStats(null)
|
|
||||||
setDatasetContent(null)
|
|
||||||
setRasterPreview(null)
|
|
||||||
setSelectedDatasetId(dataset.id)
|
|
||||||
setJobs([])
|
|
||||||
try {
|
|
||||||
if (isVectorDatasetType(dataset.dataset_type)) {
|
|
||||||
const [content, summary] = await Promise.all([
|
|
||||||
datasetsApi.getContent(projectId, dataset.id),
|
|
||||||
datasetsApi.vectorSummary(projectId, dataset.id),
|
|
||||||
])
|
|
||||||
setDatasetContent(content)
|
|
||||||
setSelectedDatasetSummary(summary)
|
|
||||||
} else if (dataset.dataset_type === 'raster') {
|
|
||||||
try {
|
|
||||||
const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id)
|
|
||||||
setSelectedRasterMetadata(toRasterMetadata(rasterInspection.metadata))
|
|
||||||
} catch (error) {
|
|
||||||
setSelectedRasterMetadata(null)
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster metadata unavailable'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await loadDatasetJobs(projectId, dataset.id)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Unable to load dataset detail'))
|
|
||||||
} finally {
|
|
||||||
setLoadingDatasetDetails(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects().catch(() => null)
|
loadProjects().catch(() => null)
|
||||||
loadCapabilities().catch(() => null)
|
loadCapabilities().catch(() => null)
|
||||||
@@ -479,13 +451,8 @@ function App(): JSX.Element {
|
|||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
setAreas([])
|
setAreas([])
|
||||||
setDatasets([])
|
setDatasets([])
|
||||||
setSelectedDatasetId(null)
|
resetDatasetForProject()
|
||||||
setSelectedDataset(null)
|
|
||||||
setSelectedMapAreaId('')
|
setSelectedMapAreaId('')
|
||||||
setSelectedDatasetSummary(null)
|
|
||||||
setSelectedRasterMetadata(null)
|
|
||||||
setSelectedRasterStats(null)
|
|
||||||
setJobs([])
|
|
||||||
resetDetectionForProject()
|
resetDetectionForProject()
|
||||||
resetSegmentationForProject()
|
resetSegmentationForProject()
|
||||||
resetExportsForProject()
|
resetExportsForProject()
|
||||||
@@ -586,305 +553,6 @@ function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadDataset = async (event: FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!selectedProjectId || !datasetForm.file) {
|
|
||||||
setErrorMessage('Select project and upload a file')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!datasetForm.datasetRole) {
|
|
||||||
setErrorMessage('Select dataset role')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') {
|
|
||||||
setErrorMessage('Reference role requires vector dataset upload')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (datasetForm.sourceMetadataJson) {
|
|
||||||
try {
|
|
||||||
const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson)
|
|
||||||
if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') {
|
|
||||||
setErrorMessage('Source metadata must be a JSON object')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('Source metadata must be valid JSON')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (datasetForm.provenanceMetadataJson) {
|
|
||||||
try {
|
|
||||||
const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson)
|
|
||||||
if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') {
|
|
||||||
setErrorMessage('Provenance metadata must be a JSON object')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setErrorMessage('Provenance metadata must be valid JSON')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.upload(selectedProjectId, {
|
|
||||||
file: datasetForm.file,
|
|
||||||
datasetType: datasetForm.datasetType,
|
|
||||||
source: datasetForm.source,
|
|
||||||
datasetRole: datasetForm.datasetRole,
|
|
||||||
sourceName: datasetForm.sourceName || undefined,
|
|
||||||
referenceLayerName: datasetForm.referenceLayerName || undefined,
|
|
||||||
sourceMetadataJson: datasetForm.sourceMetadataJson || undefined,
|
|
||||||
provenanceMetadataJson: datasetForm.provenanceMetadataJson || undefined,
|
|
||||||
areaId: datasetForm.areaId || undefined,
|
|
||||||
})
|
|
||||||
setDatasetForm((previous) => ({ ...previous, file: null }))
|
|
||||||
await loadProjectData(selectedProjectId)
|
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to upload dataset')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runVectorClip = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedClipAreaId) {
|
|
||||||
setDatasetDetailError('Select an area for clipping')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
try {
|
|
||||||
await datasetsApi.vectorClip(selectedProjectId, selectedDatasetId, {
|
|
||||||
area_id: selectedClipAreaId,
|
|
||||||
output_name: `${selectedDataset?.name.replace(/\.geojson$/, '')}-clipped`,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Vector clip failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runVectorBuffer = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
try {
|
|
||||||
await datasetsApi.vectorBuffer(selectedProjectId, selectedDatasetId, {
|
|
||||||
distance_m: 25,
|
|
||||||
dissolve: false,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Vector buffer failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runVectorIntersect = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id
|
|
||||||
if (!targetId) {
|
|
||||||
setDatasetDetailError('Select an intersect target dataset')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
try {
|
|
||||||
await datasetsApi.vectorIntersect(selectedProjectId, selectedDatasetId, {
|
|
||||||
other_dataset_id: targetId,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Vector intersect failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterInspect = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await datasetsApi.inspectRaster(selectedProjectId, selectedDatasetId)
|
|
||||||
setSelectedRasterMetadata(toRasterMetadata(response.metadata))
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster inspect unavailable'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterPreview = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await datasetsApi.rasterPreview(selectedProjectId, selectedDatasetId)
|
|
||||||
setRasterPreview(response)
|
|
||||||
if (response.metadata) {
|
|
||||||
setSelectedRasterMetadata(toRasterMetadata(response.metadata))
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster preview unavailable'))
|
|
||||||
setRasterPreview(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterStats = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await datasetsApi.rasterStats(selectedProjectId, selectedDatasetId)
|
|
||||||
setSelectedRasterStats(response)
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster stats unavailable'))
|
|
||||||
setSelectedRasterStats(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterReproject = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const targetCrs = rasterReprojectCrs.trim()
|
|
||||||
if (!targetCrs) {
|
|
||||||
setDatasetDetailError('Target CRS is required for raster reproject')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.rasterReproject(selectedProjectId, selectedDatasetId, {
|
|
||||||
target_crs: targetCrs,
|
|
||||||
resampling: rasterReprojectResampling,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster reproject failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterClip = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedClipAreaId) {
|
|
||||||
setDatasetDetailError('Select an area for raster clip')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.rasterClip(selectedProjectId, selectedDatasetId, {
|
|
||||||
area_id: selectedClipAreaId,
|
|
||||||
output_name: `raster-clipped-${selectedDatasetId}`,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster clip failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterTile = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.rasterTile(selectedProjectId, selectedDatasetId, {
|
|
||||||
tile_size: rasterTileSize,
|
|
||||||
overlap: rasterTileOverlap,
|
|
||||||
output_name: rasterTileOutputName || undefined,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster tile failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterNdvi = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.rasterNdvi(selectedProjectId, selectedDatasetId, {
|
|
||||||
nir_band: ndviNirBand,
|
|
||||||
red_band: ndviRedBand,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster NDVI failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterNdwi = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.rasterNdwi(selectedProjectId, selectedDatasetId, {
|
|
||||||
green_band: ndwiGreenBand,
|
|
||||||
nir_band: ndwiNirBand,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster NDWI failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runRasterNdbi = async () => {
|
|
||||||
if (!selectedProjectId || !selectedDatasetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await datasetsApi.rasterNdbi(selectedProjectId, selectedDatasetId, {
|
|
||||||
swir_band: ndbiSwirBand,
|
|
||||||
nir_band: ndbiNirBand,
|
|
||||||
})
|
|
||||||
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
|
||||||
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
|
||||||
if (refreshed && selectedProjectId) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
setDatasetDetailError(null)
|
|
||||||
} catch (error) {
|
|
||||||
setDatasetDetailError(formatError(error, 'Raster NDBI failed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runChangeDetection = async () => {
|
const runChangeDetection = async () => {
|
||||||
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
|
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
|
||||||
const targetDatasetId =
|
const targetDatasetId =
|
||||||
@@ -930,30 +598,6 @@ function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickDerivedDataset = async (datasetId: string) => {
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = datasets.find((item) => item.id === datasetId)
|
|
||||||
if (target) {
|
|
||||||
await loadDatasetDetails(selectedProjectId, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshMetadata = async (datasetId: string) => {
|
|
||||||
if (!selectedProjectId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const refreshed = await datasetsApi.refreshMetadata(selectedProjectId, datasetId)
|
|
||||||
await loadProjectData(selectedProjectId)
|
|
||||||
if (selectedDataset?.id === datasetId) {
|
|
||||||
setSelectedDataset(refreshed)
|
|
||||||
if (isVectorDatasetType(refreshed.dataset_type)) {
|
|
||||||
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header>
|
<header>
|
||||||
@@ -1460,7 +1104,7 @@ function App(): JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" onClick={runVectorIntersect}>
|
<button type="button" onClick={() => runVectorIntersect(availableVectorTargets)}>
|
||||||
Run intersect
|
Run intersect
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { FormEvent, useMemo, useState } from 'react'
|
||||||
|
import { datasetsApi, jobsApi } from '../services/api'
|
||||||
|
import type {
|
||||||
|
DatasetCreateResponse,
|
||||||
|
JobRead,
|
||||||
|
RasterMetadataResponse,
|
||||||
|
RasterPreviewResponse,
|
||||||
|
RasterStatsResponse,
|
||||||
|
VectorSummary,
|
||||||
|
} from '../types'
|
||||||
|
import { formatError } from '../lib/formatError'
|
||||||
|
|
||||||
|
interface DatasetWorkflowOptions {
|
||||||
|
selectedProjectId: string | null
|
||||||
|
datasets: DatasetCreateResponse[]
|
||||||
|
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
|
||||||
|
setErrorMessage: (message: string | null) => void
|
||||||
|
isVectorDatasetType: (datasetType: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRasterMetadata(metadata: Record<string, unknown> | null | undefined): RasterMetadataResponse | null {
|
||||||
|
if (!metadata) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return metadata as unknown as RasterMetadataResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDatasetWorkflow({
|
||||||
|
selectedProjectId,
|
||||||
|
datasets,
|
||||||
|
loadProjectData,
|
||||||
|
setErrorMessage,
|
||||||
|
isVectorDatasetType,
|
||||||
|
}: DatasetWorkflowOptions) {
|
||||||
|
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null)
|
||||||
|
const [selectedDataset, setSelectedDataset] = useState<DatasetCreateResponse | null>(null)
|
||||||
|
const [selectedDatasetSummary, setSelectedDatasetSummary] = useState<VectorSummary | null>(null)
|
||||||
|
const [selectedRasterMetadata, setSelectedRasterMetadata] = useState<RasterMetadataResponse | null>(null)
|
||||||
|
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
|
||||||
|
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
|
||||||
|
const [jobs, setJobs] = useState<JobRead[]>([])
|
||||||
|
const [rasterPreview, setRasterPreview] = useState<RasterPreviewResponse | null>(null)
|
||||||
|
const [selectedIntersectTargetId, setSelectedIntersectTargetId] = useState('')
|
||||||
|
const [selectedClipAreaId, setSelectedClipAreaId] = useState('')
|
||||||
|
const [rasterTileSize, setRasterTileSize] = useState(512)
|
||||||
|
const [rasterTileOverlap, setRasterTileOverlap] = useState(64)
|
||||||
|
const [rasterTileOutputName, setRasterTileOutputName] = useState('')
|
||||||
|
const [rasterReprojectCrs, setRasterReprojectCrs] = useState('EPSG:31370')
|
||||||
|
const [rasterReprojectResampling, setRasterReprojectResampling] = useState('nearest')
|
||||||
|
const [ndviNirBand, setNdviNirBand] = useState(4)
|
||||||
|
const [ndviRedBand, setNdviRedBand] = useState(3)
|
||||||
|
const [ndwiGreenBand, setNdwiGreenBand] = useState(2)
|
||||||
|
const [ndwiNirBand, setNdwiNirBand] = useState(4)
|
||||||
|
const [ndbiSwirBand, setNdbiSwirBand] = useState(5)
|
||||||
|
const [ndbiNirBand, setNdbiNirBand] = useState(4)
|
||||||
|
const [loadingDatasetDetails, setLoadingDatasetDetails] = useState(false)
|
||||||
|
const [datasetDetailError, setDatasetDetailError] = useState<string | null>(null)
|
||||||
|
const [datasetForm, setDatasetForm] = useState({
|
||||||
|
datasetType: 'vector',
|
||||||
|
source: 'user_upload',
|
||||||
|
datasetRole: 'source',
|
||||||
|
sourceName: '',
|
||||||
|
referenceLayerName: '',
|
||||||
|
sourceMetadataJson: '',
|
||||||
|
provenanceMetadataJson: '',
|
||||||
|
areaId: '',
|
||||||
|
file: null as File | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isRasterTileInputValid = useMemo(
|
||||||
|
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
||||||
|
[rasterTileSize, rasterTileOverlap],
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadDatasetJobs = async (projectId: string, datasetId: string) => {
|
||||||
|
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
|
||||||
|
setJobs(response.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => {
|
||||||
|
setLoadingDatasetDetails(true)
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
setSelectedDataset(dataset)
|
||||||
|
setSelectedDatasetSummary(null)
|
||||||
|
setSelectedRasterMetadata(null)
|
||||||
|
setSelectedRasterStats(null)
|
||||||
|
setDatasetContent(null)
|
||||||
|
setRasterPreview(null)
|
||||||
|
setSelectedDatasetId(dataset.id)
|
||||||
|
setJobs([])
|
||||||
|
try {
|
||||||
|
if (isVectorDatasetType(dataset.dataset_type)) {
|
||||||
|
const [content, summary] = await Promise.all([
|
||||||
|
datasetsApi.getContent(projectId, dataset.id),
|
||||||
|
datasetsApi.vectorSummary(projectId, dataset.id),
|
||||||
|
])
|
||||||
|
setDatasetContent(content)
|
||||||
|
setSelectedDatasetSummary(summary)
|
||||||
|
} else if (dataset.dataset_type === 'raster') {
|
||||||
|
try {
|
||||||
|
const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id)
|
||||||
|
setSelectedRasterMetadata(toRasterMetadata(rasterInspection.metadata))
|
||||||
|
} catch (error) {
|
||||||
|
setSelectedRasterMetadata(null)
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster metadata unavailable'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadDatasetJobs(projectId, dataset.id)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Unable to load dataset detail'))
|
||||||
|
} finally {
|
||||||
|
setLoadingDatasetDetails(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDataset = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selectedProjectId || !datasetForm.file) {
|
||||||
|
setErrorMessage('Select project and upload a file')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!datasetForm.datasetRole) {
|
||||||
|
setErrorMessage('Select dataset role')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') {
|
||||||
|
setErrorMessage('Reference role requires vector dataset upload')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (datasetForm.sourceMetadataJson) {
|
||||||
|
try {
|
||||||
|
const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson)
|
||||||
|
if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') {
|
||||||
|
setErrorMessage('Source metadata must be a JSON object')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Source metadata must be valid JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (datasetForm.provenanceMetadataJson) {
|
||||||
|
try {
|
||||||
|
const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson)
|
||||||
|
if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') {
|
||||||
|
setErrorMessage('Provenance metadata must be a JSON object')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErrorMessage('Provenance metadata must be valid JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.upload(selectedProjectId, {
|
||||||
|
file: datasetForm.file,
|
||||||
|
datasetType: datasetForm.datasetType,
|
||||||
|
source: datasetForm.source,
|
||||||
|
datasetRole: datasetForm.datasetRole,
|
||||||
|
sourceName: datasetForm.sourceName || undefined,
|
||||||
|
referenceLayerName: datasetForm.referenceLayerName || undefined,
|
||||||
|
sourceMetadataJson: datasetForm.sourceMetadataJson || undefined,
|
||||||
|
provenanceMetadataJson: datasetForm.provenanceMetadataJson || undefined,
|
||||||
|
areaId: datasetForm.areaId || undefined,
|
||||||
|
})
|
||||||
|
setDatasetForm((previous) => ({ ...previous, file: null }))
|
||||||
|
await loadProjectData(selectedProjectId)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to upload dataset')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSelectedDatasetAfterJob = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
|
||||||
|
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
|
||||||
|
if (refreshed) {
|
||||||
|
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runVectorClip = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedClipAreaId) {
|
||||||
|
setDatasetDetailError('Select an area for clipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
try {
|
||||||
|
await datasetsApi.vectorClip(selectedProjectId, selectedDatasetId, {
|
||||||
|
area_id: selectedClipAreaId,
|
||||||
|
output_name: `${selectedDataset?.name.replace(/\.geojson$/, '')}-clipped`,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Vector clip failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runVectorBuffer = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
try {
|
||||||
|
await datasetsApi.vectorBuffer(selectedProjectId, selectedDatasetId, {
|
||||||
|
distance_m: 25,
|
||||||
|
dissolve: false,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Vector buffer failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runVectorIntersect = async (availableVectorTargets: DatasetCreateResponse[]) => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id
|
||||||
|
if (!targetId) {
|
||||||
|
setDatasetDetailError('Select an intersect target dataset')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
try {
|
||||||
|
await datasetsApi.vectorIntersect(selectedProjectId, selectedDatasetId, {
|
||||||
|
other_dataset_id: targetId,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Vector intersect failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterInspect = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await datasetsApi.inspectRaster(selectedProjectId, selectedDatasetId)
|
||||||
|
setSelectedRasterMetadata(toRasterMetadata(response.metadata))
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster inspect unavailable'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterPreview = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await datasetsApi.rasterPreview(selectedProjectId, selectedDatasetId)
|
||||||
|
setRasterPreview(response)
|
||||||
|
if (response.metadata) {
|
||||||
|
setSelectedRasterMetadata(toRasterMetadata(response.metadata))
|
||||||
|
}
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster preview unavailable'))
|
||||||
|
setRasterPreview(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterStats = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await datasetsApi.rasterStats(selectedProjectId, selectedDatasetId)
|
||||||
|
setSelectedRasterStats(response)
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster stats unavailable'))
|
||||||
|
setSelectedRasterStats(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterReproject = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetCrs = rasterReprojectCrs.trim()
|
||||||
|
if (!targetCrs) {
|
||||||
|
setDatasetDetailError('Target CRS is required for raster reproject')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.rasterReproject(selectedProjectId, selectedDatasetId, {
|
||||||
|
target_crs: targetCrs,
|
||||||
|
resampling: rasterReprojectResampling,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster reproject failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterClip = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedClipAreaId) {
|
||||||
|
setDatasetDetailError('Select an area for raster clip')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.rasterClip(selectedProjectId, selectedDatasetId, {
|
||||||
|
area_id: selectedClipAreaId,
|
||||||
|
output_name: `raster-clipped-${selectedDatasetId}`,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster clip failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterTile = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.rasterTile(selectedProjectId, selectedDatasetId, {
|
||||||
|
tile_size: rasterTileSize,
|
||||||
|
overlap: rasterTileOverlap,
|
||||||
|
output_name: rasterTileOutputName || undefined,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster tile failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterNdvi = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.rasterNdvi(selectedProjectId, selectedDatasetId, {
|
||||||
|
nir_band: ndviNirBand,
|
||||||
|
red_band: ndviRedBand,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster NDVI failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterNdwi = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.rasterNdwi(selectedProjectId, selectedDatasetId, {
|
||||||
|
green_band: ndwiGreenBand,
|
||||||
|
nir_band: ndwiNirBand,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster NDWI failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRasterNdbi = async () => {
|
||||||
|
if (!selectedProjectId || !selectedDatasetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await datasetsApi.rasterNdbi(selectedProjectId, selectedDatasetId, {
|
||||||
|
swir_band: ndbiSwirBand,
|
||||||
|
nir_band: ndbiNirBand,
|
||||||
|
})
|
||||||
|
await refreshSelectedDatasetAfterJob()
|
||||||
|
setDatasetDetailError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setDatasetDetailError(formatError(error, 'Raster NDBI failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickDerivedDataset = async (datasetId: string) => {
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = datasets.find((item) => item.id === datasetId)
|
||||||
|
if (target) {
|
||||||
|
await loadDatasetDetails(selectedProjectId, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshMetadata = async (datasetId: string) => {
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const refreshed = await datasetsApi.refreshMetadata(selectedProjectId, datasetId)
|
||||||
|
await loadProjectData(selectedProjectId)
|
||||||
|
if (selectedDataset?.id === datasetId) {
|
||||||
|
setSelectedDataset(refreshed)
|
||||||
|
if (isVectorDatasetType(refreshed.dataset_type)) {
|
||||||
|
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetDatasetForProject = () => {
|
||||||
|
setSelectedDatasetId(null)
|
||||||
|
setSelectedDataset(null)
|
||||||
|
setSelectedDatasetSummary(null)
|
||||||
|
setSelectedRasterMetadata(null)
|
||||||
|
setSelectedRasterStats(null)
|
||||||
|
setDatasetContent(null)
|
||||||
|
setRasterPreview(null)
|
||||||
|
setJobs([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedDatasetId,
|
||||||
|
selectedDataset,
|
||||||
|
selectedDatasetSummary,
|
||||||
|
selectedRasterMetadata,
|
||||||
|
selectedRasterStats,
|
||||||
|
datasetContent,
|
||||||
|
jobs,
|
||||||
|
rasterPreview,
|
||||||
|
selectedIntersectTargetId,
|
||||||
|
selectedClipAreaId,
|
||||||
|
rasterTileSize,
|
||||||
|
rasterTileOverlap,
|
||||||
|
rasterTileOutputName,
|
||||||
|
rasterReprojectCrs,
|
||||||
|
rasterReprojectResampling,
|
||||||
|
ndviNirBand,
|
||||||
|
ndviRedBand,
|
||||||
|
ndwiGreenBand,
|
||||||
|
ndwiNirBand,
|
||||||
|
ndbiSwirBand,
|
||||||
|
ndbiNirBand,
|
||||||
|
loadingDatasetDetails,
|
||||||
|
datasetDetailError,
|
||||||
|
datasetForm,
|
||||||
|
isRasterTileInputValid,
|
||||||
|
loadDatasetJobs,
|
||||||
|
loadDatasetDetails,
|
||||||
|
uploadDataset,
|
||||||
|
runVectorClip,
|
||||||
|
runVectorBuffer,
|
||||||
|
runVectorIntersect,
|
||||||
|
runRasterInspect,
|
||||||
|
runRasterPreview,
|
||||||
|
runRasterStats,
|
||||||
|
runRasterReproject,
|
||||||
|
runRasterClip,
|
||||||
|
runRasterTile,
|
||||||
|
runRasterNdvi,
|
||||||
|
runRasterNdwi,
|
||||||
|
runRasterNdbi,
|
||||||
|
pickDerivedDataset,
|
||||||
|
refreshMetadata,
|
||||||
|
resetDatasetForProject,
|
||||||
|
setSelectedDatasetId,
|
||||||
|
setSelectedDataset,
|
||||||
|
setSelectedIntersectTargetId,
|
||||||
|
setSelectedClipAreaId,
|
||||||
|
setRasterTileSize,
|
||||||
|
setRasterTileOverlap,
|
||||||
|
setRasterTileOutputName,
|
||||||
|
setRasterReprojectCrs,
|
||||||
|
setRasterReprojectResampling,
|
||||||
|
setNdviNirBand,
|
||||||
|
setNdviRedBand,
|
||||||
|
setNdwiGreenBand,
|
||||||
|
setNdwiNirBand,
|
||||||
|
setNdbiSwirBand,
|
||||||
|
setNdbiNirBand,
|
||||||
|
setDatasetForm,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user