Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -23,4 +23,45 @@ describe('AiPipelineIllustration', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Berekening/ }))
|
||||
expect(screen.getByRole('tabpanel').textContent).toContain('De herkenning draait lokaal')
|
||||
})
|
||||
|
||||
it('moves selection and focus through the tablist with keyboard controls', () => {
|
||||
render(
|
||||
<AiPipelineIllustration
|
||||
hasImagery
|
||||
hasTiles
|
||||
gpuReady
|
||||
hasDetections={false}
|
||||
hasQualityEvidence={false}
|
||||
running={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tabs = screen.getAllByRole('tab') as HTMLButtonElement[]
|
||||
const selectedTab = screen.getByRole('tab', { name: /Detecties/ }) as HTMLButtonElement
|
||||
const panel = screen.getByRole('tabpanel')
|
||||
|
||||
expect(selectedTab.tabIndex).toBe(0)
|
||||
expect(tabs.filter((tab) => tab.tabIndex === 0)).toHaveLength(1)
|
||||
expect(selectedTab.getAttribute('aria-controls')).toBe(panel.id)
|
||||
expect(panel.getAttribute('aria-labelledby')).toBe(selectedTab.id)
|
||||
|
||||
selectedTab.focus()
|
||||
fireEvent.keyDown(selectedTab, { key: 'ArrowRight' })
|
||||
expect(screen.getByRole('tab', { name: /QA-bewijs/ }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
|
||||
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
|
||||
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
|
||||
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
|
||||
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowLeft' })
|
||||
const wrappedTab = screen.getByRole('tab', { name: /QA-bewijs/ })
|
||||
expect(document.activeElement).toBe(wrappedTab)
|
||||
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(wrappedTab.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useId, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react'
|
||||
|
||||
interface AiPipelineIllustrationProps {
|
||||
@@ -29,14 +29,47 @@ export function AiPipelineIllustration({
|
||||
const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence]
|
||||
const firstIncomplete = readiness.findIndex((ready) => !ready)
|
||||
const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete)
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const componentId = useId()
|
||||
const titleId = `${componentId}-title`
|
||||
const panelId = `${componentId}-panel`
|
||||
const selected = pipelineStages[selectedIndex]
|
||||
|
||||
const selectAndFocus = (index: number) => {
|
||||
setSelectedIndex(index)
|
||||
tabRefs.current[index]?.focus()
|
||||
}
|
||||
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
let nextIndex: number | null = null
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
nextIndex = (index + 1) % pipelineStages.length
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
nextIndex = (index - 1 + pipelineStages.length) % pipelineStages.length
|
||||
break
|
||||
case 'Home':
|
||||
nextIndex = 0
|
||||
break
|
||||
case 'End':
|
||||
nextIndex = pipelineStages.length - 1
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
selectAndFocus(nextIndex)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby="ai-pipeline-title">
|
||||
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby={titleId}>
|
||||
<div className="ai-pipeline-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Van pixel naar bewijs</p>
|
||||
<h3 id="ai-pipeline-title">Van luchtbeeld naar controleerbare detectie</h3>
|
||||
<h3 id={titleId}>Van luchtbeeld naar controleerbare detectie</h3>
|
||||
<p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p>
|
||||
</div>
|
||||
<span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}>
|
||||
@@ -49,13 +82,16 @@ export function AiPipelineIllustration({
|
||||
{pipelineStages.map(({ key, label, icon: Icon }, index) => (
|
||||
<button
|
||||
key={key}
|
||||
id={`ai-pipeline-${key}`}
|
||||
id={`${componentId}-${key}`}
|
||||
ref={(element) => { tabRefs.current[index] = element }}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selectedIndex === index}
|
||||
aria-controls="ai-pipeline-detail"
|
||||
aria-controls={panelId}
|
||||
tabIndex={selectedIndex === index ? 0 : -1}
|
||||
className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
onKeyDown={(event) => handleTabKeyDown(event, index)}
|
||||
>
|
||||
<span><Icon aria-hidden="true" /></span>
|
||||
<strong>{label}</strong>
|
||||
@@ -65,10 +101,11 @@ export function AiPipelineIllustration({
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="ai-pipeline-detail"
|
||||
id={panelId}
|
||||
className="ai-pipeline-detail"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`ai-pipeline-${selected.key}`}
|
||||
aria-labelledby={`${componentId}-${selected.key}`}
|
||||
tabIndex={0}
|
||||
key={selected.key}
|
||||
>
|
||||
<span>{String(selectedIndex + 1).padStart(2, '0')}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DetectionRead,
|
||||
DetectionRunRead,
|
||||
DetectionRunResponse,
|
||||
JobRead,
|
||||
ModelAssetRead,
|
||||
QualityCheckRead,
|
||||
YoloPreflightResponse,
|
||||
@@ -15,7 +16,7 @@ import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './de
|
||||
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
|
||||
import { AiPipelineIllustration } from './AiPipelineIllustration'
|
||||
import { ModelSelector } from '../models/ModelSelector'
|
||||
import { toAnalysisModelOption } from '../models/modelOptions'
|
||||
import { analysisModelAvailabilityMessage, toAnalysisModelOption } from '../models/modelOptions'
|
||||
|
||||
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
|
||||
const DEFAULT_DETECTION_PAGE_SIZE = 50
|
||||
@@ -87,6 +88,7 @@ interface DetectionLabProps {
|
||||
detectionTileManifestPath: string
|
||||
detectionConfidenceThreshold: number
|
||||
runningDetection: boolean
|
||||
detectionJob: JobRead | null
|
||||
detectionRunResult: DetectionRunResponse | null
|
||||
detectionRunError: string | null
|
||||
detectionRuns: DetectionRunRead[]
|
||||
@@ -151,6 +153,7 @@ export function DetectionLab({
|
||||
detectionTileManifestPath,
|
||||
detectionConfidenceThreshold,
|
||||
runningDetection,
|
||||
detectionJob,
|
||||
detectionRunResult,
|
||||
detectionRunError,
|
||||
detectionRuns,
|
||||
@@ -208,12 +211,17 @@ export function DetectionLab({
|
||||
const yoloRuntimeReady = Boolean(
|
||||
yoloPreflight?.checks?.enabled &&
|
||||
yoloPreflight.checks?.dependencies_available &&
|
||||
yoloPreflight.checks?.accelerator_ready === true &&
|
||||
yoloPreflight.checks?.model_file_exists,
|
||||
)
|
||||
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
|
||||
const detectionJobActive = detectionJob?.status === 'queued' || detectionJob?.status === 'running'
|
||||
const detectionHasDataset = selectedDetectionDatasetId.length > 0
|
||||
const detectionHasModel = selectedDetectionModel !== null
|
||||
const detectionModelReady = Boolean(selectedDetectionModel?.configured)
|
||||
const selectedDetectionModelAvailability = selectedDetectionModel
|
||||
? analysisModelAvailabilityMessage(selectedDetectionModel)
|
||||
: 'Het gekozen model is niet geconfigureerd'
|
||||
const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector'
|
||||
const detectionHasExplicitModelAsset =
|
||||
selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0
|
||||
@@ -259,7 +267,7 @@ export function DetectionLab({
|
||||
: selectedDetectionModelId === 'manual-fixture-detector'
|
||||
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
|
||||
: !detectionModelReady
|
||||
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
|
||||
? selectedDetectionModelAvailability
|
||||
: !detectionHasExplicitModelAsset
|
||||
? 'Kies een lokaal modelbestand onder beheer'
|
||||
: !detectionHasTileManifest
|
||||
@@ -275,7 +283,7 @@ export function DetectionLab({
|
||||
: selectedDetectionModelId === 'manual-fixture-detector'
|
||||
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
|
||||
: !detectionModelReady
|
||||
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
|
||||
? selectedDetectionModelAvailability
|
||||
: !detectionHasExplicitModelAsset
|
||||
? 'Kies een lokaal modelbestand onder beheer'
|
||||
: null
|
||||
@@ -499,8 +507,8 @@ export function DetectionLab({
|
||||
<DetectionWorkflowStep label="3. Modelcontrole" complete={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading' || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'validating'} />
|
||||
<DetectionWorkflowStep label="4. Resultaat" complete={detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading'} />
|
||||
</div>
|
||||
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || !guidedDetectionReady}>
|
||||
{detectionWorkflowActionLabel(detectionWorkflowStage)}
|
||||
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !guidedDetectionReady}>
|
||||
{detectionWorkflowActionLabel(detectionWorkflowStage, detectionJob?.status)}
|
||||
</button>
|
||||
|
||||
{!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
|
||||
@@ -528,7 +536,7 @@ export function DetectionLab({
|
||||
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
|
||||
</div>
|
||||
) : null}
|
||||
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
|
||||
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}>
|
||||
Bestaande beeldtegels analyseren
|
||||
</button>
|
||||
</div>
|
||||
@@ -537,15 +545,26 @@ export function DetectionLab({
|
||||
</div>
|
||||
|
||||
<div className="ai-lab-state-stack">
|
||||
{detectionJob && (detectionJob.status === 'queued' || detectionJob.status === 'running') ? (
|
||||
<div className="result-state" role="status" aria-live="polite">
|
||||
<strong>{detectionJob.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-analyse wordt uitgevoerd.'}</strong>
|
||||
<p>
|
||||
{detectionJob.status === 'queued'
|
||||
? 'De server heeft de aanvraag veilig bewaard en start ze zodra de NVIDIA-worker beschikbaar is.'
|
||||
: 'Het model verwerkt de beeldtegels op de server. Dit scherm volgt de bewaarde taak automatisch.'}
|
||||
</p>
|
||||
<span className="muted">Taak-ID: {detectionJob.id}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionRunError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>De beeldanalyse is mislukt.</strong>
|
||||
<div className="result-state result-state-error" role="alert">
|
||||
<strong>{detectionJobActive ? 'Het volgen van de servertaak is onderbroken.' : 'De beeldanalyse is mislukt.'}</strong>
|
||||
<p>{detectionRunError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionRunResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : detectionRunResult.status}</p>
|
||||
<div className={detectionRunResult.detection_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
|
||||
<p>Status: {detectionStatusLabel(detectionRunResult.status)}</p>
|
||||
<p>{detectionRunResult.message}</p>
|
||||
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
|
||||
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
|
||||
@@ -602,7 +621,7 @@ export function DetectionLab({
|
||||
className="primary-action"
|
||||
type="button"
|
||||
onClick={onRunCalibration}
|
||||
disabled={runningDetectionCalibration || !calibrationRunReady}
|
||||
disabled={runningDetectionCalibration || runningDetection || detectionJobActive || !calibrationRunReady}
|
||||
>
|
||||
Drempels vergelijken
|
||||
</button>
|
||||
@@ -927,7 +946,7 @@ export function DetectionLab({
|
||||
) : null}
|
||||
{detectionQaResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</p>
|
||||
<p>Status: {detectionStatusLabel(detectionQaResult.status)}</p>
|
||||
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
@@ -1042,10 +1061,11 @@ function DetectionWorkflowStep({
|
||||
)
|
||||
}
|
||||
|
||||
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage): string {
|
||||
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage, jobStatus?: string): string {
|
||||
if (stage === 'tiling') return 'Beeldtegels voorbereiden...'
|
||||
if (stage === 'validating') return 'Model en beeld controleren...'
|
||||
if (stage === 'detecting') return 'Gebouwen zoeken...'
|
||||
if (stage === 'detecting' && jobStatus === 'queued') return 'Wachten op NVIDIA GPU...'
|
||||
if (stage === 'detecting') return 'Gebouwen zoeken op NVIDIA GPU...'
|
||||
if (stage === 'loading') return 'Resultaat op kaart laden...'
|
||||
if (stage === 'complete') return 'Analyse opnieuw uitvoeren'
|
||||
return 'Gebouwen zoeken en op kaart tonen'
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
YoloPreflightResponse,
|
||||
} from '../../types'
|
||||
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
|
||||
import { analysisModelAvailabilityMessage } from '../models/modelOptions'
|
||||
|
||||
interface DetectionModelManagementProps {
|
||||
detectionModels: DetectionModelCapability[]
|
||||
@@ -39,6 +40,8 @@ function statusLabel(value: string): string {
|
||||
if (value === 'configured' || value === 'ready') return 'gereed'
|
||||
if (value === 'not_configured') return 'niet geconfigureerd'
|
||||
if (value === 'dependency_unavailable') return 'software ontbreekt'
|
||||
if (value === 'accelerator_unavailable') return 'GPU niet beschikbaar'
|
||||
if (value === 'contract_incomplete') return 'provenance onvolledig'
|
||||
return value.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
@@ -65,6 +68,7 @@ export function DetectionModelManagement({
|
||||
const yoloRuntimeReady = Boolean(
|
||||
yoloPreflight?.checks.enabled
|
||||
&& yoloPreflight.checks.dependencies_available
|
||||
&& yoloPreflight.checks.accelerator_ready === true
|
||||
&& yoloPreflight.checks.model_file_exists,
|
||||
)
|
||||
|
||||
@@ -110,7 +114,7 @@ export function DetectionModelManagement({
|
||||
{statusLabel(model.status)}
|
||||
</span>
|
||||
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
|
||||
<p className="muted">{model.limitation_message}</p>
|
||||
<p className="muted">{analysisModelAvailabilityMessage(model)}</p>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische identificatie</summary>
|
||||
<div className="entity-meta">
|
||||
|
||||
Reference in New Issue
Block a user