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
+70 -426
View File
@@ -4,7 +4,7 @@ import GeoMap from './components/GeoMap'
import { areasApi } from './services/api/areas'
import { datasetsApi } from './services/api/datasets'
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 { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter'
@@ -16,12 +16,6 @@ import type {
ChangeDetectionSummary,
DatasetCreateResponse,
DatasetListResponse,
JobRead,
RasterMetadataResponse,
RasterInspectResponse,
RasterStatsResponse,
RasterPreviewResponse,
VectorSummary,
ProjectRead,
ProjectCreate,
AreaCreate,
@@ -32,6 +26,7 @@ import type {
import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
import { useExportWorkflow } from './hooks/useExportWorkflow'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
@@ -71,19 +66,12 @@ function App(): JSX.Element {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [areas, setAreas] = useState<AreaRead[]>([])
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 [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
const [selectedMapAreaId, setSelectedMapAreaId] = useState('')
const [areaLayerVisible, setAreaLayerVisible] = useState(true)
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
const [jobs, setJobs] = useState<JobRead[]>([])
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null)
@@ -94,29 +82,12 @@ function App(): JSX.Element {
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | 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 [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
const [loadingAreas, setLoadingAreas] = useState(false)
const [loadingDatasets, setLoadingDatasets] = useState(false)
const [loadingDatasetDetails, setLoadingDatasetDetails] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [datasetDetailError, setDatasetDetailError] = useState<string | null>(null)
const [projectForm, setProjectForm] = useState<ProjectCreate>({
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]]]]}',
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(
() => projects.find((project) => project.id === selectedProjectId) ?? null,
[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(() => {
if (!selectedDataset || selectedDataset.dataset_type !== 'raster' || !selectedDataset.metadata_json) {
return null
@@ -343,18 +368,6 @@ function App(): JSX.Element {
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.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 => {
if (!bounds || bounds.length < 4) {
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(() => {
loadProjects().catch(() => null)
loadCapabilities().catch(() => null)
@@ -479,13 +451,8 @@ function App(): JSX.Element {
if (!selectedProjectId) {
setAreas([])
setDatasets([])
setSelectedDatasetId(null)
setSelectedDataset(null)
resetDatasetForProject()
setSelectedMapAreaId('')
setSelectedDatasetSummary(null)
setSelectedRasterMetadata(null)
setSelectedRasterStats(null)
setJobs([])
resetDetectionForProject()
resetSegmentationForProject()
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 sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
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 (
<div className="app-shell">
<header>
@@ -1460,7 +1104,7 @@ function App(): JSX.Element {
))}
</select>
</label>
<button type="button" onClick={runVectorIntersect}>
<button type="button" onClick={() => runVectorIntersect(availableVectorTargets)}>
Run intersect
</button>
</div>