Close full operational audit findings
This commit is contained in:
@@ -98,6 +98,27 @@ The workbench uses a task-based shell instead of a single long panel stack. `App
|
||||
|
||||
The premium V1 presentation layer lives in `src/styles/premium.css`. It groups navigation by Workspace, Analyze and Deliver, removes the permanent inspector column, gives desktop/ultrawide workspaces stable readable widths and switches narrow screens to full-width content with a horizontally scrollable navigation rail. API calls and workflow state remain owned by the existing hooks.
|
||||
|
||||
## Current component boundaries
|
||||
|
||||
`App.tsx` remains the shared workspace orchestrator, while focused surfaces
|
||||
and pure map helpers are kept outside it:
|
||||
|
||||
- `components/overview/OverviewWorkspace.tsx` owns status, source freshness,
|
||||
workflow progress and overview actions.
|
||||
- `components/detection/DetectionLab.tsx` owns the end-user detection flow;
|
||||
`components/detection/DetectionModelManagement.tsx` contains the collapsed
|
||||
operator-only model registry, profiles, assets and runtime diagnostics.
|
||||
- `components/map/MapWorkspace.tsx` owns the map workflow;
|
||||
`components/map/mapWorkspaceUtils.ts` owns pure bbox, metric, download and
|
||||
display helpers.
|
||||
- `components/project/ProjectPanel.tsx` owns explicit workspace management,
|
||||
including reversible archiving for non-canonical workspaces.
|
||||
|
||||
Normal screens use Dutch task language and friendly labels. UUIDs, file paths,
|
||||
checksums, raw model states and job terminology stay behind labelled technical
|
||||
disclosures. Archiving a workspace does not remove its data and the two
|
||||
canonical regional workspaces cannot be archived through the UI.
|
||||
|
||||
Data and Map are organized around the core daily workflow. Data shows Project, AOI and Dataset columns together on normal desktop widths, bounds populated lists inside their own panels and keeps create/upload forms in explicit disclosures. Map keeps the layer/AOI command surface and MapLibre frame first, then exposes provenance, BBox controls and raw feature inspection only when requested. Existing selection, export and QA actions are unchanged.
|
||||
|
||||
Wide and ultrawide screens keep a readable sidebar and centered work area, expand the MapLibre review frame and use extra horizontal space for Data, Analysis, AI and Export grids. The detail drawer overlays the work area only while open, so it does not permanently consume ultrawide canvas space.
|
||||
|
||||
+36
-190
@@ -10,11 +10,10 @@ import { ExportCenter } from './components/exports/ExportCenter'
|
||||
import { ExportPreview } from './components/exports/ExportPreview'
|
||||
import { WorkbenchInspector } from './components/inspector/WorkbenchInspector'
|
||||
import { MapWorkspace } from './components/map/MapWorkspace'
|
||||
import { OverviewWorkspace, type WorkspaceKey } from './components/overview/OverviewWorkspace'
|
||||
import { AreaPanel } from './components/project/AreaPanel'
|
||||
import { ProjectPanel } from './components/project/ProjectPanel'
|
||||
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
|
||||
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
|
||||
import { SourceFreshnessPanel } from './components/status/SourceFreshnessPanel'
|
||||
import type { DatasetCreateResponse } from './types'
|
||||
import { ProviderPanel } from './components/providers/ProviderPanel'
|
||||
import { SegmentationLab } from './components/segmentation/SegmentationLab'
|
||||
@@ -43,8 +42,6 @@ function isVectorDatasetType(datasetType: string): boolean {
|
||||
return datasetType === 'vector' || datasetType === 'geojson'
|
||||
}
|
||||
|
||||
type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
|
||||
|
||||
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
|
||||
{ key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' },
|
||||
{ key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' },
|
||||
@@ -83,6 +80,7 @@ function App(): JSX.Element {
|
||||
loadingProjects,
|
||||
loadingAreas,
|
||||
loadingDatasets,
|
||||
archivingProjectId,
|
||||
errorMessage,
|
||||
projectForm,
|
||||
areaForm,
|
||||
@@ -90,6 +88,7 @@ function App(): JSX.Element {
|
||||
loadProjectData,
|
||||
createProject,
|
||||
createArea,
|
||||
archiveProject,
|
||||
resetProjectData,
|
||||
setSelectedProjectId,
|
||||
setErrorMessage,
|
||||
@@ -586,24 +585,24 @@ function App(): JSX.Element {
|
||||
return 'Detectierun'
|
||||
}
|
||||
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
|
||||
return `${selectedDataset.dataset_type}-dataset`
|
||||
return selectedDataset.dataset_type === 'raster' ? 'Rasterdatabron' : 'Vectordatabron'
|
||||
}
|
||||
return 'Geen actieve gegevens- of analyselaag'
|
||||
}, [analysisMapLayerActive, changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled])
|
||||
const mapLayerProvenance = useMemo(() => {
|
||||
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
|
||||
return `source ${changeSourceDatasetId || 'n/a'} -> target ${changeTargetDatasetId || 'n/a'}`
|
||||
return 'Vergelijking van twee bewaarde kaartlagen'
|
||||
}
|
||||
if (analysisMapLayerActive && segmentationGeoJson) {
|
||||
return selectedSegmentationRunId ? `analysis run ${selectedSegmentationRunId}` : 'segmentation results loaded'
|
||||
return selectedSegmentationRunId ? 'Bewaarde segmentatieronde' : 'Segmentatieresultaten geladen'
|
||||
}
|
||||
if (analysisMapLayerActive && detectionGeoJson) {
|
||||
return selectedDetectionRunId ? `analysis run ${selectedDetectionRunId}` : 'detection results loaded'
|
||||
return selectedDetectionRunId ? 'Bewaarde detectieronde' : 'Detectieresultaten geladen'
|
||||
}
|
||||
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
|
||||
return `${selectedDataset.dataset_role ?? 'source'} / ${selectedDataset.source_name ?? selectedDataset.source}`
|
||||
return `Bewaarde ${selectedDataset.dataset_role === 'reference' ? 'referentielaag' : 'databron'}`
|
||||
}
|
||||
return 'Open a dataset, detection run, segmentation run or change result to draw it here.'
|
||||
return 'Open een databron, detectieronde, segmentatieronde of veranderingsresultaat om het hier te tekenen.'
|
||||
}, [
|
||||
analysisMapLayerActive,
|
||||
changeDetectionResult?.geojson,
|
||||
@@ -658,73 +657,11 @@ function App(): JSX.Element {
|
||||
}
|
||||
setActiveWorkspace(target)
|
||||
}
|
||||
const hasAnalysisOutput = qualityChecks.length > 0 || Boolean(changeDetectionResult) || detectionItems.length > 0 || segmentationItems.length > 0
|
||||
const hasMapContext = mapFeatureCount > 0 || areaFeatureCount > 0
|
||||
const workflowGuidanceComplete = Boolean(selectedProjectId) && datasets.length > 0 && hasMapContext && hasAnalysisOutput && exports.length > 0
|
||||
const recommendedWorkflowTarget: WorkspaceKey = !selectedProjectId
|
||||
? 'data'
|
||||
: datasets.length === 0
|
||||
? 'data'
|
||||
: !hasMapContext
|
||||
? 'map'
|
||||
: !hasAnalysisOutput
|
||||
? 'analysis'
|
||||
: exports.length === 0
|
||||
? 'exports'
|
||||
: 'exports'
|
||||
const workflowGuidanceSteps: Array<{
|
||||
step: string
|
||||
title: string
|
||||
detail: string
|
||||
status: string
|
||||
ready: boolean
|
||||
target: WorkspaceKey
|
||||
}> = [
|
||||
{
|
||||
step: '1',
|
||||
title: 'Project & AOI',
|
||||
detail: selectedProjectId ? `${areas.length} AOI record${areas.length === 1 ? '' : 's'} available` : 'Create or load a project context',
|
||||
status: selectedProjectId ? 'ready' : 'next',
|
||||
ready: Boolean(selectedProjectId),
|
||||
target: 'data',
|
||||
},
|
||||
{
|
||||
step: '2',
|
||||
title: 'Data',
|
||||
detail: datasets.length > 0 ? `${datasets.length} dataset${datasets.length === 1 ? '' : 's'} loaded` : 'Upload source and reference datasets',
|
||||
status: datasets.length > 0 ? 'ready' : 'waiting',
|
||||
ready: datasets.length > 0,
|
||||
target: 'data',
|
||||
},
|
||||
{
|
||||
step: '3',
|
||||
title: 'Map',
|
||||
detail: hasMapContext
|
||||
? mapFeatureCount > 0
|
||||
? `${mapFeatureCount} layer feature${mapFeatureCount === 1 ? '' : 's'}${areaFeatureCount > 0 ? ' + AOI' : ''}`
|
||||
: 'AOI loaded'
|
||||
: 'Inspect AOI and selected layer',
|
||||
status: hasMapContext ? 'ready' : 'waiting',
|
||||
ready: hasMapContext,
|
||||
target: 'map',
|
||||
},
|
||||
{
|
||||
step: '4',
|
||||
title: 'QA / AI',
|
||||
detail: hasAnalysisOutput ? 'QA, change, detection or segmentation output exists' : 'Run checks after data is ready',
|
||||
status: hasAnalysisOutput ? 'ready' : 'waiting',
|
||||
ready: hasAnalysisOutput,
|
||||
target: 'analysis',
|
||||
},
|
||||
{
|
||||
step: '5',
|
||||
title: 'Export',
|
||||
detail: exports.length > 0 ? `${exports.length} export artifact${exports.length === 1 ? '' : 's'} recorded` : 'Package validated project outputs',
|
||||
status: exports.length > 0 ? 'ready' : 'waiting',
|
||||
ready: exports.length > 0,
|
||||
target: 'exports',
|
||||
},
|
||||
]
|
||||
const hasAnalysisOutput =
|
||||
qualityChecks.length > 0
|
||||
|| Boolean(changeDetectionResult)
|
||||
|| detectionItems.length > 0
|
||||
|| segmentationItems.length > 0
|
||||
const projectContextLabel = selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME
|
||||
? REGIONAL_WORKSPACE_LABEL
|
||||
: selectedProject?.name ?? 'Geen werkruimte'
|
||||
@@ -759,7 +696,7 @@ function App(): JSX.Element {
|
||||
<p>GeoAI-werkruimte · Mol en de Kempen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-bar" aria-label="Active workbench context">
|
||||
<div className="context-bar" aria-label="Actieve werkcontext">
|
||||
<div>
|
||||
<span>Werkruimte</span>
|
||||
<strong title={projectContextLabel}>{projectContextLabel}</strong>
|
||||
@@ -782,8 +719,8 @@ function App(): JSX.Element {
|
||||
{errorMessage ? <p className="error">{errorMessage}</p> : null}
|
||||
|
||||
<div className="workbench-layout">
|
||||
<aside className="workbench-sidebar" aria-label="Workbench navigation">
|
||||
<nav aria-label="Primary workspaces">
|
||||
<aside className="workbench-sidebar" aria-label="Navigatie van de werkruimte">
|
||||
<nav aria-label="Hoofdonderdelen">
|
||||
{workspaceNavGroups.map((group) => (
|
||||
<div className="nav-group" key={group.label}>
|
||||
<p className="nav-section-label">{group.label}</p>
|
||||
@@ -799,7 +736,7 @@ function App(): JSX.Element {
|
||||
className={item.key === activeWorkspace ? 'nav-item nav-item-active' : 'nav-item'}
|
||||
onClick={() => setActiveWorkspace(item.key)}
|
||||
aria-current={item.key === activeWorkspace ? 'page' : undefined}
|
||||
aria-label={`Open ${item.label} workspace: ${item.description}`}
|
||||
aria-label={`Open ${item.label}: ${item.description}`}
|
||||
data-testid={`workspace-nav-${item.key}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
@@ -833,7 +770,7 @@ function App(): JSX.Element {
|
||||
) : null}
|
||||
</div>
|
||||
</div> : null}
|
||||
<div className="workspace-command-bar" aria-label="Workspace shortcuts">
|
||||
<div className="workspace-command-bar" aria-label="Snelkoppelingen">
|
||||
<div className="workspace-nav-cluster">
|
||||
{workspaceNavItems.slice(0, 5).map((item) => (
|
||||
<button
|
||||
@@ -842,7 +779,7 @@ function App(): JSX.Element {
|
||||
className={item.key === activeWorkspace ? 'command-chip command-chip-active' : 'command-chip'}
|
||||
onClick={() => setActiveWorkspace(item.key)}
|
||||
aria-pressed={item.key === activeWorkspace}
|
||||
aria-label={`Switch to ${item.label} workspace`}
|
||||
aria-label={`Ga naar ${item.label}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
@@ -852,112 +789,19 @@ function App(): JSX.Element {
|
||||
</div>
|
||||
|
||||
{activeWorkspace === 'overview' ? (
|
||||
<div className="workspace-stack">
|
||||
<WorkbenchStatusStrip
|
||||
selectedProject={selectedProject}
|
||||
areas={areas}
|
||||
datasets={datasets}
|
||||
qualityChecks={qualityChecks}
|
||||
exports={exports}
|
||||
activeLayerFeatureCount={mapFeatureCount}
|
||||
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
|
||||
/>
|
||||
<SourceFreshnessPanel
|
||||
report={sourceFreshness.report}
|
||||
loading={sourceFreshness.loading}
|
||||
error={sourceFreshness.error}
|
||||
onRefresh={() => { void sourceFreshness.refresh() }}
|
||||
catalogReport={sourceFreshness.catalogReport}
|
||||
catalogLoading={sourceFreshness.catalogLoading}
|
||||
catalogError={sourceFreshness.catalogError}
|
||||
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
|
||||
grbRefreshPlan={sourceFreshness.grbRefreshPlan}
|
||||
grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
|
||||
grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
|
||||
/>
|
||||
<details className="status-details-disclosure">
|
||||
<summary>
|
||||
<span>Volledige workflowstatus</span>
|
||||
<strong>{workflowGuidanceComplete ? 'voltooid' : 'optionele stappen open'}</strong>
|
||||
</summary>
|
||||
<section className="workflow-guidance-panel" aria-label="V1 workflow guidance">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<p className="eyebrow">Workbench flow</p>
|
||||
<h2>Project to export path</h2>
|
||||
</div>
|
||||
<span className="status-badge">
|
||||
{workflowGuidanceComplete
|
||||
? 'Ready for handoff'
|
||||
: `Next: ${workspaceNavItems.find((item) => item.key === recommendedWorkflowTarget)?.label ?? 'Overview'}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-guidance-steps">
|
||||
{workflowGuidanceSteps.map((step) => (
|
||||
<button
|
||||
key={`${step.step}-${step.title}`}
|
||||
type="button"
|
||||
className={
|
||||
step.target === recommendedWorkflowTarget && !step.ready
|
||||
? 'workflow-guidance-step workflow-guidance-step-active'
|
||||
: step.ready
|
||||
? 'workflow-guidance-step workflow-guidance-step-ready'
|
||||
: 'workflow-guidance-step'
|
||||
}
|
||||
onClick={() => openWorkflowGuidanceStep(step.target)}
|
||||
aria-label={`Open ${step.title} step`}
|
||||
>
|
||||
<span className="workflow-step-status">{step.status}</span>
|
||||
<strong>
|
||||
{step.step}. {step.title}
|
||||
</strong>
|
||||
<small>{step.detail}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="overview-actions">
|
||||
<div className="overview-action-copy">
|
||||
<p className="eyebrow">Quick access</p>
|
||||
<h2>Continue working</h2>
|
||||
</div>
|
||||
<div className="quick-action-grid overview-quick-actions" aria-label="Recommended next workbench actions">
|
||||
<button
|
||||
type="button"
|
||||
className="quick-action-button"
|
||||
onClick={() => setActiveWorkspace('data')}
|
||||
aria-label="Open data setup workspace"
|
||||
>
|
||||
Open data setup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-action-button"
|
||||
onClick={() => setActiveWorkspace('map')}
|
||||
aria-label="Inspect map workspace"
|
||||
>
|
||||
Inspect map
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-action-button"
|
||||
onClick={() => setActiveWorkspace('analysis')}
|
||||
aria-label="Review QA/QC workspace"
|
||||
>
|
||||
Review QA/QC
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="quick-action-button"
|
||||
onClick={() => setActiveWorkspace('exports')}
|
||||
aria-label="Manage exports workspace"
|
||||
>
|
||||
Manage exports
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
</div>
|
||||
<OverviewWorkspace
|
||||
selectedProject={selectedProject}
|
||||
selectedProjectId={selectedProjectId}
|
||||
areas={areas}
|
||||
datasets={datasets}
|
||||
qualityChecks={qualityChecks}
|
||||
exports={exports}
|
||||
activeLayerFeatureCount={mapFeatureCount}
|
||||
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
|
||||
hasAnalysisOutput={hasAnalysisOutput}
|
||||
sourceFreshness={sourceFreshness}
|
||||
onOpenWorkspace={openWorkflowGuidanceStep}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeWorkspace === 'data' ? (
|
||||
@@ -967,12 +811,14 @@ function App(): JSX.Element {
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
loadingProjects={loadingProjects}
|
||||
archivingProjectId={archivingProjectId}
|
||||
projectForm={projectForm}
|
||||
loadingDemoWorkflow={loadingDemoWorkflow}
|
||||
demoWorkflowMessage={demoWorkflowMessage}
|
||||
onCreateProject={createProject}
|
||||
onUpdateProjectForm={setProjectForm}
|
||||
onSelectProject={selectProject}
|
||||
onArchiveProject={archiveProject}
|
||||
onLoadDemoWorkflow={loadDemoWorkflow}
|
||||
/>
|
||||
|
||||
@@ -1287,7 +1133,7 @@ function App(): JSX.Element {
|
||||
</main>
|
||||
|
||||
{inspectorOpen ? (
|
||||
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Current selection inspector">
|
||||
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
|
||||
<WorkbenchInspector
|
||||
selectedProject={selectedProject}
|
||||
selectedArea={selectedArea}
|
||||
|
||||
@@ -40,20 +40,20 @@ export function ChangeDetectionPanel({
|
||||
<section className="panel change-detection-shell">
|
||||
<div className="panel-header change-detection-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Analysis</p>
|
||||
<h2>Change Detection</h2>
|
||||
<p className="eyebrow">Historische analyse</p>
|
||||
<h2>Veranderingen vergelijken</h2>
|
||||
</div>
|
||||
<button disabled={running || vectorDatasets.length < 2} onClick={onRun} type="button">
|
||||
{running ? 'Comparing...' : 'Compare vectors'}
|
||||
{running ? 'Vergelijken...' : 'Kaartlagen vergelijken'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="change-detection-input-surface" aria-label="Change detection input controls">
|
||||
<div className="change-detection-input-surface" aria-label="Instellingen voor veranderingsanalyse">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Source vector
|
||||
Eerdere kaartlaag
|
||||
<select value={sourceDatasetId} onChange={(event) => onSourceDatasetChange(event.target.value)}>
|
||||
<option value="">Select source</option>
|
||||
<option value="">Kies de eerdere kaartlaag</option>
|
||||
{vectorDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{datasetLabel(dataset)}
|
||||
@@ -62,9 +62,9 @@ export function ChangeDetectionPanel({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target vector
|
||||
Latere kaartlaag
|
||||
<select value={targetDatasetId} onChange={(event) => onTargetDatasetChange(event.target.value)}>
|
||||
<option value="">Select target</option>
|
||||
<option value="">Kies de latere kaartlaag</option>
|
||||
{vectorDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{datasetLabel(dataset)}
|
||||
@@ -73,7 +73,7 @@ export function ChangeDetectionPanel({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
IoU threshold
|
||||
Minimale geometrische overlap
|
||||
<input
|
||||
max="1"
|
||||
min="0"
|
||||
@@ -89,7 +89,7 @@ export function ChangeDetectionPanel({
|
||||
type="checkbox"
|
||||
onChange={(event) => onIncludeUnchangedChange(event.target.checked)}
|
||||
/>
|
||||
Include unchanged geometry
|
||||
Ongewijzigde objecten opnemen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,36 +97,36 @@ export function ChangeDetectionPanel({
|
||||
<div className="change-detection-state-stack">
|
||||
{error ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Change detection failed.</strong>
|
||||
<strong>De veranderingsanalyse is mislukt.</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!error && vectorDatasets.length < 2 ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Not enough vector datasets</strong>
|
||||
<p>Upload at least two vector datasets to compare.</p>
|
||||
<strong>Onvoldoende vergelijkbare kaartlagen</strong>
|
||||
<p>Voeg minstens twee vectorlagen toe om periodes te vergelijken.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="change-detection-result-surface" aria-label="Change detection result summary">
|
||||
<div className="change-detection-result-surface" aria-label="Samenvatting veranderingsanalyse">
|
||||
<div className="summary-grid">
|
||||
<div>
|
||||
<span className="metric">{result.added_count}</span>
|
||||
<span>Added</span>
|
||||
<span>Nieuw</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="metric">{result.removed_count}</span>
|
||||
<span>Removed</span>
|
||||
<span>Verdwenen</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="metric">{result.unchanged_count}</span>
|
||||
<span>Unchanged</span>
|
||||
<span>Ongewijzigd</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="metric">{result.geojson.features.length}</span>
|
||||
<span>Map features</span>
|
||||
<span>Kaartobjecten</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export function ChangeDetectionPanel({
|
||||
|
||||
{result?.warnings.length ? (
|
||||
<div className="change-detection-warning-surface">
|
||||
<strong>Warnings</strong>
|
||||
<strong>Aandachtspunten</strong>
|
||||
<ul className="compact-list">
|
||||
{result.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface DatasetDetailPanelProps {
|
||||
|
||||
function formatBytes(value: number | null | undefined): string {
|
||||
if (!value && value !== 0) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
@@ -87,11 +87,11 @@ function formatBytes(value: number | null | undefined): string {
|
||||
|
||||
function formatBounds(bounds: Record<string, number> | null | undefined): string {
|
||||
if (!bounds) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
|
||||
if (!keys.every((key) => key in bounds)) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
|
||||
}
|
||||
@@ -157,8 +157,8 @@ export function DatasetDetailPanel({
|
||||
}: DatasetDetailPanelProps) {
|
||||
return (
|
||||
<section className="dataset-detail-panel">
|
||||
<h2>Dataset details</h2>
|
||||
{selectedDatasetId ? <p>Selected dataset: {selectedDatasetId}</p> : <p>No dataset selected</p>}
|
||||
<h2>Details van de databron</h2>
|
||||
{!selectedDatasetId ? <p>Geen databron geselecteerd.</p> : null}
|
||||
{selectedDataset ? (
|
||||
<div>
|
||||
<p>
|
||||
@@ -166,13 +166,19 @@ export function DatasetDetailPanel({
|
||||
</p>
|
||||
<p>Type: {selectedDataset.dataset_type}</p>
|
||||
<p>Status: {selectedDataset.status}</p>
|
||||
<p>Original file: {selectedDataset.original_filename ?? 'n/a'}</p>
|
||||
<p>Stored file: {selectedDataset.stored_filename ?? 'n/a'}</p>
|
||||
<p>Content type: {selectedDataset.content_type ?? 'n/a'}</p>
|
||||
<p>File size: {formatBytes(selectedDataset.size_bytes)}</p>
|
||||
<p>SHA256: {selectedDataset.checksum_sha256 ?? 'n/a'}</p>
|
||||
<p>Feature count: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n/a'}</p>
|
||||
<p>BBox: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
|
||||
<p>Aantal objecten: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n.v.t.'}</p>
|
||||
<p>Ruimtelijke begrenzing: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische bestandsgegevens</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Dataset-ID: {selectedDataset.id}</span>
|
||||
<span>Oorspronkelijk bestand: {selectedDataset.original_filename ?? 'n.v.t.'}</span>
|
||||
<span>Bewaard bestand: {selectedDataset.stored_filename ?? 'n.v.t.'}</span>
|
||||
<span>Inhoudstype: {selectedDataset.content_type ?? 'n.v.t.'}</span>
|
||||
<span>Bestandsgrootte: {formatBytes(selectedDataset.size_bytes)}</span>
|
||||
<span>SHA-256: {selectedDataset.checksum_sha256 ?? 'n.v.t.'}</span>
|
||||
</div>
|
||||
</details>
|
||||
{selectedDataset.dataset_type === 'raster' ? (
|
||||
<RasterControls
|
||||
areas={areas}
|
||||
@@ -236,8 +242,9 @@ export function DatasetDetailPanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<h3>Jobs</h3>
|
||||
{jobs.length === 0 ? <p>No jobs yet.</p> : null}
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische verwerking ({jobs.length})</summary>
|
||||
{jobs.length === 0 ? <p>Nog geen verwerkingen.</p> : null}
|
||||
<ul>
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id}>
|
||||
@@ -247,19 +254,20 @@ export function DatasetDetailPanel({
|
||||
{job.result_json ? (
|
||||
<pre className="job-result">{JSON.stringify(job.result_json, null, 2)}</pre>
|
||||
) : null}
|
||||
{job.error_message ? <div className="error">error: {job.error_message}</div> : null}
|
||||
{job.error_message ? <div className="error">Fout: {job.error_message}</div> : null}
|
||||
{job.result_json?.output_dataset_id ? (
|
||||
<button type="button" onClick={() => onPickDerivedDataset(String(job.result_json?.output_dataset_id))}>
|
||||
open derived dataset
|
||||
Open afgeleide databron
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
{loadingDatasetDetails ? <p>Loading dataset details...</p> : null}
|
||||
{datasetDetailError ? <p className="error">Dataset detail error: {datasetDetailError}</p> : null}
|
||||
{loadingDatasetDetails ? <p>Details van de databron laden...</p> : null}
|
||||
{datasetDetailError ? <p className="error">De details konden niet worden geladen: {datasetDetailError}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ interface DatasetPanelProps {
|
||||
|
||||
function formatBytes(value: number | null | undefined): string {
|
||||
if (!value && value !== 0) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
@@ -46,11 +46,11 @@ function formatBytes(value: number | null | undefined): string {
|
||||
|
||||
function formatBounds(bounds: Record<string, number> | null | undefined): string {
|
||||
if (!bounds) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
|
||||
if (!keys.every((key) => key in bounds)) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ interface RasterControlsProps {
|
||||
|
||||
function formatRasterBounds(bounds: number[] | undefined | null): string {
|
||||
if (!bounds || bounds.length < 4) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
const [minX, minY, maxX, maxY] = bounds
|
||||
return `${minX.toFixed(4)}, ${minY.toFixed(4)} -> ${maxX.toFixed(4)}, ${maxY.toFixed(4)}`
|
||||
@@ -101,57 +101,57 @@ export function RasterControls({
|
||||
onRunRasterNdwi,
|
||||
onRunRasterNdbi,
|
||||
}: RasterControlsProps) {
|
||||
const previewState = rasterPreview ? 'ready' : 'not generated'
|
||||
const previewState = rasterPreview ? 'gereed' : 'nog niet aangemaakt'
|
||||
const rasterReadinessItems = [
|
||||
{
|
||||
label: 'Metadata profile',
|
||||
state: selectedRasterMetadata ? 'ready' : 'inspect needed',
|
||||
label: 'Bestandsprofiel',
|
||||
state: selectedRasterMetadata ? 'gereed' : 'controle nodig',
|
||||
detail: selectedRasterMetadata
|
||||
? `${selectedRasterMetadata.driver} | ${selectedRasterMetadata.width} x ${selectedRasterMetadata.height} | ${selectedRasterMetadata.band_count} bands`
|
||||
: 'Run metadata inspection before derived raster operations.',
|
||||
? `${selectedRasterMetadata.driver} | ${selectedRasterMetadata.width} x ${selectedRasterMetadata.height} | ${selectedRasterMetadata.band_count} banden`
|
||||
: 'Controleer eerst de metadata voordat je afgeleide rasters maakt.',
|
||||
},
|
||||
{
|
||||
label: 'CRS readiness',
|
||||
state: selectedRasterMetadata?.crs ? 'ready' : 'missing',
|
||||
detail: selectedRasterMetadata?.crs ?? 'Raster CRS is required for safe map handoff and clipping.',
|
||||
label: 'Coördinatenstelsel',
|
||||
state: selectedRasterMetadata?.crs ? 'gereed' : 'ontbreekt',
|
||||
detail: selectedRasterMetadata?.crs ?? 'Een coördinatenstelsel is vereist om veilig te begrenzen en op de kaart te tonen.',
|
||||
},
|
||||
{
|
||||
label: 'Preview artifact',
|
||||
label: 'Voorbeeldweergave',
|
||||
state: previewState,
|
||||
detail: rasterPreview
|
||||
? `${rasterPreview.preview.path} (${rasterPreview.preview.width ?? 'n/a'} x ${rasterPreview.preview.height ?? 'n/a'})`
|
||||
: 'Generate a preview to confirm visual orientation before AI runs.',
|
||||
? `${rasterPreview.preview.width ?? 'n.v.t.'} x ${rasterPreview.preview.height ?? 'n.v.t.'} pixels`
|
||||
: 'Maak een voorbeeld om de oriëntatie te controleren vóór beeldanalyse.',
|
||||
},
|
||||
{
|
||||
label: 'Tile manifest handoff',
|
||||
state: isRasterTileInputValid ? 'input valid' : 'input blocked',
|
||||
label: 'Beeldtegels',
|
||||
state: isRasterTileInputValid ? 'instellingen geldig' : 'instellingen geblokkeerd',
|
||||
detail: isRasterTileInputValid
|
||||
? 'Tile generation will create the manifest path used by detection and segmentation requests.'
|
||||
: 'Tile size must be positive and overlap must stay below tile size.',
|
||||
? 'GeoIntel kan een manifest maken voor detectie en segmentatie.'
|
||||
: 'De tegelgrootte moet positief zijn en de overlap moet kleiner blijven.',
|
||||
},
|
||||
{
|
||||
label: 'Clip AOI',
|
||||
state: areas.length > 0 ? 'available' : 'missing',
|
||||
detail: areas.length > 0 ? `${areas.length} project area${areas.length === 1 ? '' : 's'} available.` : 'Create an area before clipping.',
|
||||
label: 'Werkgebied voor begrenzing',
|
||||
state: areas.length > 0 ? 'beschikbaar' : 'ontbreekt',
|
||||
detail: areas.length > 0 ? `${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'} beschikbaar.` : 'Maak eerst een gebied aan.',
|
||||
},
|
||||
]
|
||||
const rasterGuardrailItems = [
|
||||
!selectedDatasetId ? 'Select a raster dataset before running raster operations.' : null,
|
||||
rasterUnavailableMessage ? `Raster unavailable: ${rasterUnavailableMessage}` : null,
|
||||
selectedDatasetId && !selectedRasterMetadata ? 'Inspect metadata before reprojecting, clipping, tiling or computing indices.' : null,
|
||||
selectedRasterMetadata && !selectedRasterMetadata?.crs ? 'CRS is missing; geospatial handoff should be fixed before downstream QA.' : null,
|
||||
!rasterPreview ? 'Preview is not generated yet; create it before visual review.' : null,
|
||||
!isRasterTileInputValid ? 'Tile settings are invalid; update tile size and overlap before generating a manifest.' : null,
|
||||
areas.length === 0 ? 'No project area exists yet, so raster clipping is disabled.' : null,
|
||||
!selectedDatasetId ? 'Kies eerst een rasterbestand.' : null,
|
||||
rasterUnavailableMessage ? `Raster niet beschikbaar: ${rasterUnavailableMessage}` : null,
|
||||
selectedDatasetId && !selectedRasterMetadata ? 'Controleer de metadata vóór herprojectie, begrenzing, tegels of indexberekening.' : null,
|
||||
selectedRasterMetadata && !selectedRasterMetadata?.crs ? 'Het coördinatenstelsel ontbreekt; herstel dit vóór verdere ruimtelijke analyse.' : null,
|
||||
!rasterPreview ? 'Er is nog geen voorbeeldweergave gemaakt.' : null,
|
||||
!isRasterTileInputValid ? 'De tegelinstellingen zijn ongeldig.' : null,
|
||||
areas.length === 0 ? 'Er bestaat nog geen werkgebied; begrenzen is daarom uitgeschakeld.' : null,
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
return (
|
||||
<div className="dataset-tool-panel raster-tool-panel">
|
||||
<section className="raster-readiness-surface" aria-label="Raster pipeline readiness">
|
||||
<section className="raster-readiness-surface" aria-label="Gereedheid van rasterverwerking">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Raster pipeline readiness</h3>
|
||||
<p>Operational state for metadata, preview, clipping and tile-manifest handoff.</p>
|
||||
<h3>Gereedheid van het raster</h3>
|
||||
<p>Status van metadata, voorbeeldweergave, begrenzing en beeldtegels.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="raster-readiness-grid">
|
||||
@@ -164,54 +164,54 @@ export function RasterControls({
|
||||
))}
|
||||
</div>
|
||||
<div className="raster-manifest-handoff">
|
||||
<span>Latest tile manifest</span>
|
||||
<span>Laatste beeldtegelmanifest</span>
|
||||
<p>
|
||||
{latestRasterTileManifestPath ||
|
||||
'No tile manifest generated yet. Generate tiles before handing this raster to Detection Lab.'}
|
||||
'Nog geen beeldtegels gemaakt. Maak beeldtegels voordat je dit raster gebruikt voor beeldanalyse.'}
|
||||
</p>
|
||||
{latestRasterTileManifest ? (
|
||||
<dl className="raster-manifest-details">
|
||||
<div>
|
||||
<dt>Tile count</dt>
|
||||
<dd>{latestRasterTileManifest.count ?? 'n/a'}</dd>
|
||||
<dt>Aantal tegels</dt>
|
||||
<dd>{latestRasterTileManifest.count ?? 'n.v.t.'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tile size</dt>
|
||||
<dd>{latestRasterTileManifest.tile_size ?? 'n/a'}</dd>
|
||||
<dt>Tegelgrootte</dt>
|
||||
<dd>{latestRasterTileManifest.tile_size ?? 'n.v.t.'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Overlap</dt>
|
||||
<dd>{latestRasterTileManifest.overlap ?? 'n/a'}</dd>
|
||||
<dd>{latestRasterTileManifest.overlap ?? 'n.v.t.'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tile set</dt>
|
||||
<dd>{latestRasterTileManifest.tile_set_id ?? 'n/a'}</dd>
|
||||
<dt>Tegelset</dt>
|
||||
<dd>{latestRasterTileManifest.tile_set_id ?? 'n.v.t.'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Use in Detection Lab with current manifest"
|
||||
aria-label="Gebruik huidig manifest voor gebouwdetectie"
|
||||
onClick={onUseTileManifestForDetection}
|
||||
disabled={!latestRasterTileManifestPath}
|
||||
>
|
||||
Use manifest in Detection Lab
|
||||
Gebruik voor gebouwdetectie
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Use in Segmentation Lab with current manifest"
|
||||
aria-label="Gebruik huidig manifest voor segmentatie"
|
||||
onClick={onUseTileManifestForSegmentation}
|
||||
disabled={!latestRasterTileManifestPath}
|
||||
>
|
||||
Use manifest in Segmentation Lab
|
||||
Gebruik voor segmentatie
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="raster-guardrail-surface" aria-label="Processing guardrails">
|
||||
<section className="raster-guardrail-surface" aria-label="Veiligheidscontroles">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Processing guardrails</h3>
|
||||
<p>Checks that protect downstream GIS and AI operations from ambiguous raster state.</p>
|
||||
<h3>Veiligheidscontroles</h3>
|
||||
<p>Controles die onduidelijke of ruimtelijk onveilige verwerking voorkomen.</p>
|
||||
</div>
|
||||
</div>
|
||||
{rasterGuardrailItems.length > 0 ? (
|
||||
@@ -221,83 +221,83 @@ export function RasterControls({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="raster-readiness-state">Raster controls are ready for safe operation.</p>
|
||||
<p className="raster-readiness-state">Het raster is gereed voor veilige verwerking.</p>
|
||||
)}
|
||||
</section>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Raster metadata</h4>
|
||||
<p>Raster driver: {selectedRasterMetadata?.driver ?? 'n/a'}</p>
|
||||
<p>Raster size: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n/a'}</p>
|
||||
<p>Raster checksum: {selectedRasterMetadata?.checksum_sha256 ?? 'n/a'}</p>
|
||||
<h4 className="dataset-tool-heading">Rastermetadata</h4>
|
||||
<p>Bestandsdriver: {selectedRasterMetadata?.driver ?? 'n.v.t.'}</p>
|
||||
<p>Afmetingen: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n.v.t.'}</p>
|
||||
<p>Controlesom: {selectedRasterMetadata?.checksum_sha256 ?? 'n.v.t.'}</p>
|
||||
<p>
|
||||
Profile: CRS {selectedRasterMetadata?.crs ?? 'n/a'} | bands {selectedRasterMetadata?.band_count ?? 'n/a'} | dtype {
|
||||
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n/a'}
|
||||
Profiel: CRS {selectedRasterMetadata?.crs ?? 'n.v.t.'} | banden {selectedRasterMetadata?.band_count ?? 'n.v.t.'} | datatype {
|
||||
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n.v.t.'}
|
||||
</p>
|
||||
<p>Bounds: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
|
||||
<p>Resolution: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n/a'}</p>
|
||||
{rasterUnavailableMessage ? <p className="error">Raster unavailable: {rasterUnavailableMessage}</p> : null}
|
||||
<p>Begrenzing: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
|
||||
<p>Resolutie: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n.v.t.'}</p>
|
||||
{rasterUnavailableMessage ? <p className="error">Raster niet beschikbaar: {rasterUnavailableMessage}</p> : null}
|
||||
</div>
|
||||
<h3>Raster operations</h3>
|
||||
<p>Available operations: inspect, stats, reproject, preview, clip by selected area, tile generation.</p>
|
||||
<p>Preview: {rasterPreview?.preview.path ?? 'not generated'}</p>
|
||||
<p>Preview size: {rasterPreview?.preview.width ?? 'n/a'} x {rasterPreview?.preview.height ?? 'n/a'}</p>
|
||||
<h3>Rasterbewerkingen</h3>
|
||||
<p>Controleer metadata en statistieken, herprojecteer, begrens of maak beeldtegels.</p>
|
||||
<p>Voorbeeld: {rasterPreview ? 'aangemaakt' : 'nog niet aangemaakt'}</p>
|
||||
<p>Afmetingen voorbeeld: {rasterPreview?.preview.width ?? 'n.v.t.'} x {rasterPreview?.preview.height ?? 'n.v.t.'}</p>
|
||||
<button type="button" onClick={onRunRasterInspect}>
|
||||
Inspect raster metadata
|
||||
Metadata controleren
|
||||
</button>
|
||||
<button type="button" onClick={onRunRasterPreview} disabled={!selectedDatasetId}>
|
||||
Generate preview
|
||||
Voorbeeld maken
|
||||
</button>
|
||||
<button type="button" onClick={onRunRasterStats}>
|
||||
Compute band statistics
|
||||
Bandstatistieken berekenen
|
||||
</button>
|
||||
{selectedRasterStats ? (
|
||||
<div>
|
||||
<h4>Band statistics</h4>
|
||||
<p>Generated: {selectedRasterStats.generated_at ?? 'n/a'}</p>
|
||||
<h4>Bandstatistieken</h4>
|
||||
<p>Aangemaakt: {selectedRasterStats.generated_at ?? 'n.v.t.'}</p>
|
||||
<ul>
|
||||
{selectedRasterStats.bands.map((band) => (
|
||||
<li key={band.band_index}>
|
||||
Band {band.band_index}: min {band.min ?? 'n/a'}, max {band.max ?? 'n/a'}, mean {band.mean ?? 'n/a'}, std {band.std ?? 'n/a'},
|
||||
valid {band.valid_pixel_count}, nodata ratio {(band.nodata_ratio * 100).toFixed(2)}%, dtype {band.dtype ?? 'n/a'}
|
||||
Band {band.band_index}: minimum {band.min ?? 'n.v.t.'}, maximum {band.max ?? 'n.v.t.'}, gemiddelde {band.mean ?? 'n.v.t.'}, standaardafwijking {band.std ?? 'n.v.t.'},
|
||||
geldig {band.valid_pixel_count}, aandeel zonder data {(band.nodata_ratio * 100).toFixed(2)}%, datatype {band.dtype ?? 'n.v.t.'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Reproject raster</h4>
|
||||
<p className="dataset-tool-helper">Create a derived raster in a target CRS with the selected resampling method.</p>
|
||||
<h4 className="dataset-tool-heading">Raster herprojecteren</h4>
|
||||
<p className="dataset-tool-helper">Maak een afgeleid raster in een ander coördinatenstelsel.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Reproject CRS</span>
|
||||
<span className="dataset-tool-label">Doel-CRS</span>
|
||||
<input
|
||||
value={rasterReprojectCrs}
|
||||
onChange={(event) => onSetRasterReprojectCrs(event.target.value)}
|
||||
placeholder="EPSG:31370"
|
||||
/>
|
||||
<span className="dataset-tool-helper">Target CRS for the derived raster artifact.</span>
|
||||
<span className="dataset-tool-helper">Coördinatenstelsel van het afgeleide raster.</span>
|
||||
</label>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Resampling</span>
|
||||
<span className="dataset-tool-label">Herbemonstering</span>
|
||||
<select value={rasterReprojectResampling} onChange={(event) => onSetRasterReprojectResampling(event.target.value)}>
|
||||
<option value="nearest">nearest</option>
|
||||
<option value="bilinear">bilinear</option>
|
||||
<option value="cubic">cubic</option>
|
||||
</select>
|
||||
<span className="dataset-tool-helper">Nearest preserves classes; bilinear/cubic smooth continuous rasters.</span>
|
||||
<span className="dataset-tool-helper">Dichtstbijzijnd behoudt klassen; bilineair en kubisch verzachten continue rasters.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunRasterReproject}>
|
||||
Reproject raster
|
||||
Raster herprojecteren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Clip raster</h4>
|
||||
<p className="dataset-tool-helper">Clip the selected raster to an existing project area.</p>
|
||||
<h4 className="dataset-tool-heading">Raster begrenzen</h4>
|
||||
<p className="dataset-tool-helper">Beperk het raster tot een bestaand werkgebied.</p>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Clip area</span>
|
||||
<span className="dataset-tool-label">Werkgebied</span>
|
||||
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
@@ -308,24 +308,24 @@ export function RasterControls({
|
||||
</label>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunRasterClip} disabled={areas.length === 0}>
|
||||
Clip raster by area
|
||||
Raster begrenzen
|
||||
</button>
|
||||
</div>
|
||||
{areas.length === 0 ? <p className="dataset-tool-error">Create an area before raster clipping.</p> : null}
|
||||
{areas.length === 0 ? <p className="dataset-tool-error">Maak eerst een werkgebied aan.</p> : null}
|
||||
</div>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Generate tiles</h4>
|
||||
<p className="dataset-tool-helper">Create a tile manifest for downstream detection or segmentation runs.</p>
|
||||
<h4 className="dataset-tool-heading">Beeldtegels maken</h4>
|
||||
<p className="dataset-tool-helper">Maak beeldtegels voor detectie of segmentatie.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Tile size</span>
|
||||
<span className="dataset-tool-label">Tegelgrootte</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={rasterTileSize}
|
||||
onChange={(event) => onSetRasterTileSize(Number(event.target.value))}
|
||||
/>
|
||||
<span className="dataset-tool-helper">{'Tile size must be > 0.'}</span>
|
||||
<span className="dataset-tool-helper">De tegelgrootte moet groter zijn dan nul.</span>
|
||||
</label>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Overlap</span>
|
||||
@@ -335,86 +335,86 @@ export function RasterControls({
|
||||
value={rasterTileOverlap}
|
||||
onChange={(event) => onSetRasterTileOverlap(Number(event.target.value))}
|
||||
/>
|
||||
<span className="dataset-tool-helper">Overlap must be smaller than tile size.</span>
|
||||
<span className="dataset-tool-helper">De overlap moet kleiner zijn dan de tegelgrootte.</span>
|
||||
</label>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Tile basename</span>
|
||||
<span className="dataset-tool-label">Bestandsnaamvoorvoegsel</span>
|
||||
<input
|
||||
value={rasterTileOutputName}
|
||||
onChange={(event) => onSetRasterTileOutputName(event.target.value)}
|
||||
placeholder="optional"
|
||||
placeholder="optioneel"
|
||||
/>
|
||||
<span className="dataset-tool-helper">Optional artifact name prefix for generated tiles.</span>
|
||||
<span className="dataset-tool-helper">Optioneel voorvoegsel voor de aangemaakte tegels.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunRasterTile} disabled={!isRasterTileInputValid}>
|
||||
Generate tiles
|
||||
Beeldtegels maken
|
||||
</button>
|
||||
</div>
|
||||
{!isRasterTileInputValid ? (
|
||||
<p className="dataset-tool-error">
|
||||
Tile size must be {'>'} 0 and overlap must be {'>='} 0 and smaller than tile size.
|
||||
De tegelgrootte moet groter zijn dan nul en de overlap moet positief en kleiner zijn dan de tegelgrootte.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<h4>Spectral indices</h4>
|
||||
<h4>Spectrale indexen</h4>
|
||||
<div>
|
||||
<p>Use available band indexes from the raster file (1-based).</p>
|
||||
<p>Gebruik de beschikbare bandnummers uit het rasterbestand, beginnend bij 1.</p>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">NDVI</h4>
|
||||
<p className="dataset-tool-helper">Vegetation index from NIR and red bands.</p>
|
||||
<p className="dataset-tool-helper">Vegetatie-index op basis van nabij-infrarood en rood.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">NIR band</span>
|
||||
<span className="dataset-tool-label">NIR-band</span>
|
||||
<input type="number" min={1} value={ndviNirBand} onChange={(event) => onSetNdviNirBand(Number(event.target.value))} />
|
||||
</label>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Red band</span>
|
||||
<span className="dataset-tool-label">Rode band</span>
|
||||
<input type="number" min={1} value={ndviRedBand} onChange={(event) => onSetNdviRedBand(Number(event.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunRasterNdvi}>
|
||||
Compute NDVI
|
||||
NDVI berekenen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">NDWI</h4>
|
||||
<p className="dataset-tool-helper">Water index from NIR and green bands.</p>
|
||||
<p className="dataset-tool-helper">Waterindex op basis van nabij-infrarood en groen.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">NIR band</span>
|
||||
<span className="dataset-tool-label">NIR-band</span>
|
||||
<input type="number" min={1} value={ndwiNirBand} onChange={(event) => onSetNdwiNirBand(Number(event.target.value))} />
|
||||
</label>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Green band</span>
|
||||
<span className="dataset-tool-label">Groene band</span>
|
||||
<input type="number" min={1} value={ndwiGreenBand} onChange={(event) => onSetNdwiGreenBand(Number(event.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunRasterNdwi}>
|
||||
Compute NDWI
|
||||
NDWI berekenen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">NDBI</h4>
|
||||
<p className="dataset-tool-helper">Built-up index from SWIR and NIR bands.</p>
|
||||
<p className="dataset-tool-helper">Bebouwingsindex op basis van kortgolvig en nabij-infrarood.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">SWIR band</span>
|
||||
<span className="dataset-tool-label">SWIR-band</span>
|
||||
<input type="number" min={1} value={ndbiSwirBand} onChange={(event) => onSetNdbiSwirBand(Number(event.target.value))} />
|
||||
</label>
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">NIR band</span>
|
||||
<span className="dataset-tool-label">NIR-band</span>
|
||||
<input type="number" min={1} value={ndbiNirBand} onChange={(event) => onSetNdbiNirBand(Number(event.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunRasterNdbi}>
|
||||
Compute NDBI
|
||||
NDBI berekenen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,13 +25,13 @@ export function VectorControls({
|
||||
}: VectorControlsProps) {
|
||||
return (
|
||||
<div className="dataset-tool-panel vector-tool-panel">
|
||||
<h3>Vector operations</h3>
|
||||
<h3>Vectorbewerkingen</h3>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Clip vector</h4>
|
||||
<p className="dataset-tool-helper">Clip features to the selected project area.</p>
|
||||
<h4 className="dataset-tool-heading">Begrenzen tot gebied</h4>
|
||||
<p className="dataset-tool-helper">Beperk objecten tot het gekozen werkgebied.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Clip area</span>
|
||||
<span className="dataset-tool-label">Werkgebied</span>
|
||||
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
@@ -43,39 +43,39 @@ export function VectorControls({
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunVectorClip} disabled={areas.length === 0}>
|
||||
Run clip
|
||||
Begrenzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Buffer vector</h4>
|
||||
<p className="dataset-tool-helper">Create a 25m buffer using the existing vector operation defaults.</p>
|
||||
<h4 className="dataset-tool-heading">Invloedszone</h4>
|
||||
<p className="dataset-tool-helper">Maak een zone van 25 meter rond ieder object.</p>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunVectorBuffer}>
|
||||
Run buffer (25m)
|
||||
Zone van 25 m maken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dataset-tool-group">
|
||||
<h4 className="dataset-tool-heading">Intersect vector</h4>
|
||||
<p className="dataset-tool-helper">Intersect with another persisted vector dataset.</p>
|
||||
<h4 className="dataset-tool-heading">Lagen doorsnijden</h4>
|
||||
<p className="dataset-tool-helper">Bereken de overlap met een andere bewaarde vectorlaag.</p>
|
||||
<div className="dataset-tool-grid">
|
||||
<label className="dataset-tool-field">
|
||||
<span className="dataset-tool-label">Intersect target</span>
|
||||
<span className="dataset-tool-label">Tweede kaartlaag</span>
|
||||
<select value={selectedIntersectTargetId} onChange={(event) => onSetSelectedIntersectTargetId(event.target.value)}>
|
||||
<option value="">auto first vector</option>
|
||||
<option value="">Automatisch de eerste geschikte laag</option>
|
||||
{availableVectorTargets.map((target) => (
|
||||
<option key={target.id} value={target.id}>
|
||||
{target.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="dataset-tool-helper">Leave automatic to use the first available vector target.</span>
|
||||
<span className="dataset-tool-helper">Laat automatisch staan om de eerste beschikbare vectorlaag te gebruiken.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="dataset-tool-action-row">
|
||||
<button type="button" onClick={onRunVectorIntersect}>
|
||||
Run intersect
|
||||
Overlap berekenen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,17 +12,11 @@ import type {
|
||||
} from '../../types'
|
||||
import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../hooks/useDetectionWorkflow'
|
||||
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
|
||||
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
|
||||
|
||||
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
|
||||
const DEFAULT_DETECTION_PAGE_SIZE = 50
|
||||
|
||||
function detectionModelLabel(model: DetectionModelCapability): string {
|
||||
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
|
||||
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
|
||||
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
|
||||
return model.display_name
|
||||
}
|
||||
|
||||
function detectionQualityInterpretation(f1: number | null | undefined): string {
|
||||
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
|
||||
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
|
||||
@@ -31,6 +25,18 @@ function detectionQualityInterpretation(f1: number | null | undefined): string {
|
||||
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
|
||||
}
|
||||
|
||||
function formatDetectionRunLabel(run: DetectionRunRead): string {
|
||||
const status = run.status === 'completed' ? 'afgerond' : run.status
|
||||
const timestamp = run.finished_at ?? run.created_at
|
||||
const dateLabel = timestamp
|
||||
? new Intl.DateTimeFormat('nl-BE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(timestamp))
|
||||
: 'datum onbekend'
|
||||
return `${run.model_name || 'Gebouwdetectie'} · ${status} · ${dateLabel}`
|
||||
}
|
||||
|
||||
interface CalibrationRow {
|
||||
analysisRunId: string
|
||||
qualityCheckId: string
|
||||
@@ -284,255 +290,29 @@ export function DetectionLab({
|
||||
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
|
||||
</p>
|
||||
|
||||
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
|
||||
<summary>
|
||||
<span>Technische modelinformatie</span>
|
||||
<strong>{detectionModels.length} registraties</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-state-stack">
|
||||
{loadingDetectionModels ? (
|
||||
<div className="result-state result-state-loading">
|
||||
<strong>Loading detection models.</strong>
|
||||
<p>Checking backend model registry availability.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionModelError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Detection model registry unavailable.</strong>
|
||||
<p>{detectionModelError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{modelAssetError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Local model assets unavailable.</strong>
|
||||
<p>{modelAssetError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionModels.length === 0 && !loadingDetectionModels ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>No detection models reported by backend.</strong>
|
||||
<p>Refresh models after the backend is reachable.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ul className="model-list">
|
||||
{detectionModels.map((model) => (
|
||||
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
|
||||
<strong>{detectionModelLabel(model)}</strong>
|
||||
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
|
||||
<div className="entity-meta">
|
||||
<span>{model.model_id}</span>
|
||||
<span>{model.framework}</span>
|
||||
<span>{model.task_type}</span>
|
||||
</div>
|
||||
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
|
||||
<p className="muted">{model.limitation_message}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{selectedDetectionModelId === 'yolo-configured' ? (
|
||||
<details className="ai-lab-model-surface" aria-label="Local model asset selection">
|
||||
<summary>
|
||||
<span>Modelkeuze voor beheerders</span>
|
||||
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>Lokaal modelbestand</h3>
|
||||
<p>GeoIntel kiest automatisch het actieve lokale model. Een beheerder kan hier bewust een ander reeds aanwezig, alleen-lezen modelbestand kiezen.</p>
|
||||
</div>
|
||||
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="model-asset-guidance">
|
||||
<strong>Gevalideerde profielen</strong>
|
||||
<p>
|
||||
Een profiel koppelt een lokaal model aan een gemeten zekerheidsdrempel. Een andere keuze geldt alleen voor de huidige analyse en wijzigt de serverconfiguratie niet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="operator-profile-grid" aria-label="Configured YOLO operator profiles">
|
||||
{DETECTION_OPERATOR_PROFILES.map((profile) => {
|
||||
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
|
||||
const profileSelected =
|
||||
selectedModelAssetId === profile.modelAssetId &&
|
||||
Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001
|
||||
return (
|
||||
<div
|
||||
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
|
||||
key={profile.id}
|
||||
>
|
||||
<div className="operator-profile-card-header">
|
||||
<strong>{profile.displayName}</strong>
|
||||
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat · extra controle vereist'}
|
||||
</span>
|
||||
</div>
|
||||
<p>{profile.description}</p>
|
||||
<div className="operator-profile-metrics">
|
||||
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
|
||||
<span>precision {profile.precision.toFixed(3)}</span>
|
||||
<span>recall {profile.recall.toFixed(3)}</span>
|
||||
<span>F1 {profile.f1.toFixed(3)}</span>
|
||||
<span>testgebieden {profile.positiveSampleCount}</span>
|
||||
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
|
||||
</div>
|
||||
<div className="entity-meta">
|
||||
<span>modelbestand: {profile.modelAssetId}</span>
|
||||
<span>beoordeling: {profile.promotionRecommendation}</span>
|
||||
<span>beschikbaar: {profileAsset ? 'ja' : 'niet gekoppeld'}</span>
|
||||
</div>
|
||||
<p className="field-guidance">{profile.limitationMessage}</p>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => onApplyOperatorProfile(profile)}
|
||||
disabled={!profileAsset}
|
||||
>
|
||||
Profiel gebruiken
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedModelAsset ? (
|
||||
<div className="model-asset-guidance">
|
||||
<strong>Status gekozen model</strong>
|
||||
<p>
|
||||
{selectedModelAsset.display_name} wordt voor deze analyse gebruikt. De standaard serverconfiguratie blijft ongewijzigd.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<label>
|
||||
Lokaal modelbestand
|
||||
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
|
||||
<option value="">Kies een lokaal modelbestand</option>
|
||||
{modelAssets.map((asset) => (
|
||||
<option key={asset.model_asset_id} value={asset.model_asset_id}>
|
||||
{asset.display_name} {asset.active ? '(active)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{modelAssets.length === 0 && !loadingDetectionModels ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen lokaal modelbestand gevonden.</strong>
|
||||
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedModelAsset ? (
|
||||
<div className="result-summary-card">
|
||||
<p>File: {selectedModelAsset.filename}</p>
|
||||
<p>Status: {selectedModelAsset.status}</p>
|
||||
<p>Active runtime env model: {selectedModelAsset.active ? 'yes' : 'no'}</p>
|
||||
<p>will_download_models: {selectedModelAsset.will_download_models ? 'yes' : 'no'}</p>
|
||||
<p>Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
|
||||
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
|
||||
<p>Path: {selectedModelAsset.model_path}</p>
|
||||
<p>{selectedModelAsset.limitation_message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<details className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
|
||||
<summary>
|
||||
<span>Technische runtimecontrole</span>
|
||||
<strong>{yoloRuntimeReady ? 'gereed' : yoloPreflight?.status ?? 'niet geladen'}</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>YOLO runtime preflight</h3>
|
||||
<p>Read-only runtime status. This does not load a model, run inference or download weights.</p>
|
||||
</div>
|
||||
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
|
||||
Refresh preflight
|
||||
</button>
|
||||
</div>
|
||||
<div className="ai-lab-state-stack">
|
||||
{loadingYoloPreflight ? (
|
||||
<div className="result-state result-state-loading">
|
||||
<strong>Loading YOLO preflight.</strong>
|
||||
<p>Checking backend runtime configuration and optional dependency visibility.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{yoloPreflightError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>YOLO preflight unavailable.</strong>
|
||||
<p>{yoloPreflightError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>No YOLO preflight loaded.</strong>
|
||||
<p>Refresh preflight to inspect the live backend AI runtime before running configured YOLO.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{yoloPreflight ? (
|
||||
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>Status: {yoloPreflight.status}</h3>
|
||||
<p>{yoloPreflight.message}</p>
|
||||
</div>
|
||||
<span className={yoloPreflight.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="lab-readiness-grid">
|
||||
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>YOLO enabled</span>
|
||||
<strong>{yoloPreflight.checks.enabled ? 'true' : 'false'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Dependencies</span>
|
||||
<strong>{yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Local model file</span>
|
||||
<strong>{yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>CUDA</span>
|
||||
<strong>{yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Tile manifest validation</span>
|
||||
<strong>{yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Tile count</span>
|
||||
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-meta">
|
||||
<span>torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'}</span>
|
||||
<span>ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'}</span>
|
||||
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
|
||||
<span>will_run_inference: {String(yoloPreflight.will_run_inference)}</span>
|
||||
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
|
||||
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
|
||||
<span>model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
<DetectionModelManagement
|
||||
detectionModels={detectionModels}
|
||||
modelAssets={modelAssets}
|
||||
loadingDetectionModels={loadingDetectionModels}
|
||||
detectionModelError={detectionModelError}
|
||||
modelAssetError={modelAssetError}
|
||||
selectedDetectionModelId={selectedDetectionModelId}
|
||||
selectedModelAssetId={selectedModelAssetId}
|
||||
detectionConfidenceThreshold={detectionConfidenceThreshold}
|
||||
yoloPreflight={yoloPreflight}
|
||||
loadingYoloPreflight={loadingYoloPreflight}
|
||||
yoloPreflightError={yoloPreflightError}
|
||||
onRefreshYoloPreflight={onRefreshYoloPreflight}
|
||||
onSelectModelAsset={onSelectModelAsset}
|
||||
onApplyOperatorProfile={onApplyOperatorProfile}
|
||||
/>
|
||||
|
||||
<div className="lab-block">
|
||||
<div className="ai-lab-run-surface" aria-label="Detection run controls">
|
||||
<div className="ai-lab-run-surface" aria-label="Gebouwdetectie starten">
|
||||
<h3>Nieuwe beeldanalyse</h3>
|
||||
<div
|
||||
className={guidedDetectionReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
|
||||
aria-label="Detection run readiness"
|
||||
aria-label="Startklaar voor gebouwdetectie"
|
||||
>
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
@@ -720,17 +500,22 @@ export function DetectionLab({
|
||||
) : null}
|
||||
{detectionRunResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {detectionRunResult.status}</p>
|
||||
<p>Uitleg: {detectionRunResult.message}</p>
|
||||
<p>Analyse: {detectionRunResult.analysis_run_id}</p>
|
||||
<p>Verwerking: {detectionRunResult.job_id}</p>
|
||||
<p>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : 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}
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische verwerking</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Analyserun-ID: {detectionRunResult.analysis_run_id}</span>
|
||||
<span>Taak-ID: {detectionRunResult.job_id}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Guided calibration runner">
|
||||
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Modelkalibratie voor beheerders">
|
||||
<summary>
|
||||
<span>Modelkalibratie voor beheerders</span>
|
||||
<strong>{detectionCalibrationRows.length > 0 ? `${detectionCalibrationRows.length} drempels getest` : 'gesloten'}</strong>
|
||||
@@ -738,28 +523,28 @@ export function DetectionLab({
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>Guided calibration runner</h3>
|
||||
<p>This runs real configured YOLO jobs and QA comparisons for each threshold. It does not promote or mutate model files.</p>
|
||||
<h3>Zekerheidsdrempels vergelijken</h3>
|
||||
<p>Voert het lokale model en een kwaliteitscontrole uit voor iedere drempel. Modelbestanden worden niet gewijzigd.</p>
|
||||
</div>
|
||||
<span className={calibrationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{calibrationRunReady ? 'ready' : 'needs dataset, model, manifest and reference'}
|
||||
{calibrationRunReady ? 'startklaar' : 'luchtbeeld, model, beeldtegels en referentie vereist'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="lab-form-grid">
|
||||
<label>
|
||||
Threshold set
|
||||
Zekerheidsdrempels
|
||||
<input
|
||||
type="text"
|
||||
value={calibrationThresholdText}
|
||||
onChange={(event) => onSetCalibrationThresholdText(event.target.value)}
|
||||
placeholder="0.50 0.25 0.15"
|
||||
/>
|
||||
<span className="field-guidance">Use spaces, commas or semicolons. Values must be between 0 and 1.</span>
|
||||
<span className="field-guidance">Scheid waarden met spaties, komma's of puntkomma's. Iedere waarde ligt tussen 0 en 1.</span>
|
||||
</label>
|
||||
<label>
|
||||
Reference dataset
|
||||
Referentielaag
|
||||
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
|
||||
<option value="">Select reference dataset</option>
|
||||
<option value="">Kies een referentielaag</option>
|
||||
{referenceDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{dataset.name}
|
||||
@@ -774,30 +559,30 @@ export function DetectionLab({
|
||||
onClick={onRunCalibration}
|
||||
disabled={runningDetectionCalibration || !calibrationRunReady}
|
||||
>
|
||||
Run calibration sweep
|
||||
Drempels vergelijken
|
||||
</button>
|
||||
{detectionCalibrationError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Calibration sweep failed.</strong>
|
||||
<strong>De kalibratievergelijking is mislukt.</strong>
|
||||
<p>{detectionCalibrationError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionCalibrationRows.length > 0 ? (
|
||||
<div className="calibration-progress-panel" aria-label="Calibration run progress">
|
||||
<div className="calibration-progress-panel" aria-label="Voortgang modelkalibratie">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Calibration run progress</h3>
|
||||
<p className="muted">Each row is backed by a persisted detection run and QA check when successful.</p>
|
||||
<h3>Voortgang modelkalibratie</h3>
|
||||
<p className="muted">Iedere geslaagde rij is gekoppeld aan een bewaarde beeldanalyse en kwaliteitscontrole.</p>
|
||||
</div>
|
||||
<div className="panel-action-row">
|
||||
<span className="count-pill">{detectionCalibrationRows.length} thresholds</span>
|
||||
<span className="count-pill">{detectionCalibrationRows.length} drempels</span>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
|
||||
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
|
||||
>
|
||||
Download calibration summary
|
||||
Samenvatting downloaden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -805,15 +590,15 @@ export function DetectionLab({
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Threshold</th>
|
||||
<th>Drempel</th>
|
||||
<th>Status</th>
|
||||
<th>Detections</th>
|
||||
<th>Precision</th>
|
||||
<th>Recall</th>
|
||||
<th>Objecten</th>
|
||||
<th>Precisie</th>
|
||||
<th>Herkenningsgraad</th>
|
||||
<th>F1</th>
|
||||
<th>False positives</th>
|
||||
<th>False negatives</th>
|
||||
<th>Evidence</th>
|
||||
<th>Onterecht gevonden</th>
|
||||
<th>Gemist</th>
|
||||
<th>Kaartbewijs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -821,21 +606,21 @@ export function DetectionLab({
|
||||
<tr key={row.threshold}>
|
||||
<td>{row.threshold.toFixed(2)}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>{row.detection_count ?? 'n/a'}</td>
|
||||
<td>{row.detection_count ?? 'n.v.t.'}</td>
|
||||
<td>{formatNullableNumber(row.precision ?? null, 3)}</td>
|
||||
<td>{formatNullableNumber(row.recall ?? null, 3)}</td>
|
||||
<td>{formatNullableNumber(row.f1_score ?? null, 3)}</td>
|
||||
<td>{row.false_positives ?? 'n/a'}</td>
|
||||
<td>{row.false_negatives ?? 'n/a'}</td>
|
||||
<td>{row.false_positives ?? 'n.v.t.'}</td>
|
||||
<td>{row.false_negatives ?? 'n.v.t.'}</td>
|
||||
<td>
|
||||
<button
|
||||
className="secondary-action table-action"
|
||||
type="button"
|
||||
onClick={() => row.quality_check_id ? onOpenCalibrationEvidence(row.quality_check_id) : undefined}
|
||||
disabled={!row.quality_check_id || row.status !== 'success'}
|
||||
aria-label={`Open evidence map for threshold ${row.threshold.toFixed(2)}`}
|
||||
aria-label={`Open kaartbewijs voor drempel ${row.threshold.toFixed(2)}`}
|
||||
>
|
||||
Open evidence map
|
||||
Toon op kaart
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -846,14 +631,14 @@ export function DetectionLab({
|
||||
</div>
|
||||
) : (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>No calibration sweep has been run in this session.</strong>
|
||||
<p>Choose a reference dataset and threshold set, then start the explicit sweep.</p>
|
||||
<strong>In deze sessie zijn nog geen drempels vergeleken.</strong>
|
||||
<p>Kies een referentielaag en drempelreeks en start daarna de vergelijking.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="ai-lab-results-surface" aria-label="Detection results">
|
||||
<div className="ai-lab-results-surface" aria-label="Resultaten van de beeldanalyse">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Gevonden objecten</h3>
|
||||
@@ -875,7 +660,7 @@ export function DetectionLab({
|
||||
<option value="">Kies een bewaarde analyse</option>
|
||||
{detectionRuns.map((run) => (
|
||||
<option key={run.id} value={run.id}>
|
||||
{run.model_name || 'Gebouwdetectie'} · {run.status} · {run.id}
|
||||
{formatDetectionRunLabel(run)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -918,7 +703,7 @@ export function DetectionLab({
|
||||
</div>
|
||||
{detectionItems.length > 0 ? (
|
||||
<>
|
||||
<div className="pagination-toolbar" aria-label="Detection result pagination">
|
||||
<div className="pagination-toolbar" aria-label="Paginering van gevonden objecten">
|
||||
<p className="pagination-summary" aria-live="polite">
|
||||
<strong>{detectionPageStart + 1}-{detectionPageEnd}</strong>
|
||||
<span>van {detectionItems.length}</span>
|
||||
@@ -1005,7 +790,7 @@ export function DetectionLab({
|
||||
</div>
|
||||
{calibrationRows.length > 0 ? (
|
||||
<>
|
||||
<div className="calibration-summary-grid" aria-label="Calibration comparison winners">
|
||||
<div className="calibration-summary-grid" aria-label="Beste kalibratieresultaten">
|
||||
<CalibrationSummaryCard title="Beste F1-score" row={bestF1Candidate} metric="f1" />
|
||||
<CalibrationSummaryCard title="Beste precisie" row={bestPrecisionCandidate} metric="precision" />
|
||||
<CalibrationSummaryCard title="Minste foutieve meldingen" row={lowestFalsePositivePressureCandidate} metric="falsePositives" />
|
||||
@@ -1018,7 +803,7 @@ export function DetectionLab({
|
||||
<th>Model</th>
|
||||
<th>Objecten</th>
|
||||
<th>Precisie</th>
|
||||
<th>Recall</th>
|
||||
<th>Herkenningsgraad</th>
|
||||
<th>F1</th>
|
||||
<th>Fout positief</th>
|
||||
<th>Fout negatief</th>
|
||||
@@ -1033,13 +818,17 @@ export function DetectionLab({
|
||||
<strong>{row.modelName}</strong>
|
||||
<span className="table-subtle">{row.modelAssetId ?? 'geconfigureerd lokaal model'}</span>
|
||||
</td>
|
||||
<td>{row.detectionCount ?? 'n/a'}</td>
|
||||
<td>{row.detectionCount ?? 'n.v.t.'}</td>
|
||||
<td>{formatNullableNumber(row.precision, 3)}</td>
|
||||
<td>{formatNullableNumber(row.recall, 3)}</td>
|
||||
<td>{formatNullableNumber(row.f1, 3)}</td>
|
||||
<td>{row.falsePositives ?? 'n/a'}</td>
|
||||
<td>{row.falseNegatives ?? 'n/a'}</td>
|
||||
<td>{row.qualityCheckId}</td>
|
||||
<td>{row.falsePositives ?? 'n.v.t.'}</td>
|
||||
<td>{row.falseNegatives ?? 'n.v.t.'}</td>
|
||||
<td>
|
||||
<button className="secondary-action table-action" type="button" onClick={() => onOpenCalibrationEvidence(row.qualityCheckId)}>
|
||||
Toon kaartbewijs
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1085,15 +874,18 @@ export function DetectionLab({
|
||||
) : null}
|
||||
{detectionQaResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {detectionQaResult.status}</p>
|
||||
<p>Kwaliteitscontrole: {detectionQaResult.quality_check_id}</p>
|
||||
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</p>
|
||||
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
|
||||
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
|
||||
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Gemiddelde overlap: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Minimale IoU voor een match: {detectionQaResult.iou_threshold.toFixed(2)}</p>
|
||||
<p>Fout positief: {detectionQaResult.false_positives}</p>
|
||||
<p>Fout negatief: {detectionQaResult.false_negatives}</p>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische referentie</summary>
|
||||
<span>Kwaliteitscontrole-ID: {detectionQaResult.quality_check_id}</span>
|
||||
</details>
|
||||
{detectionQaResult.coverage ? (
|
||||
<div className="detection-qa-diagnostic">
|
||||
<span>Gecontroleerd beeldbereik</span>
|
||||
@@ -1117,7 +909,7 @@ export function DetectionLab({
|
||||
{detectionQaResult.box_to_footprint_diagnostics.strict_matches} strikte vormmatches
|
||||
</strong>
|
||||
<p>
|
||||
{detectionQaResult.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count} mogelijke vormafwijkingen. Precisie, recall en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
|
||||
{detectionQaResult.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count} mogelijke vormafwijkingen. Precisie, herkenningsgraad en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1142,12 +934,12 @@ function CalibrationSummaryCard({
|
||||
<span>{title}</span>
|
||||
{row ? (
|
||||
<>
|
||||
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n/a' : formatNullableNumber(row[metric], 3)}</strong>
|
||||
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n.v.t.' : formatNullableNumber(row[metric], 3)}</strong>
|
||||
<p>Threshold {formatNullableNumber(row.threshold, 2)} · {row.modelName}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>n/a</strong>
|
||||
<strong>n.v.t.</strong>
|
||||
<p>Persisted QA metrics are required.</p>
|
||||
</>
|
||||
)}
|
||||
@@ -1155,16 +947,6 @@ function CalibrationSummaryCard({
|
||||
)
|
||||
}
|
||||
|
||||
function formatModelAssetSize(sizeBytes: number): string {
|
||||
if (sizeBytes >= 1024 * 1024) {
|
||||
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
if (sizeBytes >= 1024) {
|
||||
return `${(sizeBytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${sizeBytes} B`
|
||||
}
|
||||
|
||||
function DetectionWorkflowStep({
|
||||
label,
|
||||
complete,
|
||||
@@ -1209,7 +991,7 @@ function buildCalibrationRows(detectionRuns: DetectionRunRead[], qualityChecks:
|
||||
analysisRunId: run.id,
|
||||
qualityCheckId: check.id,
|
||||
threshold,
|
||||
modelName: run.model_name ?? 'configured detection',
|
||||
modelName: run.model_name ?? 'geconfigureerde detectie',
|
||||
modelAssetId: stringFromRecord(run.parameters_json, 'model_asset_id'),
|
||||
detectionCount: numberFromRecord(run.result_json, 'detection_count'),
|
||||
precision: metricValue(check, 'precision'),
|
||||
@@ -1335,12 +1117,12 @@ function downloadJsonFile(filename: string, payload: unknown): void {
|
||||
}
|
||||
|
||||
function formatNullableNumber(value: number | null, digits: number): string {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n/a'
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n.v.t.'
|
||||
}
|
||||
|
||||
function formatSourceTilePath(path: string | null | undefined): string {
|
||||
if (!path) {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
return parts[parts.length - 1] ?? path
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import type {
|
||||
DetectionModelCapability,
|
||||
ModelAssetRead,
|
||||
YoloPreflightResponse,
|
||||
} from '../../types'
|
||||
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
|
||||
|
||||
interface DetectionModelManagementProps {
|
||||
detectionModels: DetectionModelCapability[]
|
||||
modelAssets: ModelAssetRead[]
|
||||
loadingDetectionModels: boolean
|
||||
detectionModelError: string | null
|
||||
modelAssetError: string | null
|
||||
selectedDetectionModelId: string
|
||||
selectedModelAssetId: string
|
||||
detectionConfidenceThreshold: number
|
||||
yoloPreflight: YoloPreflightResponse | null
|
||||
loadingYoloPreflight: boolean
|
||||
yoloPreflightError: string | null
|
||||
onRefreshYoloPreflight: () => void
|
||||
onSelectModelAsset: (modelAssetId: string) => void
|
||||
onApplyOperatorProfile: (profile: DetectionOperatorProfile) => void
|
||||
}
|
||||
|
||||
export function detectionModelLabel(model: DetectionModelCapability): string {
|
||||
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
|
||||
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
|
||||
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
|
||||
return model.display_name
|
||||
}
|
||||
|
||||
function formatModelAssetSize(sizeBytes: number): string {
|
||||
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) return 'n.v.t.'
|
||||
const megabytes = sizeBytes / (1024 * 1024)
|
||||
return `${megabytes.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} MB`
|
||||
}
|
||||
|
||||
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'
|
||||
return value.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
export function DetectionModelManagement({
|
||||
detectionModels,
|
||||
modelAssets,
|
||||
loadingDetectionModels,
|
||||
detectionModelError,
|
||||
modelAssetError,
|
||||
selectedDetectionModelId,
|
||||
selectedModelAssetId,
|
||||
detectionConfidenceThreshold,
|
||||
yoloPreflight,
|
||||
loadingYoloPreflight,
|
||||
yoloPreflightError,
|
||||
onRefreshYoloPreflight,
|
||||
onSelectModelAsset,
|
||||
onApplyOperatorProfile,
|
||||
}: DetectionModelManagementProps): JSX.Element {
|
||||
const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null
|
||||
const selectedOperatorProfile = DETECTION_OPERATOR_PROFILES.find(
|
||||
(profile) => profile.modelAssetId === selectedModelAssetId,
|
||||
) ?? null
|
||||
const yoloRuntimeReady = Boolean(
|
||||
yoloPreflight?.checks.enabled
|
||||
&& yoloPreflight.checks.dependencies_available
|
||||
&& yoloPreflight.checks.model_file_exists,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="detection-model-management">
|
||||
<details className="ai-lab-model-surface" aria-label="Technische modelmogelijkheden">
|
||||
<summary>
|
||||
<span>Technische modelinformatie</span>
|
||||
<strong>{detectionModels.length} registraties</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-state-stack">
|
||||
{loadingDetectionModels ? (
|
||||
<div className="result-state result-state-loading">
|
||||
<strong>Analysemodellen worden gecontroleerd.</strong>
|
||||
<p>GeoIntel leest de modelregistratie en lokale bestanden.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionModelError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>De modelregistratie is niet bereikbaar.</strong>
|
||||
<p>{detectionModelError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{modelAssetError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>De lokale modelbestanden konden niet worden gelezen.</strong>
|
||||
<p>{modelAssetError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detectionModels.length === 0 && !loadingDetectionModels ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>De backend meldt geen analysemodellen.</strong>
|
||||
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ul className="model-list">
|
||||
{detectionModels.map((model) => (
|
||||
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
|
||||
<strong>{detectionModelLabel(model)}</strong>
|
||||
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{statusLabel(model.status)}
|
||||
</span>
|
||||
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
|
||||
<p className="muted">{model.limitation_message}</p>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische identificatie</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Model-ID: {model.model_id}</span>
|
||||
<span>Framework: {model.framework}</span>
|
||||
<span>Taaktype: {model.task_type}</span>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{selectedDetectionModelId === 'yolo-configured' ? (
|
||||
<details className="ai-lab-model-surface" aria-label="Lokale modelkeuze">
|
||||
<summary>
|
||||
<span>Modelkeuze voor beheerders</span>
|
||||
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>Lokaal modelbestand</h3>
|
||||
<p>GeoIntel kiest automatisch het actieve lokale model. Een andere keuze geldt alleen voor deze analyse.</p>
|
||||
</div>
|
||||
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="operator-profile-grid" aria-label="Gevalideerde YOLO-profielen">
|
||||
{DETECTION_OPERATOR_PROFILES.map((profile) => {
|
||||
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
|
||||
const profileSelected =
|
||||
selectedModelAssetId === profile.modelAssetId
|
||||
&& Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001
|
||||
return (
|
||||
<div
|
||||
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
|
||||
key={profile.id}
|
||||
>
|
||||
<div className="operator-profile-card-header">
|
||||
<strong>{profile.displayName}</strong>
|
||||
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat, extra controle vereist'}
|
||||
</span>
|
||||
</div>
|
||||
<p>{profile.description}</p>
|
||||
<div className="operator-profile-metrics">
|
||||
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
|
||||
<span>precisie {profile.precision.toFixed(3)}</span>
|
||||
<span>herkenningsgraad {profile.recall.toFixed(3)}</span>
|
||||
<span>F1 {profile.f1.toFixed(3)}</span>
|
||||
<span>testgebieden {profile.positiveSampleCount}</span>
|
||||
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
|
||||
</div>
|
||||
<p className="field-guidance">{profile.limitationMessage}</p>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => onApplyOperatorProfile(profile)}
|
||||
disabled={!profileAsset}
|
||||
>
|
||||
Profiel gebruiken
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<label>
|
||||
Lokaal modelbestand
|
||||
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
|
||||
<option value="">Kies een lokaal modelbestand</option>
|
||||
{modelAssets.map((asset) => (
|
||||
<option key={asset.model_asset_id} value={asset.model_asset_id}>
|
||||
{asset.display_name} {asset.active ? '(actief)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{modelAssets.length === 0 && !loadingDetectionModels ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen lokaal modelbestand gevonden.</strong>
|
||||
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedModelAsset ? (
|
||||
<details className="technical-inline-details">
|
||||
<summary>Bestands- en integriteitsgegevens</summary>
|
||||
<div className="result-summary-card">
|
||||
<p>Bestand: {selectedModelAsset.filename}</p>
|
||||
<p>Status: {statusLabel(selectedModelAsset.status)}</p>
|
||||
<p>Actief servermodel: {selectedModelAsset.active ? 'ja' : 'nee'}</p>
|
||||
<p>Automatisch downloaden: {selectedModelAsset.will_download_models ? 'ja' : 'nee'}</p>
|
||||
<p>Grootte: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
|
||||
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
|
||||
<p>Pad: {selectedModelAsset.model_path}</p>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<details className="ai-lab-model-surface" aria-label="Technische YOLO-runtimecontrole">
|
||||
<summary>
|
||||
<span>Technische runtimecontrole</span>
|
||||
<strong>{yoloRuntimeReady ? 'gereed' : statusLabel(yoloPreflight?.status ?? 'niet geladen')}</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>YOLO-runtimecontrole</h3>
|
||||
<p>Deze alleen-lezen controle start geen analyse en downloadt geen modelbestanden.</p>
|
||||
</div>
|
||||
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
|
||||
Controle vernieuwen
|
||||
</button>
|
||||
</div>
|
||||
<div className="ai-lab-state-stack">
|
||||
{loadingYoloPreflight ? (
|
||||
<div className="result-state result-state-loading">
|
||||
<strong>De YOLO-runtime wordt gecontroleerd.</strong>
|
||||
<p>GeoIntel controleert configuratie, optionele software en lokale bestanden.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{yoloPreflightError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>De YOLO-runtimecontrole is niet beschikbaar.</strong>
|
||||
<p>{yoloPreflightError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Nog geen runtimecontrole geladen.</strong>
|
||||
<p>Vernieuw de controle voordat je een lokaal model gebruikt.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{yoloPreflight ? (
|
||||
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
|
||||
<div className="lab-readiness-grid">
|
||||
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>YOLO ingeschakeld</span>
|
||||
<strong>{yoloPreflight.checks.enabled ? 'ja' : 'nee'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Benodigde software</span>
|
||||
<strong>{yoloPreflight.checks.dependencies_available === true ? 'beschikbaar' : yoloPreflight.checks.dependencies_available === false ? 'ontbreekt' : 'niet gecontroleerd'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Lokaal modelbestand</span>
|
||||
<strong>{yoloPreflight.checks.model_file_exists === true ? 'gevonden' : yoloPreflight.checks.model_path_set ? 'ontbreekt' : 'niet geconfigureerd'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>GPU-versnelling</span>
|
||||
<strong>{yoloPreflight.runtime.cuda_available === true ? 'beschikbaar' : yoloPreflight.runtime.cuda_available === false ? 'niet beschikbaar' : 'niet gecontroleerd'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Beeldtegels</span>
|
||||
<strong>{yoloPreflight.checks.manifest_valid === true ? 'geldig' : yoloPreflight.checks.manifest_path_set ? 'ongeldig' : 'niet opgegeven'}</strong>
|
||||
</div>
|
||||
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Aantal beeldtegels</span>
|
||||
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Versies en serverpaden</summary>
|
||||
<div className="entity-meta">
|
||||
<span>PyTorch: {yoloPreflight.runtime.torch_version ?? 'n.v.t.'}</span>
|
||||
<span>Ultralytics: {yoloPreflight.runtime.ultralytics_version ?? 'n.v.t.'}</span>
|
||||
<span>YOLO-configuratiemap: {yoloPreflight.runtime.yolo_config_dir ?? 'n.v.t.'}</span>
|
||||
<span>Modelmap: {yoloPreflight.runtime.model_directory ?? 'n.v.t.'}</span>
|
||||
<span>Model-ID: {yoloPreflight.model_asset_id ?? 'n.v.t.'}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ interface WorkbenchInspectorProps {
|
||||
|
||||
function formatValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'n/a'
|
||||
return 'n.v.t.'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
@@ -93,23 +93,23 @@ export function WorkbenchInspector({
|
||||
|
||||
const tabs: Array<{ key: InspectorTab; label: string }> = [
|
||||
{ key: 'context', label: 'Context' },
|
||||
{ key: 'dataset', label: 'Dataset' },
|
||||
{ key: 'quality', label: 'QA/Exports' },
|
||||
{ key: 'ai', label: 'AI Runs' },
|
||||
{ key: 'dataset', label: 'Databron' },
|
||||
{ key: 'quality', label: 'Kwaliteit en downloads' },
|
||||
{ key: 'ai', label: 'Beeldanalyse' },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="workbench-inspector-panel" data-testid="workbench-inspector-panel">
|
||||
<div className="inspector-header">
|
||||
<div>
|
||||
<p className="eyebrow">Inspector</p>
|
||||
<h2>Selection details</h2>
|
||||
<p className="eyebrow">Context</p>
|
||||
<h2>Details van de selectie</h2>
|
||||
</div>
|
||||
<button type="button" className="inspector-close" onClick={onClose} aria-label="Close selection details">
|
||||
Close
|
||||
<button type="button" className="inspector-close" onClick={onClose} aria-label="Sluit details van de selectie">
|
||||
Sluiten
|
||||
</button>
|
||||
</div>
|
||||
<div className="inspector-tabs" role="tablist" aria-label="Inspector detail tabs">
|
||||
<div className="inspector-tabs" role="tablist" aria-label="Onderdelen van de selectie">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -130,36 +130,36 @@ export function WorkbenchInspector({
|
||||
{activeTab === 'context' ? (
|
||||
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
|
||||
<div className="inspector-card">
|
||||
<h3>Project context</h3>
|
||||
<InspectorField label="Project" value={selectedProject?.name} />
|
||||
<InspectorField label="Region" value={selectedProject?.region} />
|
||||
<h3>Werkruimte</h3>
|
||||
<InspectorField label="Naam" value={selectedProject?.name} />
|
||||
<InspectorField label="Regio" value={selectedProject?.region} />
|
||||
<InspectorField label="Status" value={selectedProject?.status} />
|
||||
<InspectorField label="AOIs" value={areas.length} />
|
||||
<InspectorField label="Datasets" value={datasetsCount} />
|
||||
<InspectorField label="Gebieden" value={areas.length} />
|
||||
<InspectorField label="Databronnen" value={datasetsCount} />
|
||||
<div className="button-row">
|
||||
<button type="button" className="secondary-action" onClick={onOpenDataWorkspace}>
|
||||
Open data setup
|
||||
Gegevens beheren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inspector-card">
|
||||
<h3>Active AOI</h3>
|
||||
<InspectorField label="Name" value={selectedArea?.name} />
|
||||
<InspectorField label="Area m2" value={selectedArea?.area_m2} />
|
||||
<InspectorField label="CRS" value={selectedArea?.original_crs} />
|
||||
<InspectorField label="Geometry" value={selectedArea?.geometry?.type} />
|
||||
<h3>Actief werkgebied</h3>
|
||||
<InspectorField label="Naam" value={selectedArea?.name} />
|
||||
<InspectorField label="Oppervlakte m²" value={selectedArea?.area_m2} />
|
||||
<InspectorField label="Coördinatenstelsel" value={selectedArea?.original_crs} />
|
||||
<InspectorField label="Geometrietype" value={selectedArea?.geometry?.type} />
|
||||
<div className="button-row">
|
||||
<button type="button" className="secondary-action" onClick={onOpenMapWorkspace}>
|
||||
Open map
|
||||
Kaart openen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inspector-card">
|
||||
<h3>Map feature</h3>
|
||||
<h3>Geselecteerd kaartobject</h3>
|
||||
{selectedMapFeature ? (
|
||||
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
|
||||
) : (
|
||||
<p className="muted">No map feature selected.</p>
|
||||
<p className="muted">Er is geen kaartobject geselecteerd.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,13 +169,13 @@ export function WorkbenchInspector({
|
||||
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
|
||||
<div className="inspector-action-bar">
|
||||
<button type="button" className="secondary-action" onClick={onOpenDataWorkspace}>
|
||||
Data catalog
|
||||
Gegevenscatalogus
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onOpenMapWorkspace}>
|
||||
Map layer
|
||||
Kaartlaag
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onOpenExportsWorkspace}>
|
||||
Export
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
<DatasetDetailPanel {...datasetDetailProps} />
|
||||
@@ -185,28 +185,26 @@ export function WorkbenchInspector({
|
||||
{activeTab === 'quality' ? (
|
||||
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
|
||||
<div className="inspector-card">
|
||||
<h3>Latest QA/QC</h3>
|
||||
<InspectorField label="Check type" value={latestQualityCheck?.check_type} />
|
||||
<h3>Laatste kwaliteitscontrole</h3>
|
||||
<InspectorField label="Type controle" value={latestQualityCheck?.check_type?.replaceAll('_', ' ')} />
|
||||
<InspectorField label="Status" value={latestQualityCheck?.status} />
|
||||
<InspectorField label="Score" value={latestQualityCheck?.score} />
|
||||
<InspectorField label="Metrics" value={latestQualityCheck?.metrics.length} />
|
||||
<InspectorField label="Reference" value={latestQualityCheck?.reference_dataset_id} />
|
||||
<InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics.length} />
|
||||
<div className="button-row">
|
||||
<button type="button" className="secondary-action" onClick={onOpenQualityWorkspace}>
|
||||
Open QA/QC
|
||||
Kwaliteit openen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inspector-card">
|
||||
<h3>Latest export</h3>
|
||||
<InspectorField label="Created now" value={latestExport?.export_type} />
|
||||
<InspectorField label="Persisted type" value={latestPersistedExport?.export_type} />
|
||||
<h3>Laatste download</h3>
|
||||
<InspectorField label="Zojuist aangemaakt" value={latestExport?.export_type} />
|
||||
<InspectorField label="Bewaard type" value={latestPersistedExport?.export_type} />
|
||||
<InspectorField label="Status" value={latestPersistedExport?.status ?? latestExport?.status} />
|
||||
<InspectorField label="Path" value={latestPersistedExport?.storage_path ?? latestExport?.path} />
|
||||
<InspectorField label="Export count" value={exports.length} />
|
||||
<InspectorField label="Aantal downloads" value={exports.length} />
|
||||
<div className="button-row">
|
||||
<button type="button" className="secondary-action" onClick={onOpenExportsWorkspace}>
|
||||
Open exports
|
||||
Downloads openen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,23 +214,28 @@ export function WorkbenchInspector({
|
||||
{activeTab === 'ai' ? (
|
||||
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
|
||||
<div className="inspector-card">
|
||||
<h3>Detection run</h3>
|
||||
<InspectorField label="Selected run" value={selectedDetectionRunId} />
|
||||
<h3>Gebouwdetectie</h3>
|
||||
<InspectorField label="Status" value={selectedDetectionRun?.status} />
|
||||
<InspectorField label="Model" value={selectedDetectionRun?.model_name} />
|
||||
<InspectorField label="Detections loaded" value={detectionItems.length} />
|
||||
<InspectorField label="Gevonden objecten" value={detectionItems.length} />
|
||||
<div className="button-row">
|
||||
<button type="button" className="secondary-action" onClick={onOpenAiWorkspace}>
|
||||
Open AI Labs
|
||||
Beeldanalyse openen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inspector-card">
|
||||
<h3>Segmentation run</h3>
|
||||
<InspectorField label="Selected run" value={selectedSegmentationRunId} />
|
||||
<h3>Segmentatie</h3>
|
||||
<InspectorField label="Status" value={selectedSegmentationRun?.status} />
|
||||
<InspectorField label="Model" value={selectedSegmentationRun?.model_name} />
|
||||
<InspectorField label="Segmentations loaded" value={segmentationItems.length} />
|
||||
<InspectorField label="Herkende vlakken" value={segmentationItems.length} />
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische analyseruns</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Detectierun-ID: {selectedDetectionRunId || 'n.v.t.'}</span>
|
||||
<span>Segmentatierun-ID: {selectedSegmentationRunId || 'n.v.t.'}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
import { featureCollectionBounds } from '../../lib/geojsonBounds'
|
||||
import type {
|
||||
ProjectRead,
|
||||
VectorSelectionBBox,
|
||||
VectorSelectionMetric,
|
||||
VectorSelectionResponse,
|
||||
} from '../../types'
|
||||
|
||||
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
|
||||
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
|
||||
|
||||
export function operationalScopeProjectLabel(project: ProjectRead): string {
|
||||
if (project.name === MOL_PROJECT_NAME) {
|
||||
return 'Mol'
|
||||
}
|
||||
if (project.name === KEMPEN_PROJECT_NAME) {
|
||||
return 'Kempen (28 gemeenten)'
|
||||
}
|
||||
return project.name
|
||||
}
|
||||
|
||||
export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
|
||||
if (!bbox) {
|
||||
return null
|
||||
}
|
||||
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
|
||||
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
|
||||
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
|
||||
return Math.max(0, widthMetres * heightMetres)
|
||||
}
|
||||
|
||||
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
const tolerance = 1e-9
|
||||
return (
|
||||
Math.abs(left.min_x - right.min_x) < tolerance
|
||||
&& Math.abs(left.min_y - right.min_y) < tolerance
|
||||
&& Math.abs(left.max_x - right.max_x) < tolerance
|
||||
&& Math.abs(left.max_y - right.max_y) < tolerance
|
||||
)
|
||||
}
|
||||
|
||||
export function formatArea(areaSquareMetres: number | null): string {
|
||||
if (areaSquareMetres === null) {
|
||||
return 'Nog niet geselecteerd'
|
||||
}
|
||||
if (areaSquareMetres >= 1_000_000) {
|
||||
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
|
||||
}
|
||||
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
|
||||
}
|
||||
|
||||
export function resultCountLabel(result: VectorSelectionResponse): string {
|
||||
const total = result.total_feature_count ?? result.feature_count
|
||||
return result.truncated && result.total_feature_count == null
|
||||
? `${result.feature_count.toLocaleString('nl-BE')}+`
|
||||
: total.toLocaleString('nl-BE')
|
||||
}
|
||||
|
||||
export function resultMetricLabel(result: VectorSelectionResponse): string {
|
||||
if (!result.summary) {
|
||||
return resultCountLabel(result)
|
||||
}
|
||||
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
|
||||
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
|
||||
}
|
||||
|
||||
export function selectionMetricLabel(metric: VectorSelectionMetric): string {
|
||||
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
|
||||
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
|
||||
}
|
||||
|
||||
export function formatTemporalMetric(value: number, unit: string): string {
|
||||
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
|
||||
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
|
||||
}
|
||||
|
||||
export function readablePropertyName(value: string): string {
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
}
|
||||
|
||||
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
|
||||
const points: Array<[number, number]> = []
|
||||
const walk = (coords: unknown) => {
|
||||
if (!Array.isArray(coords)) {
|
||||
return
|
||||
}
|
||||
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
|
||||
points.push([coords[0], coords[1]])
|
||||
return
|
||||
}
|
||||
for (const item of coords) {
|
||||
walk(item)
|
||||
}
|
||||
}
|
||||
|
||||
if ('coordinates' in (geometry ?? {})) {
|
||||
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
function formatCoordinate(value: number): string {
|
||||
return Number.isFinite(value) ? value.toFixed(6) : 'n.v.t.'
|
||||
}
|
||||
|
||||
export function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
|
||||
const points = collectGeometryPoints(feature?.geometry)
|
||||
if (!feature?.geometry || points.length === 0) {
|
||||
return {
|
||||
bboxLabel: 'n.v.t.',
|
||||
coordinateCount: 0,
|
||||
geometryType: feature?.geometry?.type ?? 'geen',
|
||||
}
|
||||
}
|
||||
|
||||
const xs = points.map((point) => point[0])
|
||||
const ys = points.map((point) => point[1])
|
||||
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
|
||||
Math.max(...xs),
|
||||
)}, ${formatCoordinate(Math.max(...ys))}`
|
||||
|
||||
return {
|
||||
bboxLabel,
|
||||
coordinateCount: points.length,
|
||||
geometryType: feature.geometry.type,
|
||||
}
|
||||
}
|
||||
|
||||
export function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
|
||||
const bounds = featureCollectionBounds(collection)
|
||||
if (!bounds) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
min_x: bounds.minX,
|
||||
min_y: bounds.minY,
|
||||
max_x: bounds.maxX,
|
||||
max_y: bounds.maxY,
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
export function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
|
||||
const points = collectGeometryPoints(feature?.geometry)
|
||||
if (points.length === 0) {
|
||||
return null
|
||||
}
|
||||
const xs = points.map((point) => point[0])
|
||||
const ys = points.map((point) => point[1])
|
||||
return {
|
||||
min_x: Math.min(...xs),
|
||||
min_y: Math.min(...ys),
|
||||
max_x: Math.max(...xs),
|
||||
max_y: Math.max(...ys),
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBboxFromCorners(
|
||||
first: [number, number],
|
||||
second: [number, number],
|
||||
): VectorSelectionBBox {
|
||||
return {
|
||||
min_x: Math.min(first[0], second[0]),
|
||||
min_y: Math.min(first[1], second[1]),
|
||||
max_x: Math.max(first[0], second[0]),
|
||||
max_y: Math.max(first[1], second[1]),
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
|
||||
if (!bbox) {
|
||||
return 'n.v.t.'
|
||||
}
|
||||
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
|
||||
}
|
||||
|
||||
export function formatPercentage(value: number | null | undefined): string {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
|
||||
: 'n.v.t.'
|
||||
}
|
||||
|
||||
export function bboxToInputState(bbox: VectorSelectionBBox | null) {
|
||||
return {
|
||||
min_x: bbox ? String(bbox.min_x) : '',
|
||||
min_y: bbox ? String(bbox.min_y) : '',
|
||||
max_x: bbox ? String(bbox.max_x) : '',
|
||||
max_y: bbox ? String(bbox.max_y) : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
|
||||
const min_x = Number(input.min_x)
|
||||
const min_y = Number(input.min_y)
|
||||
const max_x = Number(input.max_x)
|
||||
const max_y = Number(input.max_y)
|
||||
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
|
||||
return null
|
||||
}
|
||||
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
|
||||
}
|
||||
|
||||
export function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [feature],
|
||||
}
|
||||
}
|
||||
|
||||
export function safeFileStem(value: unknown): string {
|
||||
const stem = String(value ?? 'selected-feature')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return stem || 'selected-feature'
|
||||
}
|
||||
|
||||
function fallbackCopyText(text: string): void {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
export function copyText(text: string): void {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
|
||||
return
|
||||
}
|
||||
fallbackCopyText(text)
|
||||
}
|
||||
|
||||
export function downloadJsonFile(
|
||||
filename: string,
|
||||
payload: unknown,
|
||||
contentType = 'application/json',
|
||||
): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
AreaRead,
|
||||
DatasetCreateResponse,
|
||||
ExportRead,
|
||||
ProjectRead,
|
||||
QualityCheckRead,
|
||||
} from '../../types'
|
||||
import type { useSourceFreshness } from '../../hooks/useSourceFreshness'
|
||||
import { SourceFreshnessPanel } from '../status/SourceFreshnessPanel'
|
||||
import { WorkbenchStatusStrip } from '../WorkbenchStatusStrip'
|
||||
|
||||
export type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
|
||||
|
||||
interface OverviewWorkspaceProps {
|
||||
selectedProject: ProjectRead | null
|
||||
selectedProjectId: string | null
|
||||
areas: AreaRead[]
|
||||
datasets: DatasetCreateResponse[]
|
||||
qualityChecks: QualityCheckRead[]
|
||||
exports: ExportRead[]
|
||||
activeLayerFeatureCount: number
|
||||
selectedAreaHasGeometry: boolean
|
||||
hasAnalysisOutput: boolean
|
||||
sourceFreshness: ReturnType<typeof useSourceFreshness>
|
||||
onOpenWorkspace: (target: WorkspaceKey) => void
|
||||
}
|
||||
|
||||
interface WorkflowStep {
|
||||
step: string
|
||||
title: string
|
||||
detail: string
|
||||
status: string
|
||||
ready: boolean
|
||||
target: WorkspaceKey
|
||||
}
|
||||
|
||||
export function OverviewWorkspace({
|
||||
selectedProject,
|
||||
selectedProjectId,
|
||||
areas,
|
||||
datasets,
|
||||
qualityChecks,
|
||||
exports,
|
||||
activeLayerFeatureCount,
|
||||
selectedAreaHasGeometry,
|
||||
hasAnalysisOutput,
|
||||
sourceFreshness,
|
||||
onOpenWorkspace,
|
||||
}: OverviewWorkspaceProps): JSX.Element {
|
||||
const hasMapContext = activeLayerFeatureCount > 0 || selectedAreaHasGeometry
|
||||
const workflowComplete =
|
||||
Boolean(selectedProjectId)
|
||||
&& datasets.length > 0
|
||||
&& hasMapContext
|
||||
&& hasAnalysisOutput
|
||||
&& exports.length > 0
|
||||
const recommendedTarget: WorkspaceKey = !selectedProjectId || datasets.length === 0
|
||||
? 'data'
|
||||
: !hasMapContext
|
||||
? 'map'
|
||||
: !hasAnalysisOutput
|
||||
? 'analysis'
|
||||
: 'exports'
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
step: '1',
|
||||
title: 'Werkruimte en gebied',
|
||||
detail: selectedProjectId
|
||||
? `${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'} beschikbaar`
|
||||
: 'Laad of maak een werkruimte',
|
||||
status: selectedProjectId ? 'gereed' : 'volgende stap',
|
||||
ready: Boolean(selectedProjectId),
|
||||
target: 'data',
|
||||
},
|
||||
{
|
||||
step: '2',
|
||||
title: 'Databronnen',
|
||||
detail: datasets.length > 0
|
||||
? `${datasets.length} ${datasets.length === 1 ? 'databron' : 'databronnen'} ingeladen`
|
||||
: 'Voeg bron- en referentiegegevens toe',
|
||||
status: datasets.length > 0 ? 'gereed' : 'wachten',
|
||||
ready: datasets.length > 0,
|
||||
target: 'data',
|
||||
},
|
||||
{
|
||||
step: '3',
|
||||
title: 'Kaart',
|
||||
detail: hasMapContext
|
||||
? activeLayerFeatureCount > 0
|
||||
? `${activeLayerFeatureCount.toLocaleString('nl-BE')} objecten op de kaart`
|
||||
: 'Werkgebied ingeladen'
|
||||
: 'Bekijk het werkgebied en kies een kaartlaag',
|
||||
status: hasMapContext ? 'gereed' : 'wachten',
|
||||
ready: hasMapContext,
|
||||
target: 'map',
|
||||
},
|
||||
{
|
||||
step: '4',
|
||||
title: 'Controle en analyse',
|
||||
detail: hasAnalysisOutput
|
||||
? 'Er is een bewaard analyse- of kwaliteitsresultaat'
|
||||
: 'Voer een controle uit zodra de gegevens klaarstaan',
|
||||
status: hasAnalysisOutput ? 'gereed' : 'wachten',
|
||||
ready: hasAnalysisOutput,
|
||||
target: 'analysis',
|
||||
},
|
||||
{
|
||||
step: '5',
|
||||
title: 'Downloads',
|
||||
detail: exports.length > 0
|
||||
? `${exports.length} ${exports.length === 1 ? 'resultaat' : 'resultaten'} bewaard`
|
||||
: 'Bewaar een gecontroleerd resultaat',
|
||||
status: exports.length > 0 ? 'gereed' : 'wachten',
|
||||
ready: exports.length > 0,
|
||||
target: 'exports',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="workspace-stack">
|
||||
<WorkbenchStatusStrip
|
||||
selectedProject={selectedProject}
|
||||
areas={areas}
|
||||
datasets={datasets}
|
||||
qualityChecks={qualityChecks}
|
||||
exports={exports}
|
||||
activeLayerFeatureCount={activeLayerFeatureCount}
|
||||
selectedAreaHasGeometry={selectedAreaHasGeometry}
|
||||
/>
|
||||
<SourceFreshnessPanel
|
||||
report={sourceFreshness.report}
|
||||
loading={sourceFreshness.loading}
|
||||
error={sourceFreshness.error}
|
||||
onRefresh={() => { void sourceFreshness.refresh() }}
|
||||
catalogReport={sourceFreshness.catalogReport}
|
||||
catalogLoading={sourceFreshness.catalogLoading}
|
||||
catalogError={sourceFreshness.catalogError}
|
||||
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
|
||||
grbRefreshPlan={sourceFreshness.grbRefreshPlan}
|
||||
grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
|
||||
grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
|
||||
/>
|
||||
<details className="status-details-disclosure">
|
||||
<summary>
|
||||
<span>Volledige workflowstatus</span>
|
||||
<strong>{workflowComplete ? 'voltooid' : 'stappen open'}</strong>
|
||||
</summary>
|
||||
<section className="workflow-guidance-panel" aria-label="Voortgang van de werkstroom">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<p className="eyebrow">Van bron tot resultaat</p>
|
||||
<h2>Voortgang van de werkstroom</h2>
|
||||
</div>
|
||||
<span className="status-badge">
|
||||
{workflowComplete ? 'Klaar om te delen' : 'Ga verder met de gemarkeerde stap'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-guidance-steps">
|
||||
{steps.map((step) => (
|
||||
<button
|
||||
key={`${step.step}-${step.title}`}
|
||||
type="button"
|
||||
className={
|
||||
step.target === recommendedTarget && !step.ready
|
||||
? 'workflow-guidance-step workflow-guidance-step-active'
|
||||
: step.ready
|
||||
? 'workflow-guidance-step workflow-guidance-step-ready'
|
||||
: 'workflow-guidance-step'
|
||||
}
|
||||
onClick={() => onOpenWorkspace(step.target)}
|
||||
aria-label={`Open stap ${step.title}`}
|
||||
>
|
||||
<span className="workflow-step-status">{step.status}</span>
|
||||
<strong>{step.step}. {step.title}</strong>
|
||||
<small>{step.detail}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="overview-actions">
|
||||
<div className="overview-action-copy">
|
||||
<p className="eyebrow">Snel verder</p>
|
||||
<h2>Kies je volgende actie</h2>
|
||||
</div>
|
||||
<div className="quick-action-grid overview-quick-actions" aria-label="Aanbevolen acties">
|
||||
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('data')}>
|
||||
Gegevens beheren
|
||||
</button>
|
||||
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('map')}>
|
||||
Kaart openen
|
||||
</button>
|
||||
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('analysis')}>
|
||||
Kwaliteit bekijken
|
||||
</button>
|
||||
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('exports')}>
|
||||
Downloads beheren
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { ProjectCreate, ProjectRead } from '../../types'
|
||||
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
|
||||
const REGIONAL_PROJECT_NAME = 'Kempen Regional Workbench'
|
||||
const LEGACY_MOL_PROJECT_NAME = 'Mol Municipality Workbench'
|
||||
const PROTECTED_PROJECT_NAMES = new Set([REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
|
||||
|
||||
function isTechnicalProject(project: ProjectRead): boolean {
|
||||
return TECHNICAL_PROJECT_PATTERN.test(project.name)
|
||||
@@ -27,12 +28,14 @@ interface ProjectPanelProps {
|
||||
projects: ProjectRead[]
|
||||
selectedProjectId: string | null
|
||||
loadingProjects: boolean
|
||||
archivingProjectId: string | null
|
||||
projectForm: ProjectCreate
|
||||
loadingDemoWorkflow: boolean
|
||||
demoWorkflowMessage: string | null
|
||||
onCreateProject: (event: FormEvent<HTMLFormElement>) => void
|
||||
onUpdateProjectForm: (projectForm: ProjectCreate) => void
|
||||
onSelectProject: (projectId: string) => void
|
||||
onArchiveProject: (projectId: string) => Promise<void>
|
||||
onLoadDemoWorkflow: () => void
|
||||
}
|
||||
|
||||
@@ -40,12 +43,14 @@ export function ProjectPanel({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
loadingProjects,
|
||||
archivingProjectId,
|
||||
projectForm,
|
||||
loadingDemoWorkflow,
|
||||
demoWorkflowMessage,
|
||||
onCreateProject,
|
||||
onUpdateProjectForm,
|
||||
onSelectProject,
|
||||
onArchiveProject,
|
||||
onLoadDemoWorkflow,
|
||||
}: ProjectPanelProps): JSX.Element {
|
||||
const selectedProject = projects.find((project) => project.id === selectedProjectId)
|
||||
@@ -134,6 +139,28 @@ export function ProjectPanel({
|
||||
</button>
|
||||
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
|
||||
</div>
|
||||
{selectedProject && !PROTECTED_PROJECT_NAMES.has(selectedProject.name) ? (
|
||||
<div className="project-lifecycle-actions">
|
||||
<div>
|
||||
<strong>Actieve werkruimte opruimen</strong>
|
||||
<p className="muted">
|
||||
Archiveren verbergt deze werkruimte uit de standaardlijst. Databronnen en analyseresultaten blijven bewaard.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
disabled={archivingProjectId === selectedProject.id}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Werkruimte "${projectDisplayName(selectedProject)}" archiveren?`)) {
|
||||
void onArchiveProject(selectedProject.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{archivingProjectId === selectedProject.id ? 'Archiveren...' : 'Werkruimte archiveren'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{advancedProjects.length > 0 ? (
|
||||
<details className="technical-run-list">
|
||||
<summary>{advancedProjects.length} alternatieve en technische werkruimtes</summary>
|
||||
|
||||
@@ -65,6 +65,18 @@ function qualityStatusLabel(status: string | null | undefined): string {
|
||||
return status
|
||||
}
|
||||
|
||||
function qualityCheckTypeLabel(checkType: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
vector_vs_reference: 'Kaartlaag tegenover referentie',
|
||||
detections_vs_reference: 'Gebouwdetectie tegenover referentie',
|
||||
segmentations_vs_reference: 'Segmentatie tegenover referentie',
|
||||
detection_vs_reference: 'Gebouwdetectie tegenover referentie',
|
||||
segmentation_vs_reference: 'Segmentatie tegenover referentie',
|
||||
}
|
||||
if (!checkType) return 'Kwaliteitscontrole'
|
||||
return labels[checkType] ?? checkType.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined {
|
||||
return check?.metrics.find((metric) => metric.metric_key === metricKey)
|
||||
}
|
||||
@@ -78,7 +90,7 @@ function evidenceLabel(item: Record<string, unknown>): string {
|
||||
const candidate = item['candidate_feature_id']
|
||||
const reference = item['reference_feature_id']
|
||||
const iou = item['iou']
|
||||
const pairLabel = [candidate ? `Candidate ${candidate}` : null, reference ? `Reference ${reference}` : null].filter(Boolean).join(' / ')
|
||||
const pairLabel = [candidate ? `Te controleren ${candidate}` : null, reference ? `Referentie ${reference}` : null].filter(Boolean).join(' / ')
|
||||
const iouValue = Number(iou)
|
||||
return iou === null || iou === undefined || !Number.isFinite(iouValue) ? pairLabel || JSON.stringify(item) : `${pairLabel || 'Match'} / IoU ${iouValue.toFixed(3)}`
|
||||
}
|
||||
@@ -143,11 +155,11 @@ export function QualityResultsPanel({
|
||||
[latestCheck, qualityChecks, selectedQualityCheckId],
|
||||
)
|
||||
const selectedCandidateName = selectedQualityCheck?.candidate_dataset_id
|
||||
? datasetNameById.get(selectedQualityCheck.candidate_dataset_id) ?? selectedQualityCheck.candidate_dataset_id
|
||||
: 'n/a'
|
||||
? datasetNameById.get(selectedQualityCheck.candidate_dataset_id) ?? 'Laag niet meer in de gegevenslijst'
|
||||
: 'Niet bewaard'
|
||||
const selectedReferenceName = selectedQualityCheck
|
||||
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? selectedQualityCheck.reference_dataset_id
|
||||
: 'n/a'
|
||||
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? 'Referentielaag niet meer in de gegevenslijst'
|
||||
: 'Niet beschikbaar'
|
||||
const selectedMatchEvidence = findingEvidenceList(selectedQualityCheck, 'match_evidence')
|
||||
const selectedFalsePositiveEvidence = findingEvidenceList(selectedQualityCheck, 'false_positive_evidence')
|
||||
const selectedFalseNegativeEvidence = findingEvidenceList(selectedQualityCheck, 'false_negative_evidence')
|
||||
@@ -177,7 +189,7 @@ export function QualityResultsPanel({
|
||||
<span className="count-pill">{qualityChecks.length} controles</span>
|
||||
</div>
|
||||
|
||||
<div className="quality-summary-surface" aria-label="QA/QC result summary">
|
||||
<div className="quality-summary-surface" aria-label="Samenvatting kwaliteitsresultaten">
|
||||
<div className="quality-summary-grid">
|
||||
<div>
|
||||
<span>Afgerond</span>
|
||||
@@ -208,8 +220,8 @@ export function QualityResultsPanel({
|
||||
<span>Details, kaartbewijs en historiek</span>
|
||||
<strong>{qualityChecks.length} bewaarde controles</strong>
|
||||
</summary>
|
||||
<div className="quality-evidence-surface" aria-label="QA/QC dataset evidence">
|
||||
<div className="quality-handoff-grid" aria-label="QA/QC dataset handoff context">
|
||||
<div className="quality-evidence-surface" aria-label="Databronnen van de kwaliteitscontrole">
|
||||
<div className="quality-handoff-grid" aria-label="Context van de vergeleken databronnen">
|
||||
<div>
|
||||
<span>Te controleren lagen</span>
|
||||
<strong>{candidateDatasets.length}</strong>
|
||||
@@ -224,13 +236,13 @@ export function QualityResultsPanel({
|
||||
<span>Laatste vergelijking</span>
|
||||
<strong>{qualityStatusLabel(latestCheck?.status)}</strong>
|
||||
<p className="quality-dataset-name">
|
||||
Te controleren: {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n.v.t.'} / referentie: {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n.v.t.'}
|
||||
Te controleren: {latestCandidateName ?? 'laag niet meer beschikbaar'} / referentie: {latestReferenceName ?? 'laag niet meer beschikbaar'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quality-drilldown-surface" aria-label="QA/QC evidence drilldown">
|
||||
<div className="quality-drilldown-surface" aria-label="Details van het kaartbewijs">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Bewijs van de kwaliteitscontrole</h3>
|
||||
@@ -264,28 +276,28 @@ export function QualityResultsPanel({
|
||||
<div className="quality-drilldown-grid">
|
||||
<div>
|
||||
<span>Geselecteerde controle</span>
|
||||
<strong>{selectedQualityCheck.check_type}</strong>
|
||||
<p>{selectedQualityCheck.id}</p>
|
||||
<strong>{qualityCheckTypeLabel(selectedQualityCheck.check_type)}</strong>
|
||||
<p>{qualityScoreInterpretation(selectedQualityCheck.score)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Te controleren laag</span>
|
||||
<strong>{selectedCandidateName}</strong>
|
||||
<p>{selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</p>
|
||||
<p>De laag waarvan de kwaliteit wordt beoordeeld.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Referentielaag</span>
|
||||
<strong>{selectedReferenceName}</strong>
|
||||
<p>{selectedQualityCheck.reference_dataset_id}</p>
|
||||
<p>De bewaarde bron waarmee wordt vergeleken.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Analyserun</span>
|
||||
<strong>{selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</strong>
|
||||
<p>Taak: {selectedQualityCheck.job_id ?? 'n.v.t.'}</p>
|
||||
<span>Beoordeling</span>
|
||||
<strong>{qualityScoreInterpretation(selectedQualityCheck.score)}</strong>
|
||||
<p>Score: {qualityScoreValue(selectedQualityCheck.score)} op een schaal van 0 tot 1</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>{selectedQualityCheck.status}</strong>
|
||||
<p>Score: {qualityScoreValue(selectedQualityCheck.score)}</p>
|
||||
<strong>{qualityStatusLabel(selectedQualityCheck.status)}</strong>
|
||||
<p>Resultaat is bewaard in de werkruimte.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Afgerond</span>
|
||||
@@ -293,6 +305,17 @@ export function QualityResultsPanel({
|
||||
<p>Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische identificatie en verwerking</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Controle-ID: {selectedQualityCheck.id}</span>
|
||||
<span>Type: {selectedQualityCheck.check_type}</span>
|
||||
<span>Te controleren dataset-ID: {selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</span>
|
||||
<span>Referentie-dataset-ID: {selectedQualityCheck.reference_dataset_id}</span>
|
||||
<span>Analyserun-ID: {selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</span>
|
||||
<span>Taak-ID: {selectedQualityCheck.job_id ?? 'n.v.t.'}</span>
|
||||
</div>
|
||||
</details>
|
||||
<div className="quality-evidence-token-grid">
|
||||
<div>
|
||||
<span>Onterecht gevonden</span>
|
||||
@@ -325,7 +348,7 @@ export function QualityResultsPanel({
|
||||
onOpenEvidenceMap={onOpenEvidenceMap}
|
||||
/>
|
||||
) : null}
|
||||
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
|
||||
<div className="quality-feature-evidence-grid" aria-label="Kaartbewijs per object">
|
||||
<div>
|
||||
<span>Overeenkomende object-ID's</span>
|
||||
{selectedMatchEvidence.length > 0 ? (
|
||||
@@ -386,7 +409,7 @@ export function QualityResultsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quality-control-surface" aria-label="QA/QC refresh and filters">
|
||||
<div className="quality-control-surface" aria-label="Kwaliteitsresultaten vernieuwen en filteren">
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
@@ -415,12 +438,12 @@ export function QualityResultsPanel({
|
||||
</div>
|
||||
|
||||
{qualityChecks.length > 0 ? (
|
||||
<div className="quality-history-controls" aria-label="QA/QC result filters">
|
||||
<div className="quality-history-controls" aria-label="Filters voor kwaliteitsresultaten">
|
||||
<label>
|
||||
Kwaliteitsresultaten zoeken
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Type, id or dataset"
|
||||
placeholder="Type of naam van een kaartlaag"
|
||||
value={qualitySearchQuery}
|
||||
onChange={(event) => setQualitySearchQuery(event.target.value)}
|
||||
/>
|
||||
@@ -481,7 +504,7 @@ export function QualityResultsPanel({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="quality-history-surface" aria-label="QA/QC result history">
|
||||
<div className="quality-history-surface" aria-label="Geschiedenis van kwaliteitsresultaten">
|
||||
<div className="panel-title-row">
|
||||
<h3>Historiek</h3>
|
||||
<span className="count-pill">{visibleQualityChecks.length} getoond</span>
|
||||
@@ -491,18 +514,18 @@ export function QualityResultsPanel({
|
||||
<li className="quality-check-card" key={check.id}>
|
||||
<div className="quality-check-header">
|
||||
<div>
|
||||
<strong>{check.check_type}</strong>
|
||||
<strong>{qualityCheckTypeLabel(check.check_type)}</strong>
|
||||
<div className="entity-meta">
|
||||
<span className="quality-check-dataset-link">
|
||||
candidate: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? check.candidate_dataset_id : 'n/a'}
|
||||
Te controleren: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? 'laag niet meer beschikbaar' : 'n.v.t.'}
|
||||
</span>
|
||||
<span className="quality-check-dataset-link">
|
||||
reference: {datasetNameById.get(check.reference_dataset_id) ?? check.reference_dataset_id}
|
||||
Referentie: {datasetNameById.get(check.reference_dataset_id) ?? 'laag niet meer beschikbaar'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={check.status === 'ok' || check.status === 'completed' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{check.status}
|
||||
{qualityStatusLabel(check.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quality-check-actions">
|
||||
@@ -528,10 +551,17 @@ export function QualityResultsPanel({
|
||||
<strong>{check.metrics.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Controle-ID</span>
|
||||
<strong>{check.id}</strong>
|
||||
<span>Beoordeling</span>
|
||||
<strong>{qualityScoreInterpretation(check.score)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische referentie</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Controle-ID: {check.id}</span>
|
||||
<span>Type: {check.check_type}</span>
|
||||
</div>
|
||||
</details>
|
||||
<div className="quality-metric-section">
|
||||
<span>Gemeten kwaliteit</span>
|
||||
<div className="quality-metric-grid">
|
||||
|
||||
@@ -48,6 +48,15 @@ interface SegmentationLabProps {
|
||||
onRunQa: () => void
|
||||
}
|
||||
|
||||
function segmentationRunLabel(run: SegmentationRunRead): string {
|
||||
const timestamp = run.finished_at ?? run.created_at
|
||||
const dateLabel = timestamp
|
||||
? new Intl.DateTimeFormat('nl-BE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(timestamp))
|
||||
: 'datum onbekend'
|
||||
const status = run.status === 'completed' ? 'afgerond' : run.status
|
||||
return `${run.model_name || 'Segmentatie'} · ${status} · ${dateLabel}`
|
||||
}
|
||||
|
||||
export function SegmentationLab({
|
||||
segmentationModels,
|
||||
loadingSegmentationModels,
|
||||
@@ -95,50 +104,50 @@ export function SegmentationLab({
|
||||
const segmentationRunReady =
|
||||
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
|
||||
const segmentationRunBlockedReason = !selectedProjectId
|
||||
? 'Select or create a project first'
|
||||
? 'Kies eerst een werkruimte'
|
||||
: !segmentationHasDataset
|
||||
? 'Select a raster dataset'
|
||||
? 'Kies een rasterbestand'
|
||||
: selectedSegmentationModelId === 'fixture-segmenter'
|
||||
? 'Fixture segmenter is explicit test/demo-only'
|
||||
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo’s'
|
||||
: !selectedSegmentationModelConfigured
|
||||
? selectedSegmentationModelLimitation ?? 'Selected segmentation model is not configured'
|
||||
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
|
||||
: null
|
||||
|
||||
return (
|
||||
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<p className="eyebrow">Polygon segmentation</p>
|
||||
<h2>Segmentation Lab</h2>
|
||||
<p className="eyebrow">Vlakken herkennen in beeld</p>
|
||||
<h2>Segmentatie</h2>
|
||||
</div>
|
||||
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
|
||||
Refresh models
|
||||
Status vernieuwen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details className="ai-lab-model-surface" aria-label="Segmentation model capabilities">
|
||||
<details className="ai-lab-model-surface" aria-label="Technische informatie over segmentatiemodellen">
|
||||
<summary>
|
||||
<span>Model registry</span>
|
||||
<strong>{segmentationModels.length} models</strong>
|
||||
<span>Technische modelinformatie</span>
|
||||
<strong>{segmentationModels.length} modellen</strong>
|
||||
</summary>
|
||||
<div className="ai-lab-disclosure-body">
|
||||
<div className="ai-lab-state-stack">
|
||||
{loadingSegmentationModels ? (
|
||||
<div className="result-state result-state-loading">
|
||||
<strong>Loading segmentation models.</strong>
|
||||
<p>Checking backend model registry availability.</p>
|
||||
<strong>Segmentatiemodellen worden gecontroleerd.</strong>
|
||||
<p>GeoIntel leest de modelregistratie van de backend.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationModelError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Segmentation model registry unavailable.</strong>
|
||||
<strong>De modelregistratie voor segmentatie is niet bereikbaar.</strong>
|
||||
<p>{segmentationModelError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationModels.length === 0 && !loadingSegmentationModels ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>No segmentation models reported by backend.</strong>
|
||||
<p>Refresh models after the backend is reachable.</p>
|
||||
<strong>De backend meldt geen segmentatiemodellen.</strong>
|
||||
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -146,14 +155,17 @@ export function SegmentationLab({
|
||||
{segmentationModels.map((model) => (
|
||||
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
|
||||
<strong>{model.display_name}</strong>
|
||||
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
|
||||
<div className="entity-meta">
|
||||
<span>{model.model_id}</span>
|
||||
<span>{model.framework}</span>
|
||||
<span>{model.task_type}</span>
|
||||
</div>
|
||||
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
|
||||
<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>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische identificatie</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Model-ID: {model.model_id}</span>
|
||||
<span>Framework: {model.framework}</span>
|
||||
<span>Taaktype: {model.task_type}</span>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -161,55 +173,55 @@ export function SegmentationLab({
|
||||
</details>
|
||||
|
||||
<div className="lab-block">
|
||||
<div className="ai-lab-run-surface" aria-label="Segmentation run controls">
|
||||
<h3>Run segmentation</h3>
|
||||
<div className="ai-lab-run-surface" aria-label="Segmentatie starten">
|
||||
<h3>Nieuwe segmentatie</h3>
|
||||
<div
|
||||
className={segmentationRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
|
||||
aria-label="Segmentation run readiness"
|
||||
aria-label="Startklaar voor segmentatie"
|
||||
>
|
||||
<div className="ai-lab-section-header">
|
||||
<div>
|
||||
<h3>Run readiness</h3>
|
||||
<p>Checks the selected raster and segmenter state before submitting a segmentation job.</p>
|
||||
<h3>Wat is nog nodig?</h3>
|
||||
<p>GeoIntel controleert het raster en model voordat de verwerking start.</p>
|
||||
</div>
|
||||
<span className={segmentationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{segmentationRunReady ? 'Ready to submit' : 'Blocked'}
|
||||
{segmentationRunReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="lab-readiness-grid">
|
||||
<div className={segmentationHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Raster dataset</span>
|
||||
<strong>{segmentationHasDataset ? 'Selected' : 'Select a raster dataset'}</strong>
|
||||
<span>Rasterbestand</span>
|
||||
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
|
||||
</div>
|
||||
<div className={selectedSegmentationModelConfigured ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Model availability</span>
|
||||
<span>Analysemodel</span>
|
||||
<strong>
|
||||
{selectedSegmentationModelConfigured
|
||||
? 'Selected model is configured'
|
||||
: selectedSegmentationModelLimitation ?? 'Select a configured segmentation model'}
|
||||
? 'Het gekozen model is beschikbaar'
|
||||
: selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
|
||||
<span>Tile manifest</span>
|
||||
<strong>{segmentationHasTileManifest ? 'Provided for provenance' : 'Optional for the fixture segmenter'}</strong>
|
||||
<span>Beeldtegels</span>
|
||||
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Niet vereist voor het fixturemodel'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={segmentationRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
|
||||
<span>Run action</span>
|
||||
<strong>{segmentationRunReady ? 'Ready to submit a segmentation job' : segmentationRunBlockedReason}</strong>
|
||||
<span>Analyse</span>
|
||||
<strong>{segmentationRunReady ? 'Klaar om segmentatie te starten' : segmentationRunBlockedReason}</strong>
|
||||
</div>
|
||||
{rasterDatasets.length === 0 ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>No raster datasets available for segmentation.</strong>
|
||||
<p>Upload or select a raster dataset in Data before running segmentation.</p>
|
||||
<strong>Geen rasterbestand beschikbaar voor segmentatie.</strong>
|
||||
<p>Voeg eerst een rasterbestand toe onder Bronnen.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lab-form-grid">
|
||||
<label>
|
||||
Raster dataset
|
||||
Rasterbestand
|
||||
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
|
||||
<option value="">Select raster dataset</option>
|
||||
<option value="">Kies een rasterbestand</option>
|
||||
{rasterDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{dataset.name}
|
||||
@@ -218,7 +230,7 @@ export function SegmentationLab({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Model
|
||||
Analysemodel
|
||||
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
|
||||
{segmentationModels.map((model) => (
|
||||
<option key={model.model_id} value={model.model_id}>
|
||||
@@ -228,7 +240,7 @@ export function SegmentationLab({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Min confidence
|
||||
Minimale zekerheid
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -239,22 +251,25 @@ export function SegmentationLab({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische beeldtegelinstelling</summary>
|
||||
<label>
|
||||
Tile manifest
|
||||
Beeldtegelmanifest
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Raster tile manifest path"
|
||||
placeholder="Pad naar het beeldtegelmanifest"
|
||||
value={segmentationTileManifestPath}
|
||||
onChange={(event) => onSetTileManifestPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</details>
|
||||
<button
|
||||
className="primary-action"
|
||||
type="button"
|
||||
onClick={onRunSegmentation}
|
||||
disabled={runningSegmentation || !segmentationRunReady}
|
||||
>
|
||||
Run segmentation
|
||||
Segmentatie starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,61 +277,66 @@ export function SegmentationLab({
|
||||
<div className="ai-lab-state-stack">
|
||||
{!selectedSegmentationModelConfigured ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Segmentation model is not ready.</strong>
|
||||
<p>{selectedSegmentationModelLimitation ?? 'Select a configured segmentation model'}</p>
|
||||
<strong>Het segmentatiemodel is nog niet gereed.</strong>
|
||||
<p>{selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel.'}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationRunError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Segmentation run failed.</strong>
|
||||
<strong>De segmentatie is mislukt.</strong>
|
||||
<p>{segmentationRunError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationRunResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {segmentationRunResult.status}</p>
|
||||
<p>Message: {segmentationRunResult.message}</p>
|
||||
<p>Analysis run: {segmentationRunResult.analysis_run_id}</p>
|
||||
<p>Job: {segmentationRunResult.job_id}</p>
|
||||
<p>Segmentations: {segmentationRunResult.segmentation_count}</p>
|
||||
<p>Status: {segmentationRunResult.status === 'completed' ? 'afgerond' : 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}
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische verwerking</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Analyserun-ID: {segmentationRunResult.analysis_run_id}</span>
|
||||
<span>Taak-ID: {segmentationRunResult.job_id}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ai-lab-results-surface" aria-label="Segmentation results">
|
||||
<div className="ai-lab-results-surface" aria-label="Segmentatieresultaten">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Segmentation results</h3>
|
||||
<p className="muted">Load persisted segmentation polygons and filter by class or confidence.</p>
|
||||
<h3>Herkende vlakken</h3>
|
||||
<p className="muted">Bekijk bewaarde resultaten en filter op klasse of zekerheid.</p>
|
||||
</div>
|
||||
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
|
||||
Refresh runs
|
||||
Analyses vernieuwen
|
||||
</button>
|
||||
</div>
|
||||
<div className="lab-form-grid">
|
||||
<label>
|
||||
Run
|
||||
Analyse
|
||||
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
|
||||
<option value="">Select segmentation run</option>
|
||||
<option value="">Kies een bewaarde analyse</option>
|
||||
{segmentationRuns.map((run) => (
|
||||
<option key={run.id} value={run.id}>
|
||||
{run.model_name || 'segmentation'} - {run.status} - {run.id}
|
||||
{segmentationRunLabel(run)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Class
|
||||
Klasse
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Class filter"
|
||||
placeholder="Filter op klasse"
|
||||
value={segmentationClassFilter}
|
||||
onChange={(event) => onSetClassFilter(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min confidence
|
||||
Minimale zekerheid
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -328,18 +348,18 @@ export function SegmentationLab({
|
||||
</label>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}>
|
||||
Load segmentations
|
||||
Resultaten laden
|
||||
</button>
|
||||
{loadingSegmentationResults ? (
|
||||
<div className="result-state result-state-loading">
|
||||
<strong>Loading segmentation results.</strong>
|
||||
<p>Retrieving persisted segmentation polygons for the selected run.</p>
|
||||
<strong>Segmentatieresultaten worden geladen.</strong>
|
||||
<p>GeoIntel leest de bewaarde polygonen van deze analyse.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ai-lab-state-stack">
|
||||
<div className="result-state result-state-ready">
|
||||
<strong>Segmentations loaded: {segmentationItems.length}</strong>
|
||||
<p>{selectedSegmentationRunId ? 'Loaded from persisted segmentation records.' : 'Select a segmentation run before loading results.'}</p>
|
||||
<strong>{segmentationItems.length} vlakken geladen</strong>
|
||||
<p>{selectedSegmentationRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{segmentationItems.length > 0 ? (
|
||||
@@ -347,23 +367,21 @@ export function SegmentationLab({
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th>Confidence</th>
|
||||
<th>Area m2</th>
|
||||
<th>Klasse</th>
|
||||
<th>Zekerheid</th>
|
||||
<th>Oppervlakte m²</th>
|
||||
<th>Model</th>
|
||||
<th>Tile</th>
|
||||
<th>Mask path</th>
|
||||
<th>Brontegel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{segmentationItems.map((segmentation) => (
|
||||
<tr key={segmentation.id}>
|
||||
<td>{segmentation.class_name}</td>
|
||||
<td>{segmentation.confidence?.toFixed(2) ?? 'n/a'}</td>
|
||||
<td>{segmentation.area_m2?.toFixed(2) ?? 'n/a'}</td>
|
||||
<td>{segmentation.confidence?.toFixed(2) ?? 'n.v.t.'}</td>
|
||||
<td>{segmentation.area_m2?.toFixed(2) ?? 'n.v.t.'}</td>
|
||||
<td>{segmentation.model_name}</td>
|
||||
<td>{segmentation.source_tile_path || (segmentation.tile_index ?? 'n/a')}</td>
|
||||
<td>{segmentation.mask_path || 'n/a'}</td>
|
||||
<td>{segmentation.tile_index ?? 'n.v.t.'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -372,12 +390,12 @@ export function SegmentationLab({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ai-lab-qa-surface" aria-label="Segmentation QA controls and results">
|
||||
<h3>Segmentation QA</h3>
|
||||
<div className="ai-lab-qa-surface" aria-label="Kwaliteitscontrole voor segmentatie">
|
||||
<h3>Kwaliteitscontrole segmentatie</h3>
|
||||
<label>
|
||||
Reference dataset
|
||||
Referentielaag
|
||||
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
|
||||
<option value="">Select reference dataset</option>
|
||||
<option value="">Kies een referentielaag</option>
|
||||
{referenceDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
{dataset.name}
|
||||
@@ -386,24 +404,27 @@ export function SegmentationLab({
|
||||
</select>
|
||||
</label>
|
||||
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
|
||||
Compare segmentations to reference
|
||||
Vergelijk met referentielaag
|
||||
</button>
|
||||
{segmentationQaError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Segmentation QA failed.</strong>
|
||||
<strong>De kwaliteitscontrole is mislukt.</strong>
|
||||
<p>{segmentationQaError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{segmentationQaResult ? (
|
||||
<div className="result-summary-card">
|
||||
<p>Status: {segmentationQaResult.status}</p>
|
||||
<p>Quality check: {segmentationQaResult.quality_check_id}</p>
|
||||
<p>Precision: {segmentationQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
|
||||
<p>Recall: {segmentationQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
|
||||
<p>F1: {segmentationQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
|
||||
<p>Mean IoU: {segmentationQaResult.mean_iou?.toFixed(3) ?? 'n/a'}</p>
|
||||
<p>False positives: {segmentationQaResult.false_positives}</p>
|
||||
<p>False negatives: {segmentationQaResult.false_negatives}</p>
|
||||
<p>Status: {segmentationQaResult.status === 'completed' ? 'afgerond' : segmentationQaResult.status}</p>
|
||||
<p>Precisie: {segmentationQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Herkenningsgraad: {segmentationQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>F1: {segmentationQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Gemiddelde overlap: {segmentationQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
|
||||
<p>Onterecht gevonden: {segmentationQaResult.false_positives}</p>
|
||||
<p>Gemist: {segmentationQaResult.false_negatives}</p>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische referentie</summary>
|
||||
<span>Kwaliteitscontrole-ID: {segmentationQaResult.quality_check_id}</span>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ export function useProjectWorkspace() {
|
||||
const [loadingProjects, setLoadingProjects] = useState(false)
|
||||
const [loadingAreas, setLoadingAreas] = useState(false)
|
||||
const [loadingDatasets, setLoadingDatasets] = useState(false)
|
||||
const [archivingProjectId, setArchivingProjectId] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const projectDataRequestSequence = useRef(0)
|
||||
|
||||
@@ -159,7 +160,7 @@ export function useProjectWorkspace() {
|
||||
setSelectedProjectId(nextProjectId)
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to load projects')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Werkruimtes konden niet worden geladen')
|
||||
} finally {
|
||||
setLoadingProjects(false)
|
||||
}
|
||||
@@ -180,7 +181,7 @@ export function useProjectWorkspace() {
|
||||
return projectData
|
||||
} catch (error) {
|
||||
if (requestId === projectDataRequestSequence.current) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'De gegevens van de werkruimte konden niet worden geladen')
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
@@ -194,7 +195,7 @@ export function useProjectWorkspace() {
|
||||
const createProject = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!projectForm.name.trim()) {
|
||||
setErrorMessage('Project name is required')
|
||||
setErrorMessage('Geef een naam voor de werkruimte op')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -207,21 +208,21 @@ export function useProjectWorkspace() {
|
||||
setSelectedProjectId(createdProject.id)
|
||||
await loadProjects(createdProject.id)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to create project')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden aangemaakt')
|
||||
}
|
||||
}
|
||||
|
||||
const createArea = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!selectedProjectId) {
|
||||
setErrorMessage('Select a project first')
|
||||
setErrorMessage('Kies eerst een werkruimte')
|
||||
return
|
||||
}
|
||||
let geometry: AreaCreate['geometry']
|
||||
try {
|
||||
geometry = JSON.parse(areaForm.geometry) as AreaCreate['geometry']
|
||||
} catch {
|
||||
setErrorMessage('Invalid GeoJSON geometry JSON')
|
||||
setErrorMessage('De opgegeven GeoJSON-geometrie is ongeldig')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -233,7 +234,24 @@ export function useProjectWorkspace() {
|
||||
await loadProjectData(selectedProjectId)
|
||||
setAreaForm((previous) => ({ ...previous, name: '' }))
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to create area')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Het gebied kon niet worden aangemaakt')
|
||||
}
|
||||
}
|
||||
|
||||
const archiveProject = async (projectId: string) => {
|
||||
setArchivingProjectId(projectId)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
await projectsApi.update(projectId, { status: 'archived' })
|
||||
if (selectedProjectId === projectId) {
|
||||
setSelectedProjectId(null)
|
||||
resetProjectData()
|
||||
}
|
||||
await loadProjects()
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden gearchiveerd')
|
||||
} finally {
|
||||
setArchivingProjectId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +270,7 @@ export function useProjectWorkspace() {
|
||||
loadingProjects,
|
||||
loadingAreas,
|
||||
loadingDatasets,
|
||||
archivingProjectId,
|
||||
errorMessage,
|
||||
projectForm,
|
||||
areaForm,
|
||||
@@ -259,6 +278,7 @@ export function useProjectWorkspace() {
|
||||
loadProjectData,
|
||||
createProject,
|
||||
createArea,
|
||||
archiveProject,
|
||||
resetProjectData,
|
||||
setSelectedProjectId,
|
||||
setErrorMessage,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from './client'
|
||||
import type { ProjectCreate, ProjectListResponse, ProjectRead } from '../../types'
|
||||
import type { ProjectCreate, ProjectListResponse, ProjectRead, ProjectUpdate } from '../../types'
|
||||
|
||||
export const projectsApi = {
|
||||
list: (options?: { name?: string; limit?: number }): Promise<ProjectListResponse> => {
|
||||
list: (options?: { name?: string; limit?: number; status?: 'active' | 'archived' | 'all' }): Promise<ProjectListResponse> => {
|
||||
const search = new URLSearchParams()
|
||||
if (options?.name) search.set('name', options.name)
|
||||
if (options?.limit) search.set('limit', String(options.limit))
|
||||
if (options?.status) search.set('status', options.status)
|
||||
const query = search.toString()
|
||||
return apiGet<ProjectListResponse>(`/api/v1/projects${query ? `?${query}` : ''}`)
|
||||
},
|
||||
create: (payload: ProjectCreate): Promise<ProjectRead> => apiPost<ProjectRead>('/api/v1/projects', payload),
|
||||
get: (id: string): Promise<ProjectRead> => apiGet<ProjectRead>(`/api/v1/projects/${id}`),
|
||||
update: (id: string, payload: Partial<ProjectCreate>): Promise<ProjectRead> =>
|
||||
update: (id: string, payload: ProjectUpdate): Promise<ProjectRead> =>
|
||||
apiPatch<ProjectRead>(`/api/v1/projects/${id}`, payload),
|
||||
delete: (id: string): Promise<{ deleted: boolean }> =>
|
||||
apiDelete<{ deleted: boolean }>(`/api/v1/projects/${id}`),
|
||||
|
||||
@@ -2281,6 +2281,80 @@ details.ai-lab-model-surface > summary strong {
|
||||
}
|
||||
}
|
||||
|
||||
/* Progressive disclosure keeps technical provenance available without
|
||||
competing with the operational workflow. */
|
||||
|
||||
.technical-inline-details {
|
||||
min-width: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.technical-inline-details > summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.technical-inline-details[open] > summary {
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.technical-inline-details .entity-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.7rem;
|
||||
}
|
||||
|
||||
.technical-inline-details .entity-meta > * {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detection-model-management {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-lifecycle-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.project-lifecycle-actions p {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.workspace-grid-ai {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detection-lab-shell .lab-form-grid,
|
||||
.detection-model-management .lab-form-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.project-lifecycle-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.project-lifecycle-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.geo-image-quality-metrics,
|
||||
.detection-review-summary {
|
||||
display: grid;
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface ProjectCreate {
|
||||
region?: string
|
||||
}
|
||||
|
||||
export interface ProjectUpdate extends Partial<ProjectCreate> {
|
||||
status?: 'active' | 'archived'
|
||||
}
|
||||
|
||||
export interface ProjectListResponse {
|
||||
items: ProjectRead[]
|
||||
total: number
|
||||
|
||||
Reference in New Issue
Block a user