Close full operational audit findings
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 14:19:02 +02:00
parent cc36dabcbe
commit 78a5f0b0b2
81 changed files with 2248 additions and 1467 deletions
@@ -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>