feat(ui): refine governed workbench and dual-screen flows

This commit is contained in:
Jens
2026-08-30 06:00:28 +02:00
parent 80a2d1654d
commit a0884d64c9
48 changed files with 1360 additions and 184 deletions
@@ -46,7 +46,7 @@ export function useChangeDetectionWorkflow({
return
}
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
setChangeDetectionError('IoU threshold must be between 0 and 1')
setChangeDetectionError('De IoU-drempel moet tussen 0 en 1 liggen.')
return
}
setChangeDetectionError(null)
@@ -63,10 +63,10 @@ export function useChangeDetectionWorkflow({
...(selection.areaId ? { area_id: selection.areaId } : {}),
})
if (job.status !== 'success') {
throw new Error(job.error_message || 'Change detection job failed')
throw new Error(job.error_message || 'De wijzigingsanalyse is mislukt.')
}
if (!job.result_json) {
throw new Error('Change detection completed without result payload')
throw new Error('De wijzigingsanalyse is afgerond zonder resultaat.')
}
setChangeSourceDatasetId(sourceDatasetId)
setChangeTargetDatasetId(targetDatasetId)
+6 -6
View File
@@ -218,7 +218,7 @@ export function useDatasetWorkflow({
await loadDatasetJobs(projectId, dataset.id, detailRequestId)
} catch (error) {
if (detailRequestId === datasetDetailRequestSequence.current) {
setDatasetDetailError(formatError(error, 'Unable to load dataset detail'))
setDatasetDetailError(formatError(error, 'De datasetdetails konden niet worden geladen.'))
}
} finally {
if (detailRequestId === datasetDetailRequestSequence.current) {
@@ -245,11 +245,11 @@ export function useDatasetWorkflow({
try {
const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson)
if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') {
setErrorMessage('Source metadata must be a JSON object')
setErrorMessage('Bronmetadata moet een JSON-object zijn.')
return
}
} catch {
setErrorMessage('Source metadata must be valid JSON')
setErrorMessage('Bronmetadata moet geldige JSON zijn.')
return
}
}
@@ -257,11 +257,11 @@ export function useDatasetWorkflow({
try {
const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson)
if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') {
setErrorMessage('Provenance metadata must be a JSON object')
setErrorMessage('Provenancemetadata moet een JSON-object zijn.')
return
}
} catch {
setErrorMessage('Provenance metadata must be valid JSON')
setErrorMessage('Provenancemetadata moet geldige JSON zijn.')
return
}
}
@@ -401,7 +401,7 @@ export function useDatasetWorkflow({
}
const targetCrs = rasterReprojectCrs.trim()
if (!targetCrs) {
setDatasetDetailError('Target CRS is required for raster reproject')
setDatasetDetailError('Voor rasterherprojectie is een doel-CRS vereist.')
return
}
try {
+6 -12
View File
@@ -13,6 +13,7 @@ import type {
YoloPreflightResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import { rasterTileCount } from '../lib/rasterTiling'
import {
analysisRunIdFromJob,
completedDetectionResponse,
@@ -84,16 +85,6 @@ function tileManifestPathFromJob(job: JobRead): string | null {
return typeof manifestPath === 'string' && manifestPath.trim().length > 0 ? manifestPath.trim() : null
}
function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, overlap: number): number | null {
const width = metadata.width
const height = metadata.height
if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {
return null
}
const step = tileSize - overlap
return Math.ceil(width / step) * Math.ceil(height / step)
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
@@ -507,11 +498,14 @@ export function useDetectionWorkflow({
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertProjectCurrent()
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256
const maxTiles = yoloPreflight?.max_tiles
if (expectedTileCount === null) {
throw new Error('De afmetingen van het luchtbeeld konden niet veilig worden bepaald')
}
if (expectedTileCount > maxTiles) {
if (!Number.isInteger(maxTiles) || (maxTiles ?? 0) <= 0) {
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
}
if (expectedTileCount > maxTiles!) {
throw new Error(
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
@@ -107,7 +107,7 @@ describe('a thrown error', () => {
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toBe('Full GIS workflow failed.')
expect(view.result.current.error).toBe('De volledige GIS-werkstroom is mislukt.')
})
})
+1 -1
View File
@@ -16,7 +16,7 @@ export type FullWorkflowMode = 'new' | 'reuse'
export const IDLE_STATUS = 'Klaar om de volledige GIS-werkstroom uit te voeren.'
const STOPPED_STATUS = 'De werkstroom is gestopt.'
const GENERIC_FAILURE = 'Full GIS workflow failed.'
const GENERIC_FAILURE = 'De volledige GIS-werkstroom is mislukt.'
interface FullGisWorkflowOptions {
selectedDataset: DatasetCreateResponse | null | undefined
+1 -1
View File
@@ -36,7 +36,7 @@ export function useMapSelectionQa({
return null
}
if (candidateDataset.id === selectedMapQaReferenceDatasetId) {
setMapSelectionQaError('Candidate and reference datasets must be different.')
setMapSelectionQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
return null
}
+4 -4
View File
@@ -75,11 +75,11 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
return
}
if (qaCandidateDatasetId === qaReferenceDatasetId) {
setQaError('Candidate and reference datasets must be different')
setQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
return
}
if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) {
setQaError('IoU threshold must be between 0 and 1')
setQaError('De IoU-drempel moet tussen 0 en 1 liggen.')
return
}
setQaError(null)
@@ -94,7 +94,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
}
const job: JobRead = await qaApi.runQa(selectedProjectId, request)
if (job.status === 'failed') {
setQaError(job.error_message || 'QA comparison failed')
setQaError(job.error_message || 'De kwaliteitsvergelijking is mislukt.')
return
}
const payload = job.result_json
@@ -113,7 +113,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
await loadProjectData(selectedProjectId)
}
} catch (error) {
setQaError(error instanceof Error ? error.message : 'QA comparison failed')
setQaError(error instanceof Error ? error.message : 'De kwaliteitsvergelijking is mislukt.')
} finally {
setQaRunning(false)
}
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
const mocks = vi.hoisted(() => ({
rasterInspect: vi.fn(),
rasterTile: vi.fn(),
listModels: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
@@ -13,6 +15,10 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('../services/api', () => ({
datasetsApi: {
rasterInspect: mocks.rasterInspect,
rasterTile: mocks.rasterTile,
},
segmentationApi: {
listModels: mocks.listModels,
runAsync: mocks.runAsync,
@@ -60,6 +66,7 @@ function renderWorkflow(selectedProjectId = projectId) {
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
maxInferenceTiles: 100,
loadProjectData,
loadQualityChecks,
}))
@@ -87,6 +94,20 @@ describe('useSegmentationWorkflow GPU execution', () => {
mocks.getRun.mockResolvedValue(persistedRun)
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
mocks.rasterInspect.mockResolvedValue({
dataset_id: datasetId,
ready: true,
metadata: { width: 512, height: 512 },
})
mocks.rasterTile.mockResolvedValue({
id: 'tile-job-1',
job_type: 'raster.tile',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { manifest_path: '/tiles/generated-manifest.json' },
})
})
it('queues, follows and reconciles a persisted segmentation result', async () => {
@@ -118,15 +139,41 @@ describe('useSegmentationWorkflow GPU execution', () => {
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('does not queue a configured model without a tile manifest', async () => {
it('prepares a server manifest before queueing when no manifest is supplied', async () => {
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.rasterInspect).toHaveBeenCalledWith(projectId, datasetId)
expect(mocks.rasterTile).toHaveBeenCalledWith(projectId, datasetId, {
tile_size: 512,
overlap: 64,
})
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
tile_manifest_path: '/tiles/generated-manifest.json',
}))
expect(result.current.segmentationTileManifestPath).toBe('/tiles/generated-manifest.json')
expect(result.current.segmentationRunError).toBeNull()
})
it('uses the backend-reported inference limit and refuses tiling before writes', async () => {
mocks.rasterInspect.mockResolvedValueOnce({
dataset_id: datasetId,
ready: true,
metadata: { width: 5376, height: 4480 },
})
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.rasterTile).not.toHaveBeenCalled()
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
expect(result.current.segmentationRunError).toContain('120 beeldtegels')
expect(result.current.segmentationRunError).toContain('maximum is 100')
})
it('ignores a late run list after the active project changes', async () => {
@@ -142,6 +189,7 @@ describe('useSegmentationWorkflow GPU execution', () => {
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
maxInferenceTiles: 100,
loadProjectData,
loadQualityChecks,
}),
@@ -185,6 +233,7 @@ describe('useSegmentationWorkflow GPU execution', () => {
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
maxInferenceTiles: 100,
loadProjectData,
loadQualityChecks,
}),
+52 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { segmentationApi } from '../services/api'
import { datasetsApi, segmentationApi } from '../services/api'
import type {
DatasetCreateResponse,
JobRead,
@@ -11,6 +11,7 @@ import type {
SegmentationRunResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import { rasterTileCount } from '../lib/rasterTiling'
import {
analysisRunIdFromSegmentationJob,
completedSegmentationResponse,
@@ -18,10 +19,19 @@ import {
waitForSegmentationJob,
} from '../services/segmentationJob'
const SEGMENTATION_TILE_SIZE = 512
const SEGMENTATION_TILE_OVERLAP = 64
function tileManifestPathFromJob(job: JobRead): string | null {
const manifestPath = job.result_json?.manifest_path
return typeof manifestPath === 'string' && manifestPath.trim() ? manifestPath.trim() : null
}
interface SegmentationWorkflowOptions {
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
qaIouThreshold: number
maxInferenceTiles: number | null
loadProjectData: (projectId: string) => Promise<unknown>
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
}
@@ -40,6 +50,7 @@ export function useSegmentationWorkflow({
selectedProjectId,
rasterDatasets,
qaIouThreshold,
maxInferenceTiles,
loadProjectData,
loadQualityChecks,
}: SegmentationWorkflowOptions) {
@@ -237,10 +248,6 @@ export function useSegmentationWorkflow({
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
return
}
if (!segmentationTileManifestPath.trim()) {
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
return
}
if (
(activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|| segmentationJob?.status === 'queued'
@@ -251,15 +258,6 @@ export function useSegmentationWorkflow({
}
const projectId = selectedProjectId
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters,
}
const controller = new AbortController()
const executionSequence = segmentationExecutionSequence.current + 1
segmentationExecutionSequence.current = executionSequence
@@ -279,6 +277,46 @@ export function useSegmentationWorkflow({
setRunningSegmentation(true)
setSegmentationJob(null)
try {
let manifestPath = segmentationTileManifestPath.trim()
if (!manifestPath) {
if (!Number.isInteger(maxInferenceTiles) || (maxInferenceTiles ?? 0) <= 0) {
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
}
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertExecutionCurrent()
const expectedTileCount = rasterTileCount(
inspection.metadata,
SEGMENTATION_TILE_SIZE,
SEGMENTATION_TILE_OVERLAP,
)
if (expectedTileCount === null) {
throw new Error('De afmetingen van het rasterbestand konden niet veilig worden bepaald')
}
if (expectedTileCount > maxInferenceTiles!) {
throw new Error(
`Dit rasterbestand zou ${expectedTileCount} beeldtegels maken; het door de server gemelde maximum is ${maxInferenceTiles}. Knip het raster eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
tile_size: SEGMENTATION_TILE_SIZE,
overlap: SEGMENTATION_TILE_OVERLAP,
})
assertExecutionCurrent()
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
}
setSegmentationTileManifestPath(manifestPath)
}
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: parameters,
}
const queuedJob = await segmentationApi.runAsync(request)
assertExecutionCurrent()
setSegmentationJob(queuedJob)