Close full operational audit findings
This commit is contained in:
+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