Extract frontend orchestration hooks
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 39 Frontend orchestration decomposition (2026-06-17)
|
||||||
|
|
||||||
|
- Moved provider capability loading from `App.tsx` into `frontend/src/hooks/useProviderCapabilities.ts`.
|
||||||
|
- Moved change detection state and API orchestration into `frontend/src/hooks/useChangeDetectionWorkflow.ts`.
|
||||||
|
- Moved derived MapLibre workbench state, feature collection selection and feature-inspector reset behavior into `frontend/src/hooks/useMapWorkspaceState.ts`.
|
||||||
|
- Added regression tests that keep provider/change/map orchestration out of `App.tsx`.
|
||||||
|
- No API contracts, migrations, product features, provider fetching or AI behavior were introduced.
|
||||||
|
|
||||||
## Sprint 38 Export Center preview hardening (2026-06-17)
|
## Sprint 38 Export Center preview hardening (2026-06-17)
|
||||||
|
|
||||||
- Prevented HTML project report artifacts from being offered through the JSON preview path in the frontend Export Center.
|
- Prevented HTML project report artifacts from being offered through the JSON preview path in the frontend Export Center.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_uses_shared_orchestration_hooks() -> None:
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "useProviderCapabilities" in app
|
||||||
|
assert "useChangeDetectionWorkflow" in app
|
||||||
|
assert "useMapWorkspaceState" in app
|
||||||
|
assert "from './hooks/useProviderCapabilities'" in app
|
||||||
|
assert "from './hooks/useChangeDetectionWorkflow'" in app
|
||||||
|
assert "from './hooks/useMapWorkspaceState'" in app
|
||||||
|
assert "analysisApi" not in app
|
||||||
|
assert "externalApi" not in app
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_capabilities_hook_owns_provider_api_calls() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useProviderCapabilities.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "externalApi.listProviders" in hook
|
||||||
|
assert "loadCapabilities" in hook
|
||||||
|
assert "loadingCapabilities" in hook
|
||||||
|
assert "capabilitiesError" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_detection_hook_owns_change_detection_api_calls() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useChangeDetectionWorkflow.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "analysisApi.runChangeDetection" in hook
|
||||||
|
assert "runChangeDetection" in hook
|
||||||
|
assert "changeDetectionResult" in hook
|
||||||
|
assert "loadDatasetJobs" in hook
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_workspace_state_hook_owns_derived_map_state() -> None:
|
||||||
|
hook = (ROOT / "frontend" / "src" / "hooks" / "useMapWorkspaceState.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "areaFeatureCollection" in hook
|
||||||
|
assert "mapFeatureCollection" in hook
|
||||||
|
assert "mapLayerLabel" in hook
|
||||||
|
assert "setSelectedMapFeature(null)" in hook
|
||||||
|
|
||||||
@@ -1,3 +1,27 @@
|
|||||||
|
## Sprint 39 Frontend orchestration decomposition (2026-06-17)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Moved provider capability loading state and `externalApi.listProviders` calls into `frontend/src/hooks/useProviderCapabilities.ts`.
|
||||||
|
- Moved change-detection state, validation and `analysisApi.runChangeDetection` calls into `frontend/src/hooks/useChangeDetectionWorkflow.ts`.
|
||||||
|
- Moved map-layer derived state, area GeoJSON feature construction and selected-feature reset behavior into `frontend/src/hooks/useMapWorkspaceState.ts`.
|
||||||
|
- Kept `App.tsx` as the cross-module composition layer without changing panel props, API contracts, migrations or product behavior.
|
||||||
|
- Added static regression tests for the extracted orchestration hooks.
|
||||||
|
- Updated frontend README, changelog and TODO status.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `cd backend && python -m pytest tests/test_sprint39_frontend_orchestration_hooks.py -q` passed: 4 tests.
|
||||||
|
- `cd frontend && npm run typecheck` passed.
|
||||||
|
- `cd frontend && npm run build` passed.
|
||||||
|
|
||||||
|
Open:
|
||||||
|
- Full readiness and Tower deploy smoke should run before considering this pass deployed.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- Project/area/dataset cross-load orchestration still lives in `App.tsx`; it is a good next low-risk decomposition target.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Run full readiness and Tower deploy verification, then extract project/area loading into a dedicated hook if behavior remains stable.
|
||||||
|
|
||||||
## Sprint 38 Export Center preview hardening (2026-06-17)
|
## Sprint 38 Export Center preview hardening (2026-06-17)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid.
|
- [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid.
|
||||||
- [x] Single-container `geointel` Unraid compose/template runtime.
|
- [x] Single-container `geointel` Unraid compose/template runtime.
|
||||||
- [x] Export preview component decomposition and HTML report download-only UX hardening.
|
- [x] Export preview component decomposition and HTML report download-only UX hardening.
|
||||||
- [ ] Further frontend shared workbench orchestration decomposition.
|
- [x] Provider, change-detection and map-workspace orchestration hook decomposition.
|
||||||
|
- [ ] Further project/area/dataset cross-load orchestration decomposition.
|
||||||
|
|
||||||
## Sprint 8 status
|
## Sprint 8 status
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
|||||||
- The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build.
|
- The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build.
|
||||||
- Export preview rendering lives in `src/components/exports/ExportPreview.tsx`.
|
- Export preview rendering lives in `src/components/exports/ExportPreview.tsx`.
|
||||||
- The Export Center only offers JSON preview for JSON/GeoJSON artifacts; HTML project reports are shown as download-only artifacts.
|
- The Export Center only offers JSON preview for JSON/GeoJSON artifacts; HTML project reports are shown as download-only artifacts.
|
||||||
|
- Provider capability loading lives in `src/hooks/useProviderCapabilities.ts`.
|
||||||
|
- Change detection orchestration lives in `src/hooks/useChangeDetectionWorkflow.ts`.
|
||||||
|
- Derived MapLibre workbench state lives in `src/hooks/useMapWorkspaceState.ts`.
|
||||||
|
|
||||||
## Raster dependency visibility
|
## Raster dependency visibility
|
||||||
|
|
||||||
|
|||||||
+54
-129
@@ -3,7 +3,7 @@ import './styles/app.css'
|
|||||||
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, externalApi } from './services/api'
|
import { demoApi } from './services/api'
|
||||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||||
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
|
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
|
||||||
import { DatasetPanel } from './components/datasets/DatasetPanel'
|
import { DatasetPanel } from './components/datasets/DatasetPanel'
|
||||||
@@ -16,8 +16,6 @@ import { ProjectPanel } from './components/project/ProjectPanel'
|
|||||||
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
|
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
|
||||||
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
|
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
|
||||||
import type {
|
import type {
|
||||||
ApiError,
|
|
||||||
ChangeDetectionSummary,
|
|
||||||
DatasetCreateResponse,
|
DatasetCreateResponse,
|
||||||
DatasetListResponse,
|
DatasetListResponse,
|
||||||
ProjectRead,
|
ProjectRead,
|
||||||
@@ -25,13 +23,15 @@ import type {
|
|||||||
AreaCreate,
|
AreaCreate,
|
||||||
AreaListResponse,
|
AreaListResponse,
|
||||||
AreaRead,
|
AreaRead,
|
||||||
ProviderCapability,
|
|
||||||
} from './types'
|
} from './types'
|
||||||
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 { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
|
||||||
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
||||||
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
||||||
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
||||||
|
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
||||||
|
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
||||||
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||||
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
|
||||||
import { formatError } from './lib/formatError'
|
import { formatError } from './lib/formatError'
|
||||||
@@ -45,22 +45,6 @@ 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 [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 [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)
|
||||||
@@ -171,8 +155,31 @@ function App(): JSX.Element {
|
|||||||
[availableVectorDatasets],
|
[availableVectorDatasets],
|
||||||
)
|
)
|
||||||
const candidateDatasets = availableVectorDatasets
|
const candidateDatasets = availableVectorDatasets
|
||||||
const providers = useMemo(() => providerCapabilities, [providerCapabilities])
|
|
||||||
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
|
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 {
|
const {
|
||||||
qaCandidateDatasetId,
|
qaCandidateDatasetId,
|
||||||
qaReferenceDatasetId,
|
qaReferenceDatasetId,
|
||||||
@@ -301,52 +308,32 @@ function App(): JSX.Element {
|
|||||||
selectedSegmentationRunId,
|
selectedSegmentationRunId,
|
||||||
isVectorDatasetType,
|
isVectorDatasetType,
|
||||||
})
|
})
|
||||||
const selectedMapArea = useMemo(
|
const {
|
||||||
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
|
mapLayerVisible,
|
||||||
[areas, selectedMapAreaId],
|
mapLayerOpacity,
|
||||||
)
|
selectedMapAreaId,
|
||||||
const areaFeatureCollection = useMemo<GeoJSON.FeatureCollection | null>(() => {
|
areaLayerVisible,
|
||||||
if (!selectedMapArea?.geometry) {
|
areaLayerOpacity,
|
||||||
return null
|
selectedMapFeature,
|
||||||
}
|
areaFeatureCollection,
|
||||||
return {
|
mapFeatureCollection,
|
||||||
type: 'FeatureCollection',
|
mapLayerLabel,
|
||||||
features: [
|
mapFeatureCount,
|
||||||
{
|
areaFeatureCount,
|
||||||
type: 'Feature',
|
setMapLayerVisible,
|
||||||
geometry: selectedMapArea.geometry,
|
setMapLayerOpacity,
|
||||||
properties: {
|
setSelectedMapAreaId,
|
||||||
layer_type: 'project_area',
|
setAreaLayerVisible,
|
||||||
area_id: selectedMapArea.id,
|
setAreaLayerOpacity,
|
||||||
name: selectedMapArea.name,
|
setSelectedMapFeature,
|
||||||
area_m2: selectedMapArea.area_m2 ?? null,
|
} = useMapWorkspaceState({
|
||||||
original_crs: selectedMapArea.original_crs ?? null,
|
areas,
|
||||||
},
|
changeDetectionGeoJson: changeDetectionResult?.geojson ?? null,
|
||||||
},
|
segmentationGeoJson,
|
||||||
],
|
detectionGeoJson,
|
||||||
}
|
datasetContent,
|
||||||
}, [selectedMapArea])
|
selectedDataset,
|
||||||
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 loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
setLoadingProjects(true)
|
setLoadingProjects(true)
|
||||||
setErrorMessage(null)
|
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(() => {
|
useEffect(() => {
|
||||||
loadProjects().catch(() => null)
|
loadProjects().catch(() => null)
|
||||||
loadCapabilities().catch(() => null)
|
loadCapabilities().catch(() => null)
|
||||||
@@ -414,10 +388,6 @@ function App(): JSX.Element {
|
|||||||
loadSegmentationModels().catch(() => null)
|
loadSegmentationModels().catch(() => null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedMapFeature(null)
|
|
||||||
}, [mapFeatureCollection, areaFeatureCollection])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
setAreas([])
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header>
|
<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user