Extract dataset workflow orchestration
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 02:44:10 +02:00
parent 5d56a73908
commit 361776cbbc
7 changed files with 653 additions and 427 deletions
+7
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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>
+486
View File
@@ -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,
}
}