Extract frontend orchestration hooks
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 07:10:39 +02:00
parent ced2a0affa
commit 87d0d84bd8
9 changed files with 351 additions and 130 deletions
+54 -129
View File
@@ -3,7 +3,7 @@ import './styles/app.css'
import { areasApi } from './services/api/areas'
import { datasetsApi } from './services/api/datasets'
import { projectsApi } from './services/api/projects'
import { analysisApi, demoApi, externalApi } from './services/api'
import { demoApi } from './services/api'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
import { DatasetPanel } from './components/datasets/DatasetPanel'
@@ -16,8 +16,6 @@ import { ProjectPanel } from './components/project/ProjectPanel'
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
import type {
ApiError,
ChangeDetectionSummary,
DatasetCreateResponse,
DatasetListResponse,
ProjectRead,
@@ -25,13 +23,15 @@ import type {
AreaCreate,
AreaListResponse,
AreaRead,
ProviderCapability,
} from './types'
import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
import { useExportWorkflow } from './hooks/useExportWorkflow'
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { formatError } from './lib/formatError'
@@ -45,22 +45,6 @@ function App(): JSX.Element {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [areas, setAreas] = useState<AreaRead[]>([])
const [datasets, setDatasets] = useState<DatasetCreateResponse[]>([])
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 [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null)
const [changeSourceDatasetId, setChangeSourceDatasetId] = useState('')
const [changeTargetDatasetId, setChangeTargetDatasetId] = useState('')
const [changeIouThreshold, setChangeIouThreshold] = useState(0.8)
const [changeIncludeUnchanged, setChangeIncludeUnchanged] = useState(true)
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | null>(null)
const [changeDetectionError, setChangeDetectionError] = useState<string | null>(null)
const [loadingProjects, setLoadingProjects] = useState(false)
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
@@ -171,8 +155,31 @@ function App(): JSX.Element {
[availableVectorDatasets],
)
const candidateDatasets = availableVectorDatasets
const providers = useMemo(() => providerCapabilities, [providerCapabilities])
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
const {
providers,
loadingCapabilities,
capabilitiesError,
loadCapabilities,
} = useProviderCapabilities()
const {
changeSourceDatasetId,
changeTargetDatasetId,
changeIouThreshold,
changeIncludeUnchanged,
runningChangeDetection,
changeDetectionResult,
changeDetectionError,
runChangeDetection,
setChangeSourceDatasetId,
setChangeTargetDatasetId,
setChangeIouThreshold,
setChangeIncludeUnchanged,
} = useChangeDetectionWorkflow({
selectedProjectId,
availableVectorDatasets,
loadDatasetJobs,
})
const {
qaCandidateDatasetId,
qaReferenceDatasetId,
@@ -301,52 +308,32 @@ function App(): JSX.Element {
selectedSegmentationRunId,
isVectorDatasetType,
})
const selectedMapArea = useMemo(
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
[areas, selectedMapAreaId],
)
const areaFeatureCollection = useMemo<GeoJSON.FeatureCollection | null>(() => {
if (!selectedMapArea?.geometry) {
return null
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: selectedMapArea.geometry,
properties: {
layer_type: 'project_area',
area_id: selectedMapArea.id,
name: selectedMapArea.name,
area_m2: selectedMapArea.area_m2 ?? null,
original_crs: selectedMapArea.original_crs ?? null,
},
},
],
}
}, [selectedMapArea])
const mapFeatureCollection = useMemo(
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
[changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent],
)
const mapLayerLabel = useMemo(() => {
if (changeDetectionResult) {
return 'Change detection result'
}
if (segmentationGeoJson) {
return 'Segmentation result'
}
if (detectionGeoJson) {
return 'Detection result'
}
if (datasetContent && selectedDataset) {
return selectedDataset.name
}
return 'No active vector layer'
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
const {
mapLayerVisible,
mapLayerOpacity,
selectedMapAreaId,
areaLayerVisible,
areaLayerOpacity,
selectedMapFeature,
areaFeatureCollection,
mapFeatureCollection,
mapLayerLabel,
mapFeatureCount,
areaFeatureCount,
setMapLayerVisible,
setMapLayerOpacity,
setSelectedMapAreaId,
setAreaLayerVisible,
setAreaLayerOpacity,
setSelectedMapFeature,
} = useMapWorkspaceState({
areas,
changeDetectionGeoJson: changeDetectionResult?.geojson ?? null,
segmentationGeoJson,
detectionGeoJson,
datasetContent,
selectedDataset,
})
const loadProjects = async () => {
setLoadingProjects(true)
setErrorMessage(null)
@@ -394,19 +381,6 @@ function App(): JSX.Element {
}
}
const loadCapabilities = async () => {
setLoadingCapabilities(true)
setCapabilitiesError(null)
try {
const providerResponse = await externalApi.listProviders()
setProviderCapabilities(providerResponse.providers)
} catch (error) {
setCapabilitiesError(error instanceof Error ? error.message : 'Failed to load external capabilities')
} finally {
setLoadingCapabilities(false)
}
}
useEffect(() => {
loadProjects().catch(() => null)
loadCapabilities().catch(() => null)
@@ -414,10 +388,6 @@ function App(): JSX.Element {
loadSegmentationModels().catch(() => null)
}, [])
useEffect(() => {
setSelectedMapFeature(null)
}, [mapFeatureCollection, areaFeatureCollection])
useEffect(() => {
if (!selectedProjectId) {
setAreas([])
@@ -524,51 +494,6 @@ function App(): JSX.Element {
}
}
const runChangeDetection = async () => {
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
const targetDatasetId =
changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id
if (!sourceDatasetId || !targetDatasetId) {
setChangeDetectionError('Select two vector datasets')
return
}
if (sourceDatasetId === targetDatasetId) {
setChangeDetectionError('Source and target datasets must differ')
return
}
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
setChangeDetectionError('IoU threshold must be between 0 and 1')
return
}
setChangeDetectionError(null)
setChangeDetectionResult(null)
setRunningChangeDetection(true)
try {
const job = await analysisApi.runChangeDetection({
source_dataset_id: sourceDatasetId,
target_dataset_id: targetDatasetId,
iou_threshold: changeIouThreshold,
include_unchanged: changeIncludeUnchanged,
})
if (job.status !== 'success') {
throw new Error(job.error_message || 'Change detection job failed')
}
if (!job.result_json) {
throw new Error('Change detection completed without result payload')
}
setChangeSourceDatasetId(sourceDatasetId)
setChangeTargetDatasetId(targetDatasetId)
setChangeDetectionResult(job.result_json)
if (selectedProjectId) {
await loadDatasetJobs(selectedProjectId, sourceDatasetId)
}
} catch (error) {
setChangeDetectionError(formatError(error, 'Change detection failed'))
} finally {
setRunningChangeDetection(false)
}
}
return (
<div className="app-shell">
<header>
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { analysisApi } from '../services/api'
import type { ChangeDetectionSummary, DatasetCreateResponse } from '../types'
import { formatError } from '../lib/formatError'
interface ChangeDetectionWorkflowOptions {
selectedProjectId: string | null
availableVectorDatasets: DatasetCreateResponse[]
loadDatasetJobs: (projectId: string, datasetId: string) => Promise<void>
}
export function useChangeDetectionWorkflow({
selectedProjectId,
availableVectorDatasets,
loadDatasetJobs,
}: ChangeDetectionWorkflowOptions) {
const [changeSourceDatasetId, setChangeSourceDatasetId] = useState('')
const [changeTargetDatasetId, setChangeTargetDatasetId] = useState('')
const [changeIouThreshold, setChangeIouThreshold] = useState(0.8)
const [changeIncludeUnchanged, setChangeIncludeUnchanged] = useState(true)
const [runningChangeDetection, setRunningChangeDetection] = useState(false)
const [changeDetectionResult, setChangeDetectionResult] = useState<ChangeDetectionSummary | null>(null)
const [changeDetectionError, setChangeDetectionError] = useState<string | null>(null)
const runChangeDetection = async () => {
const sourceDatasetId = changeSourceDatasetId || availableVectorDatasets[0]?.id
const targetDatasetId =
changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id
if (!sourceDatasetId || !targetDatasetId) {
setChangeDetectionError('Select two vector datasets')
return
}
if (sourceDatasetId === targetDatasetId) {
setChangeDetectionError('Source and target datasets must differ')
return
}
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
setChangeDetectionError('IoU threshold must be between 0 and 1')
return
}
setChangeDetectionError(null)
setChangeDetectionResult(null)
setRunningChangeDetection(true)
try {
const job = await analysisApi.runChangeDetection({
source_dataset_id: sourceDatasetId,
target_dataset_id: targetDatasetId,
iou_threshold: changeIouThreshold,
include_unchanged: changeIncludeUnchanged,
})
if (job.status !== 'success') {
throw new Error(job.error_message || 'Change detection job failed')
}
if (!job.result_json) {
throw new Error('Change detection completed without result payload')
}
setChangeSourceDatasetId(sourceDatasetId)
setChangeTargetDatasetId(targetDatasetId)
setChangeDetectionResult(job.result_json)
if (selectedProjectId) {
await loadDatasetJobs(selectedProjectId, sourceDatasetId)
}
} catch (error) {
setChangeDetectionError(formatError(error, 'Change detection failed'))
} finally {
setRunningChangeDetection(false)
}
}
return {
changeSourceDatasetId,
changeTargetDatasetId,
changeIouThreshold,
changeIncludeUnchanged,
runningChangeDetection,
changeDetectionResult,
changeDetectionError,
runChangeDetection,
setChangeSourceDatasetId,
setChangeTargetDatasetId,
setChangeIouThreshold,
setChangeIncludeUnchanged,
}
}
@@ -0,0 +1,98 @@
import { useEffect, useMemo, useState } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../types'
interface MapWorkspaceStateOptions {
areas: AreaRead[]
changeDetectionGeoJson: GeoJSON.FeatureCollection | null
segmentationGeoJson: GeoJSON.FeatureCollection | null
detectionGeoJson: GeoJSON.FeatureCollection | null
datasetContent: GeoJSON.FeatureCollection | null
selectedDataset: DatasetCreateResponse | null
}
export function useMapWorkspaceState({
areas,
changeDetectionGeoJson,
segmentationGeoJson,
detectionGeoJson,
datasetContent,
selectedDataset,
}: MapWorkspaceStateOptions) {
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 selectedMapArea = useMemo(
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
[areas, selectedMapAreaId],
)
const areaFeatureCollection = useMemo<GeoJSON.FeatureCollection | null>(() => {
if (!selectedMapArea?.geometry) {
return null
}
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: selectedMapArea.geometry,
properties: {
layer_type: 'project_area',
area_id: selectedMapArea.id,
name: selectedMapArea.name,
area_m2: selectedMapArea.area_m2 ?? null,
original_crs: selectedMapArea.original_crs ?? null,
},
},
],
}
}, [selectedMapArea])
const mapFeatureCollection = useMemo(
() => changeDetectionGeoJson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
[changeDetectionGeoJson, segmentationGeoJson, detectionGeoJson, datasetContent],
)
const mapLayerLabel = useMemo(() => {
if (changeDetectionGeoJson) {
return 'Change detection result'
}
if (segmentationGeoJson) {
return 'Segmentation result'
}
if (detectionGeoJson) {
return 'Detection result'
}
if (datasetContent && selectedDataset) {
return selectedDataset.name
}
return 'No active vector layer'
}, [changeDetectionGeoJson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
useEffect(() => {
setSelectedMapFeature(null)
}, [mapFeatureCollection, areaFeatureCollection])
return {
mapLayerVisible,
mapLayerOpacity,
selectedMapAreaId,
areaLayerVisible,
areaLayerOpacity,
selectedMapFeature,
areaFeatureCollection,
mapFeatureCollection,
mapLayerLabel,
mapFeatureCount,
areaFeatureCount,
setMapLayerVisible,
setMapLayerOpacity,
setSelectedMapAreaId,
setAreaLayerVisible,
setAreaLayerOpacity,
setSelectedMapFeature,
}
}
@@ -0,0 +1,31 @@
import { useMemo, useState } from 'react'
import { externalApi } from '../services/api'
import type { ProviderCapability } from '../types'
export function useProviderCapabilities() {
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null)
const providers = useMemo(() => providerCapabilities, [providerCapabilities])
const loadCapabilities = async () => {
setLoadingCapabilities(true)
setCapabilitiesError(null)
try {
const providerResponse = await externalApi.listProviders()
setProviderCapabilities(providerResponse.providers)
} catch (error) {
setCapabilitiesError(error instanceof Error ? error.message : 'Failed to load external capabilities')
} finally {
setLoadingCapabilities(false)
}
}
return {
providers,
loadingCapabilities,
capabilitiesError,
loadCapabilities,
}
}