Files
geointel/frontend/src/hooks/useDatasetWorkflow.ts
T
Codex 9fff0c5706
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
fix: isolate regional project context
2026-07-14 18:41:05 +02:00

609 lines
21 KiB
TypeScript

import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { datasetsApi, jobsApi } from '../services/api'
import type {
AreaRead,
DatasetCreateResponse,
JobRead,
RasterTileHandoff,
RasterMetadataResponse,
RasterPreviewResponse,
RasterStatsResponse,
VectorSummary,
} from '../types'
import { formatError } from '../lib/formatError'
import { isPrimaryFocusMunicipalityBoundaryDataset } from '../config/primaryFocus'
import { VECTOR_VIEWPORT_FEATURE_THRESHOLD } from '../config/vectorDelivery'
interface DatasetWorkflowOptions {
selectedProjectId: string | null
areas: AreaRead[]
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
}
function isOperationalScopeBoundaryDataset(dataset: DatasetCreateResponse): boolean {
return (
dataset.status === 'ready' &&
(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson') &&
dataset.source_metadata?.layer_type === 'regional_boundary'
)
}
function extractRasterTileManifestPath(job: JobRead | null | undefined): string {
const manifest = toRasterTileHandoff(job)
return manifest?.manifest_path ?? ''
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function optionalNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function toRasterTileHandoff(job: JobRead | null | undefined): RasterTileHandoff | null {
const result = job?.result_json
if (!result) {
return null
}
const manifestPath = result.manifest_path
if (typeof manifestPath !== 'string' || manifestPath.trim().length === 0) {
return null
}
const manifest = typeof result.manifest === 'object' && result.manifest !== null
? result.manifest as Record<string, unknown>
: {}
return {
manifest_path: manifestPath,
tile_set_id: optionalString(result.tile_set_id) ?? optionalString(manifest.tile_set_id),
tile_size: optionalNumber(result.tile_size) ?? optionalNumber(manifest.tile_size),
overlap: optionalNumber(result.overlap) ?? optionalNumber(manifest.overlap),
count: optionalNumber(result.count) ?? optionalNumber(manifest.count),
source_dataset_id: optionalString(result.source_dataset_id) ?? optionalString(manifest.source_dataset_id),
created_at: optionalString(manifest.created_at),
}
}
export function useDatasetWorkflow({
selectedProjectId,
areas,
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 [latestRasterTileManifestPath, setLatestRasterTileManifestPath] = useState('')
const [latestRasterTileManifest, setLatestRasterTileManifest] = useState<RasterTileHandoff | 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 datasetDetailRequestSequence = useRef(0)
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],
)
useEffect(() => {
if (areas.length === 0) {
setSelectedClipAreaId('')
} else if (!selectedClipAreaId || !areas.some((area) => area.id === selectedClipAreaId)) {
setSelectedClipAreaId(areas[0].id)
}
}, [areas, selectedClipAreaId])
useEffect(() => {
if (!selectedProjectId || selectedDatasetId || datasets.length === 0) {
return
}
// Auto-open the first usable dataset so Data, Map and Exports start with real context.
const defaultDataset =
datasets.find(isOperationalScopeBoundaryDataset) ??
datasets.find(isPrimaryFocusMunicipalityBoundaryDataset) ??
datasets.find((dataset) => isVectorDatasetType(dataset.dataset_type) && dataset.status === 'ready') ??
datasets.find((dataset) => dataset.status === 'ready') ??
datasets[0]
if (defaultDataset) {
loadDatasetDetails(selectedProjectId, defaultDataset).catch(() => null)
}
}, [datasets, isVectorDatasetType, selectedDatasetId, selectedProjectId])
const loadDatasetJobs = async (projectId: string, datasetId: string, detailRequestId?: number) => {
const response = await jobsApi.list(projectId, { dataset_id: datasetId, limit: 20, offset: 0 })
if (detailRequestId != null && detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setJobs(response.items)
const latestRasterTileJob = response.items.find(
(job) => job.job_type === 'raster.tile' && extractRasterTileManifestPath(job),
)
const manifest = toRasterTileHandoff(latestRasterTileJob)
const manifestPath = manifest ? manifest.manifest_path : ''
setLatestRasterTileManifest(manifest)
setLatestRasterTileManifestPath(manifestPath)
}
const loadDatasetDetails = async (projectId: string, dataset: DatasetCreateResponse) => {
const detailRequestId = ++datasetDetailRequestSequence.current
setLoadingDatasetDetails(true)
setDatasetDetailError(null)
setSelectedDataset(dataset)
setSelectedDatasetSummary(null)
setSelectedRasterMetadata(null)
setSelectedRasterStats(null)
setDatasetContent(null)
setRasterPreview(null)
setLatestRasterTileManifestPath('')
setLatestRasterTileManifest(null)
setSelectedDatasetId(dataset.id)
setJobs([])
try {
if (isVectorDatasetType(dataset.dataset_type)) {
const summary = await datasetsApi.vectorSummary(projectId, dataset.id)
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setSelectedDatasetSummary(summary)
const featureCount = summary.feature_count ?? dataset.feature_count
if (featureCount == null || featureCount <= VECTOR_VIEWPORT_FEATURE_THRESHOLD) {
const content = await datasetsApi.getContent(projectId, dataset.id)
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setDatasetContent(content)
}
} else if (dataset.dataset_type === 'raster') {
try {
const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id)
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setSelectedRasterMetadata(toRasterMetadata(rasterInspection.metadata))
} catch (error) {
if (detailRequestId !== datasetDetailRequestSequence.current) {
return
}
setSelectedRasterMetadata(null)
setDatasetDetailError(formatError(error, 'Raster metadata unavailable'))
}
}
await loadDatasetJobs(projectId, dataset.id, detailRequestId)
} catch (error) {
if (detailRequestId === datasetDetailRequestSequence.current) {
setDatasetDetailError(formatError(error, 'Unable to load dataset detail'))
}
} finally {
if (detailRequestId === datasetDetailRequestSequence.current) {
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 {
const job = await datasetsApi.rasterTile(selectedProjectId, selectedDatasetId, {
tile_size: rasterTileSize,
overlap: rasterTileOverlap,
output_name: rasterTileOutputName || undefined,
})
const manifest = toRasterTileHandoff(job)
const manifestPath = manifest?.manifest_path ?? ''
setLatestRasterTileManifest(manifest)
setLatestRasterTileManifestPath(manifestPath)
await refreshSelectedDatasetAfterJob()
if (manifestPath) {
setLatestRasterTileManifest(manifest)
setLatestRasterTileManifestPath(manifestPath)
}
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 = () => {
datasetDetailRequestSequence.current += 1
setSelectedDatasetId(null)
setSelectedDataset(null)
setSelectedDatasetSummary(null)
setSelectedRasterMetadata(null)
setSelectedRasterStats(null)
setDatasetContent(null)
setRasterPreview(null)
setLatestRasterTileManifestPath('')
setLatestRasterTileManifest(null)
setJobs([])
setLoadingDatasetDetails(false)
setDatasetDetailError(null)
}
return {
selectedDatasetId,
selectedDataset,
selectedDatasetSummary,
selectedRasterMetadata,
selectedRasterStats,
datasetContent,
jobs,
rasterPreview,
latestRasterTileManifestPath,
latestRasterTileManifest,
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,
}
}