Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -79,6 +79,15 @@ export function LandingPage({
|
||||
return () => document.body.classList.remove('landing-body')
|
||||
}, [])
|
||||
|
||||
const scrollAccessPanelIntoView = () => {
|
||||
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
accessPanelRef.current.scrollIntoView({
|
||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setPendingAction('operator')
|
||||
@@ -99,9 +108,7 @@ export function LandingPage({
|
||||
setPendingAction('guest')
|
||||
setAttempted(true)
|
||||
setAuthError(null)
|
||||
if (typeof accessPanelRef.current?.scrollIntoView === 'function') {
|
||||
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
scrollAccessPanelIntoView()
|
||||
try {
|
||||
const session = await loginAsGuest()
|
||||
onAuthenticated(session)
|
||||
@@ -114,9 +121,7 @@ export function LandingPage({
|
||||
|
||||
const focusLogin = () => {
|
||||
setMenuOpen(false)
|
||||
if (typeof accessPanelRef.current?.scrollIntoView === 'function') {
|
||||
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
scrollAccessPanelIntoView()
|
||||
window.requestAnimationFrame(() => usernameRef.current?.focus())
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -255,6 +255,14 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
|
||||
visibleThemes,
|
||||
walloniaScopeSelected,
|
||||
} = view
|
||||
const resultsError = (
|
||||
analysisMode === 'evolution'
|
||||
? [temporalComparisonError]
|
||||
: [mapSelectionError, themeResultsError]
|
||||
)
|
||||
.filter((message): message is string => Boolean(message))
|
||||
.filter((message, index, messages) => messages.indexOf(message) === index)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -406,7 +414,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
|
||||
: 'Niet beschikbaar'}
|
||||
</small>
|
||||
</span>
|
||||
<i>{active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
|
||||
<i>{workspaceLoading ? 'Laden' : active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -859,6 +867,25 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
|
||||
<span />
|
||||
<strong>De gekozen bronnen worden begrensd geladen en geanalyseerd…</strong>
|
||||
</div>
|
||||
) : resultsError ? (
|
||||
<div className="geo-results-error" role="alert">
|
||||
<strong>De analyse kon niet worden voltooid</strong>
|
||||
<p>{resultsError}</p>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
disabled={analysisMode === 'evolution' ? !temporalSelectionValid : selectedThemes.length === 0}
|
||||
onClick={() => {
|
||||
if (analysisMode === 'evolution') {
|
||||
runTemporalComparison()
|
||||
} else if (mapSelectionBbox) {
|
||||
void analyzeSelection(mapSelectionBbox, areaIdForSelection(mapSelectionBbox))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Opnieuw proberen
|
||||
</button>
|
||||
</div>
|
||||
) : analysisMode === 'current' && themeInsights.length === 0 && !mapSelectionResult ? (
|
||||
<div className="geo-results-empty">
|
||||
<strong>Nog niet geanalyseerd</strong>
|
||||
@@ -1019,10 +1046,6 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
|
||||
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
|
||||
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
|
||||
) : null}
|
||||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||||
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
|
||||
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
|
||||
|
||||
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
|
||||
<details className="geo-result-details">
|
||||
<summary>Kenmerken van de gevonden objecten</summary>
|
||||
|
||||
@@ -18,16 +18,43 @@ describe('ModelSelector', () => {
|
||||
it('opens the selector and returns an available model choice', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Automatisch aanbevolen/ }))
|
||||
const trigger = screen.getByRole('button', { name: /Automatisch aanbevolen/ })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id)
|
||||
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Automatisch aanbevolen/ }))
|
||||
|
||||
fireEvent.click(screen.getByText('Concrete modellen'))
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ }))
|
||||
expect(onChange).toHaveBeenCalledWith('fast')
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('keeps unavailable runtime models disabled', () => {
|
||||
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Snel lokaal model/ }))
|
||||
fireEvent.click(screen.getByText('Concrete modellen'))
|
||||
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Snel lokaal model/ }))
|
||||
expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('closes predictably and restores trigger focus after close or cancel', () => {
|
||||
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modelkeuze sluiten' }))
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent(dialog, new Event('cancel', { bubbles: false, cancelable: true }))
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(dialog.hasAttribute('open')).toBe(false)
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,11 @@ export function ModelSelector({
|
||||
advancedLabel = 'Concrete modellen',
|
||||
}: ModelSelectorProps): JSX.Element {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const dialogId = useId()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const allOptions = useMemo(
|
||||
() => automaticOption ? [automaticOption, ...options] : options,
|
||||
@@ -64,27 +68,44 @@ export function ModelSelector({
|
||||
?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogRef.current?.open) return
|
||||
const selectedButton = dialogRef.current.querySelector<HTMLElement>('[aria-checked="true"]')
|
||||
selectedButton?.focus()
|
||||
}, [showAdvanced])
|
||||
if (!isOpen || !dialogRef.current?.open) return
|
||||
const selectedButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]:not(:disabled)')
|
||||
const firstAvailableButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"]:not(:disabled)')
|
||||
;(selectedButton ?? firstAvailableButton ?? closeButtonRef.current)?.focus()
|
||||
}, [isOpen, showAdvanced, value])
|
||||
|
||||
const openDialog = () => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog || dialog.open) return
|
||||
setShowAdvanced(options.some((option) => option.id === value))
|
||||
dialog.showModal()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
if (dialogRef.current?.open) dialogRef.current.close()
|
||||
setIsOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
const select = (option: ModelSelectionOption) => {
|
||||
if (option.status !== 'available') return
|
||||
onChange(option.id)
|
||||
dialogRef.current?.close()
|
||||
closeDialog()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="model-selector">
|
||||
<span className="model-selector-label">{label}</span>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="model-selector-trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={dialogRef.current?.open ?? false}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={dialogId}
|
||||
disabled={disabled || loading || allOptions.length === 0}
|
||||
onClick={() => dialogRef.current?.showModal()}
|
||||
onClick={openDialog}
|
||||
>
|
||||
<span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span>
|
||||
<span>
|
||||
@@ -94,14 +115,27 @@ export function ModelSelector({
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<dialog ref={dialogRef} className="model-selector-dialog" aria-labelledby={titleId}>
|
||||
<dialog
|
||||
id={dialogId}
|
||||
ref={dialogRef}
|
||||
className="model-selector-dialog"
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault()
|
||||
closeDialog()
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<div className="model-selector-dialog-header">
|
||||
<div>
|
||||
<span className="section-kicker">Taakgerichte modelkeuze</span>
|
||||
<h2 id={titleId}>Kies hoe GeoIntel analyseert</h2>
|
||||
<p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p>
|
||||
</div>
|
||||
<button type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={() => dialogRef.current?.close()}>
|
||||
<button ref={closeButtonRef} type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={closeDialog}>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -169,4 +203,4 @@ function ModelOptionCard({ option, checked, onSelect }: { option: ModelSelection
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DetectionModelCapability } from '../../types'
|
||||
import {
|
||||
analysisModelAvailabilityMessage,
|
||||
analysisModelDisplayName,
|
||||
toAnalysisModelOption,
|
||||
} from './modelOptions'
|
||||
|
||||
function model(overrides: Partial<DetectionModelCapability> = {}): DetectionModelCapability {
|
||||
return {
|
||||
model_id: 'segmentation-placeholder',
|
||||
display_name: 'Segmentation placeholder',
|
||||
framework: 'none',
|
||||
task_type: 'segmentation',
|
||||
supported_classes: [],
|
||||
configured: false,
|
||||
status: 'not_configured',
|
||||
limitation_message: 'Segmentation inference is not configured for this placeholder.',
|
||||
validated_regions: [],
|
||||
nationally_validated: false,
|
||||
operator_review_required: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('analysis model availability copy', () => {
|
||||
it('does not expose raw English backend placeholder copy in the Dutch UI', () => {
|
||||
const capability = model()
|
||||
|
||||
expect(analysisModelAvailabilityMessage(capability)).toContain('nog geen productiegeschikt segmentatiemodel')
|
||||
expect(analysisModelDisplayName(capability)).toBe('Segmentatiemodel nog niet geconfigureerd')
|
||||
expect(toAnalysisModelOption(capability).description).not.toContain('Segmentation inference')
|
||||
})
|
||||
|
||||
it('explains an unavailable NVIDIA runtime explicitly', () => {
|
||||
const capability = model({
|
||||
model_id: 'yolo-configured',
|
||||
task_type: 'object_detection',
|
||||
status: 'accelerator_unavailable',
|
||||
})
|
||||
|
||||
expect(analysisModelAvailabilityMessage(capability)).toContain('NVIDIA CUDA')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,65 @@
|
||||
import type { DetectionModelCapability } from '../../types'
|
||||
import type { ModelSelectionOption } from './ModelSelector'
|
||||
|
||||
export function analysisModelDisplayName(model: DetectionModelCapability): string {
|
||||
const knownNames: Record<string, string> = {
|
||||
'yolo-configured': 'Lokaal gebouwmodel',
|
||||
'manual-fixture-detector': 'Testdetectie (geen productie)',
|
||||
'yolo-placeholder': 'Gebouwmodel nog niet geconfigureerd',
|
||||
'segmentation-placeholder': 'Segmentatiemodel nog niet geconfigureerd',
|
||||
'fixture-segmenter': 'Testsegmentatie (geen productie)',
|
||||
'yolo-seg-configured': 'Lokaal YOLO-segmentatiemodel',
|
||||
'sam-configured': 'Lokaal SAM-segmentatiemodel',
|
||||
'yolo-seg-placeholder': 'YOLO-segmentatie nog niet geconfigureerd',
|
||||
'sam-placeholder': 'SAM-segmentatie nog niet geconfigureerd',
|
||||
}
|
||||
return knownNames[model.model_id] ?? model.display_name
|
||||
}
|
||||
|
||||
function supportedClassLabel(value: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
building: 'gebouwen',
|
||||
vegetation: 'vegetatie',
|
||||
water: 'water',
|
||||
landuse: 'landgebruik',
|
||||
segment: 'algemene vlakken',
|
||||
}
|
||||
return labels[value.toLowerCase()] ?? value
|
||||
}
|
||||
|
||||
export function analysisModelAvailabilityMessage(model: DetectionModelCapability): string {
|
||||
const task = model.task_type === 'segmentation' ? 'segmentatiemodel' : 'detectiemodel'
|
||||
if (model.model_id === 'manual-fixture-detector' || model.model_id === 'fixture-segmenter') {
|
||||
return 'Alleen beschikbaar voor expliciete geautomatiseerde tests; dit is geen productie-inferentie.'
|
||||
}
|
||||
if (model.configured) {
|
||||
return `Dit lokale ${task} is op de server geconfigureerd. Resultaten blijven operatorcontrole vereisen.`
|
||||
}
|
||||
if (model.status === 'accelerator_unavailable') {
|
||||
return 'De vereiste NVIDIA CUDA-runtime is momenteel niet beschikbaar op de server.'
|
||||
}
|
||||
if (model.status === 'dependency_unavailable') {
|
||||
return 'De vereiste PyTorch- of modelsoftware is nog niet beschikbaar op de server.'
|
||||
}
|
||||
if (model.status === 'contract_incomplete') {
|
||||
return 'Het modelbestand is aanwezig, maar de versieerbare provenancecontrole is nog niet volledig.'
|
||||
}
|
||||
if (model.model_id.includes('placeholder')) {
|
||||
return `Er is nog geen productiegeschikt ${task} aan deze registratie gekoppeld.`
|
||||
}
|
||||
return `Dit ${task} is nog niet volledig geconfigureerd op de server.`
|
||||
}
|
||||
|
||||
export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption {
|
||||
const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie'
|
||||
const configured = model.configured && model.status !== 'not_configured'
|
||||
const supportedClasses = model.supported_classes.map(supportedClassLabel)
|
||||
return {
|
||||
id: model.model_id,
|
||||
name: model.display_name,
|
||||
name: analysisModelDisplayName(model),
|
||||
description: configured
|
||||
? `Beschikbaar voor lokale ${task}${model.supported_classes.length ? ` van ${model.supported_classes.join(', ')}` : ''}.`
|
||||
: model.limitation_message,
|
||||
? `Beschikbaar voor lokale ${task}${supportedClasses.length ? ` van ${supportedClasses.join(', ')}` : ''}.`
|
||||
: analysisModelAvailabilityMessage(model),
|
||||
recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined,
|
||||
status: configured ? 'available' : 'unavailable',
|
||||
statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd',
|
||||
@@ -24,4 +74,4 @@ export function toAnalysisModelOption(model: DetectionModelCapability): ModelSel
|
||||
model.operator_review_required ? 'Operatorcontrole vereist' : '',
|
||||
].filter(Boolean),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DatasetCreateResponse,
|
||||
JobRead,
|
||||
SegmentationModelCapability,
|
||||
SegmentationQaResult,
|
||||
SegmentationRead,
|
||||
@@ -7,7 +8,11 @@ import type {
|
||||
SegmentationRunResponse,
|
||||
} from '../../types'
|
||||
import { ModelSelector } from '../models/ModelSelector'
|
||||
import { toAnalysisModelOption } from '../models/modelOptions'
|
||||
import {
|
||||
analysisModelAvailabilityMessage,
|
||||
analysisModelDisplayName,
|
||||
toAnalysisModelOption,
|
||||
} from '../models/modelOptions'
|
||||
|
||||
interface SegmentationLabProps {
|
||||
segmentationModels: SegmentationModelCapability[]
|
||||
@@ -18,11 +23,14 @@ interface SegmentationLabProps {
|
||||
segmentationTileManifestPath: string
|
||||
segmentationConfidenceThreshold: number
|
||||
runningSegmentation: boolean
|
||||
segmentationJob: JobRead | null
|
||||
segmentationRunResult: SegmentationRunResponse | null
|
||||
segmentationRunError: string | null
|
||||
segmentationRuns: SegmentationRunRead[]
|
||||
selectedSegmentationRunId: string
|
||||
segmentationItems: SegmentationRead[]
|
||||
segmentationTotal: number
|
||||
segmentationTruncated: boolean
|
||||
segmentationClassFilter: string
|
||||
segmentationMinConfidenceFilter: number
|
||||
loadingSegmentationResults: boolean
|
||||
@@ -92,11 +100,14 @@ export function SegmentationLab({
|
||||
segmentationTileManifestPath,
|
||||
segmentationConfidenceThreshold,
|
||||
runningSegmentation,
|
||||
segmentationJob,
|
||||
segmentationRunResult,
|
||||
segmentationRunError,
|
||||
segmentationRuns,
|
||||
selectedSegmentationRunId,
|
||||
segmentationItems,
|
||||
segmentationTotal,
|
||||
segmentationTruncated,
|
||||
segmentationClassFilter,
|
||||
segmentationMinConfidenceFilter,
|
||||
loadingSegmentationResults,
|
||||
@@ -127,8 +138,15 @@ export function SegmentationLab({
|
||||
const segmentationHasTileManifest = segmentationTileManifestPath.trim().length > 0
|
||||
const segmentationModelUiRunnable =
|
||||
selectedSegmentationModelConfigured && selectedSegmentationModelId !== 'fixture-segmenter'
|
||||
const selectedSegmentationModel = segmentationModels.find(
|
||||
(model) => model.model_id === selectedSegmentationModelId,
|
||||
) ?? null
|
||||
const selectedSegmentationModelAvailability = selectedSegmentationModel
|
||||
? analysisModelAvailabilityMessage(selectedSegmentationModel)
|
||||
: selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
|
||||
const segmentationRunReady =
|
||||
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
|
||||
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable && segmentationHasTileManifest
|
||||
const segmentationJobActive = segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'
|
||||
const segmentationRunBlockedReason = !selectedProjectId
|
||||
? 'Kies eerst een werkruimte'
|
||||
: !segmentationHasDataset
|
||||
@@ -136,8 +154,10 @@ export function SegmentationLab({
|
||||
: selectedSegmentationModelId === 'fixture-segmenter'
|
||||
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo’s'
|
||||
: !selectedSegmentationModelConfigured
|
||||
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
|
||||
: null
|
||||
? selectedSegmentationModelAvailability
|
||||
: !segmentationHasTileManifest
|
||||
? 'Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand'
|
||||
: null
|
||||
|
||||
return (
|
||||
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
|
||||
@@ -180,10 +200,10 @@ export function SegmentationLab({
|
||||
<ul className="model-list">
|
||||
{segmentationModels.map((model) => (
|
||||
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
|
||||
<strong>{model.display_name}</strong>
|
||||
<strong>{analysisModelDisplayName(model)}</strong>
|
||||
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</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">
|
||||
@@ -219,17 +239,19 @@ export function SegmentationLab({
|
||||
<span>Rasterbestand</span>
|
||||
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
|
||||
</div>
|
||||
<div className={selectedSegmentationModelConfigured ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<div className={segmentationModelUiRunnable ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Analysemodel</span>
|
||||
<strong>
|
||||
{selectedSegmentationModelConfigured
|
||||
{selectedSegmentationModelId === 'fixture-segmenter'
|
||||
? 'Alleen beschikbaar voor geautomatiseerde tests'
|
||||
: selectedSegmentationModelConfigured
|
||||
? 'Het gekozen model is beschikbaar'
|
||||
: selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel'}
|
||||
: selectedSegmentationModelAvailability}
|
||||
</strong>
|
||||
</div>
|
||||
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Beeldtegels</span>
|
||||
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Niet vereist voor het fixturemodel'}</strong>
|
||||
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Koppel het tegelmanifest van het rasterbestand'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,29 +314,44 @@ export function SegmentationLab({
|
||||
className="primary-action"
|
||||
type="button"
|
||||
onClick={onRunSegmentation}
|
||||
disabled={runningSegmentation || !segmentationRunReady}
|
||||
disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}
|
||||
>
|
||||
Segmentatie starten
|
||||
{segmentationJob?.status === 'queued'
|
||||
? 'Wachten op NVIDIA GPU…'
|
||||
: runningSegmentation
|
||||
? 'GPU-segmentatie wordt verwerkt…'
|
||||
: 'Segmentatie starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-lab-state-stack">
|
||||
{segmentationJobActive ? (
|
||||
<div className="result-state result-state-loading" role="status" aria-live="polite">
|
||||
<strong>{segmentationJob?.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-segmentatie wordt uitgevoerd.'}</strong>
|
||||
<p>
|
||||
{segmentationJob?.status === 'queued'
|
||||
? 'De server start de taak zodra de NVIDIA-worker beschikbaar is.'
|
||||
: 'GeoIntel volgt de servertaak en toont na voltooiing alleen de werkelijk bewaarde polygonen.'}
|
||||
</p>
|
||||
<span className="muted">Taak-ID: {segmentationJob?.id}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!selectedSegmentationModelConfigured ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Het segmentatiemodel is nog niet gereed.</strong>
|
||||
<p>{selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel.'}</p>
|
||||
<p>{selectedSegmentationModelAvailability}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationRunError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<div className="result-state result-state-error" role="alert">
|
||||
<strong>De segmentatie is mislukt.</strong>
|
||||
<p>{segmentationRunError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationRunResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {segmentationRunResult.status === 'completed' ? 'afgerond' : segmentationRunResult.status}</p>
|
||||
<div className={segmentationRunResult.segmentation_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
|
||||
<p>Status: {analysisStatusLabel(segmentationRunResult.status)}</p>
|
||||
<p>{segmentationRunResult.message}</p>
|
||||
<p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p>
|
||||
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
|
||||
@@ -382,10 +419,30 @@ export function SegmentationLab({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ai-lab-state-stack">
|
||||
<div className="result-state result-state-ready">
|
||||
<strong>{segmentationItems.length} vlakken geladen</strong>
|
||||
<p>{selectedSegmentationRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
|
||||
</div>
|
||||
{loadingSegmentationResults || segmentationRunError ? null : !selectedSegmentationRunId ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Kies eerst een bewaarde analyse.</strong>
|
||||
<p>Daarna toont GeoIntel uitsluitend de polygonen van die analyserun.</p>
|
||||
</div>
|
||||
) : segmentationTotal === 0 ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen bewaarde vlakken binnen deze filters.</strong>
|
||||
<p>Dit bewijst niet dat het gebied geen relevante objecten bevat.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={segmentationTruncated ? 'result-state result-state-warning' : 'result-state result-state-ready'}>
|
||||
<strong>
|
||||
{segmentationTruncated
|
||||
? `${segmentationItems.length} van ${segmentationTotal} vlakken geladen`
|
||||
: `${segmentationTotal} vlakken geladen`}
|
||||
</strong>
|
||||
<p>
|
||||
{segmentationTruncated
|
||||
? 'De kaart en tabel tonen een begrensde pagina. Gebruik filters om het resultaat gericht te verfijnen.'
|
||||
: 'Deze resultaten zijn bewaard in de database.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{segmentationItems.length > 0 ? (
|
||||
<div className="table-scroll">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GeoIntelMark } from '../brand/GeoIntelBrand'
|
||||
export interface WorkspaceNavigationItem {
|
||||
key: WorkspaceKey
|
||||
label: string
|
||||
navigationLabel?: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ export function WorkbenchNavigation({
|
||||
data-testid={`workspace-nav-${item.key}`}
|
||||
>
|
||||
<Icon className="nav-item-icon" aria-hidden="true" strokeWidth={1.8} />
|
||||
<span>{item.label}</span>
|
||||
<span>{item.navigationLabel ?? item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user