feat: simplify regional end user workbench
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 21:40:32 +02:00
parent 1693e4ca1f
commit b5bcd9b041
52 changed files with 1183 additions and 617 deletions
+100 -79
View File
@@ -30,6 +30,8 @@ import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap'
import { useViewportVectorLayer } from './hooks/useViewportVectorLayer'
import { getDatasetDisplayName } from './lib/datasetDisplay'
import { REGIONAL_WORKSPACE_LABEL, REGIONAL_WORKSPACE_PROJECT_NAME } from './config/primaryFocus'
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
@@ -38,19 +40,20 @@ function isVectorDatasetType(datasetType: string): boolean {
type WorkspaceKey = 'overview' | 'data' | 'map' | 'analysis' | 'ai' | 'exports' | 'system'
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
{ key: 'overview', label: 'Overview', description: 'V1 readiness and next action' },
{ key: 'data', label: 'Data', description: 'Projects, AOI and datasets' },
{ key: 'map', label: 'Map', description: 'Layer review and inspection' },
{ key: 'analysis', label: 'QA/QC', description: 'Quality checks and change detection' },
{ key: 'ai', label: 'AI Labs', description: 'Detection and segmentation' },
{ key: 'exports', label: 'Exports', description: 'Artifacts and downloads' },
{ key: 'system', label: 'System', description: 'Provider capabilities' },
{ key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' },
{ key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' },
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
{ key: 'system', label: 'Beheer', description: 'Databronnen en systeemstatus' },
]
const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
{ label: 'Workspace', keys: ['overview', 'data', 'map'] },
{ label: 'Analyze', keys: ['analysis', 'ai'] },
{ label: 'Deliver', keys: ['exports', 'system'] },
{ label: 'Verkennen', keys: ['map', 'data'] },
{ label: 'Analyseren', keys: ['analysis', 'ai'] },
{ label: 'Afronden', keys: ['exports'] },
{ label: 'Beheer', keys: ['overview', 'system'] },
]
function App(): JSX.Element {
@@ -659,43 +662,45 @@ function App(): JSX.Element {
target: 'exports',
},
]
const projectContextLabel = selectedProject?.name ?? 'No project'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Select area' : 'No AOI')
const datasetContextLabel = selectedDataset?.name ?? (datasets.length > 0 ? 'Select dataset' : 'No dataset')
const projectContextLabel = selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME
? REGIONAL_WORKSPACE_LABEL
: selectedProject?.name ?? 'Geen werkruimte'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Kies een gebied' : 'Geen gebied')
const datasetContextLabel = selectedDataset ? getDatasetDisplayName(selectedDataset) : datasets.length > 0 ? 'Kies een bron' : 'Geen bron'
const layerContextLabel = mapFeatureCollection
? `${mapFeatureCount} features`
: viewportVectorLayerActive
? 'Viewport layer selected'
: 'No active layer'
? 'Kaartlaag gekozen'
: 'Geen actieve laag'
return (
<div className="app-shell workbench-shell">
<a className="skip-link" href="#workspace-main">
Skip to workspace
Ga naar de werkruimte
</a>
<header className="workbench-topbar">
<div className="brand-block">
<span className="brand-mark" aria-hidden="true">GI</span>
<div>
<h1>GeoIntel</h1>
<p>GeoAI Workbench · Mol / Kempen</p>
<p>GeoAI-werkruimte · Mol en de Kempen</p>
</div>
</div>
<div className="context-bar" aria-label="Active workbench context">
<div>
<span>Project</span>
<span>Werkruimte</span>
<strong title={projectContextLabel}>{projectContextLabel}</strong>
</div>
<div>
<span>AOI</span>
<span>Gebied</span>
<strong title={areaContextLabel}>{areaContextLabel}</strong>
</div>
<div>
<span>Dataset</span>
<span>Bron</span>
<strong title={datasetContextLabel}>{datasetContextLabel}</strong>
</div>
<div>
<span>Layer</span>
<span>Kaartlaag</span>
<strong title={layerContextLabel}>{layerContextLabel}</strong>
</div>
</div>
@@ -770,7 +775,7 @@ function App(): JSX.Element {
</button>
))}
</div>
<span>{selectedProject ? `Active project: ${selectedProject.name}` : 'Start with a project or load the demo workflow.'}</span>
<span>{selectedProject ? `Actief bereik: ${projectContextLabel}` : 'De regionale gegevens worden ingeladen.'}</span>
</div>
{activeWorkspace === 'overview' ? (
@@ -784,6 +789,11 @@ function App(): JSX.Element {
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
<strong>{workflowGuidanceComplete ? 'voltooid' : 'optionele stappen open'}</strong>
</summary>
<section className="workflow-guidance-panel" aria-label="V1 workflow guidance">
<div className="panel-title-row">
<div>
@@ -860,6 +870,7 @@ function App(): JSX.Element {
</button>
</div>
</section>
</details>
</div>
) : null}
@@ -958,7 +969,6 @@ function App(): JSX.Element {
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId}
onSelectProject={selectProject}
onOpenDatasetInMap={openDatasetInMap}
onSetAreaLayerVisible={setAreaLayerVisible}
onSetAreaLayerOpacity={setAreaLayerOpacity}
@@ -981,22 +991,6 @@ function App(): JSX.Element {
{activeWorkspace === 'analysis' ? (
<div className="workspace-grid workspace-grid-analysis">
<ChangeDetectionPanel
vectorDatasets={availableVectorDatasets}
sourceDatasetId={changeSourceDatasetId}
targetDatasetId={changeTargetDatasetId}
iouThreshold={changeIouThreshold}
includeUnchanged={changeIncludeUnchanged}
running={runningChangeDetection}
result={changeDetectionResult}
error={changeDetectionError}
onSourceDatasetChange={setChangeSourceDatasetId}
onTargetDatasetChange={setChangeTargetDatasetId}
onIouThresholdChange={setChangeIouThreshold}
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
<QualityResultsPanel
selectedProjectId={selectedProjectId}
qualityChecks={qualityChecks}
@@ -1008,6 +1002,27 @@ function App(): JSX.Element {
evidenceLoading={qualityEvidenceLoading}
evidenceError={qualityEvidenceError}
/>
<details className="secondary-analysis-disclosure">
<summary>
<span>Historische vectorlagen vergelijken</span>
<strong>Geavanceerd</strong>
</summary>
<ChangeDetectionPanel
vectorDatasets={availableVectorDatasets}
sourceDatasetId={changeSourceDatasetId}
targetDatasetId={changeTargetDatasetId}
iouThreshold={changeIouThreshold}
includeUnchanged={changeIncludeUnchanged}
running={runningChangeDetection}
result={changeDetectionResult}
error={changeDetectionError}
onSourceDatasetChange={setChangeSourceDatasetId}
onTargetDatasetChange={setChangeTargetDatasetId}
onIouThresholdChange={setChangeIouThreshold}
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
</details>
</div>
) : null}
@@ -1069,46 +1084,52 @@ function App(): JSX.Element {
onApplyOperatorProfile={applyDetectionOperatorProfile}
/>
<SegmentationLab
segmentationModels={segmentationModels}
loadingSegmentationModels={loadingSegmentationModels}
segmentationModelError={segmentationModelError}
selectedSegmentationDatasetId={selectedSegmentationDatasetId}
selectedSegmentationModelId={selectedSegmentationModelId}
segmentationTileManifestPath={segmentationTileManifestPath}
segmentationConfidenceThreshold={segmentationConfidenceThreshold}
runningSegmentation={runningSegmentation}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
segmentationReferenceDatasetId={segmentationReferenceDatasetId}
segmentationQaResult={segmentationQaResult}
segmentationQaError={segmentationQaError}
runningSegmentationQa={runningSegmentationQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
onLoadModels={loadSegmentationModels}
onSelectDataset={setSelectedSegmentationDatasetId}
onSelectModel={setSelectedSegmentationModelId}
onSetTileManifestPath={setSegmentationTileManifestPath}
onSetConfidenceThreshold={setSegmentationConfidenceThreshold}
onRunSegmentation={runSegmentation}
onLoadRuns={() => loadSegmentationRuns()}
onSelectRun={setSelectedSegmentationRunId}
onSetClassFilter={setSegmentationClassFilter}
onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter}
onLoadResults={() => loadSegmentationResults()}
onSelectReferenceDataset={setSegmentationReferenceDatasetId}
onRunQa={runSegmentationQa}
/>
<details className="secondary-analysis-disclosure segmentation-disclosure">
<summary>
<span>Segmentatie van beeldvlakken</span>
<strong>Nog niet geconfigureerd</strong>
</summary>
<SegmentationLab
segmentationModels={segmentationModels}
loadingSegmentationModels={loadingSegmentationModels}
segmentationModelError={segmentationModelError}
selectedSegmentationDatasetId={selectedSegmentationDatasetId}
selectedSegmentationModelId={selectedSegmentationModelId}
segmentationTileManifestPath={segmentationTileManifestPath}
segmentationConfidenceThreshold={segmentationConfidenceThreshold}
runningSegmentation={runningSegmentation}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
segmentationReferenceDatasetId={segmentationReferenceDatasetId}
segmentationQaResult={segmentationQaResult}
segmentationQaError={segmentationQaError}
runningSegmentationQa={runningSegmentationQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
onLoadModels={loadSegmentationModels}
onSelectDataset={setSelectedSegmentationDatasetId}
onSelectModel={setSelectedSegmentationModelId}
onSetTileManifestPath={setSegmentationTileManifestPath}
onSetConfidenceThreshold={setSegmentationConfidenceThreshold}
onRunSegmentation={runSegmentation}
onLoadRuns={() => loadSegmentationRuns()}
onSelectRun={setSelectedSegmentationRunId}
onSetClassFilter={setSegmentationClassFilter}
onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter}
onLoadResults={() => loadSegmentationResults()}
onSelectReferenceDataset={setSegmentationReferenceDatasetId}
onRunQa={runSegmentationQa}
/>
</details>
</div>
) : null}
@@ -28,21 +28,21 @@ function countReferenceDatasets(datasets: DatasetCreateResponse[]): number {
function nextAction(items: StatusItem[]): string {
if (!items.some((item) => item.key === 'project' && item.state === 'ready')) {
return 'Create or open a project.'
return 'De regionale werkruimte kon niet worden geopend.'
}
if (!items.some((item) => item.key === 'area' && item.state === 'ready')) {
return 'Create an AOI or select an area with geometry.'
return 'Kies een gemeente of de volledige regio.'
}
if (!items.some((item) => item.key === 'datasets' && item.state === 'ready')) {
return 'Upload a vector or raster dataset, or load the offline demo workflow.'
return 'Laad een kaartlaag of luchtbeeld in.'
}
if (!items.some((item) => item.key === 'qa' && item.state === 'ready')) {
return 'Run QA/QC against a reference dataset when candidate and reference data are available.'
return 'Kaart en bronnen zijn klaar. Kwaliteitscontrole is beschikbaar zodra er een analyseresultaat is.'
}
if (!items.some((item) => item.key === 'exports' && item.state === 'ready')) {
return 'Create a metadata, report or GeoJSON export for handoff.'
return 'De analyse is klaar; maak eventueel een rapport of download.'
}
return 'Workbench has the core V1 loop populated.'
return 'Alle onderdelen van de werkruimte bevatten bewaarde resultaten.'
}
export function WorkbenchStatusStrip({
@@ -64,63 +64,64 @@ export function WorkbenchStatusStrip({
const items: StatusItem[] = [
{
key: 'project',
label: 'Project',
value: selectedProject ? selectedProject.name : 'No project',
detail: selectedProject ? selectedProject.region : 'Open or create a project',
label: 'Werkruimte',
value: selectedProject ? selectedProject.name : 'Geen werkruimte',
detail: selectedProject ? selectedProject.region : 'Niet geladen',
state: selectedProject ? 'ready' : 'waiting',
},
{
key: 'area',
label: 'AOI',
value: `${areas.length} area${areas.length === 1 ? '' : 's'}`,
detail: selectedAreaHasGeometry ? 'selected area has map geometry' : 'no selected map geometry',
label: 'Gebieden',
value: `${areas.length} beschikbaar`,
detail: selectedAreaHasGeometry ? 'Kaartgrens beschikbaar' : 'Geen kaartgrens geselecteerd',
state: selectedAreaHasGeometry ? 'ready' : areas.length > 0 ? 'warning' : 'waiting',
},
{
key: 'datasets',
label: 'Datasets',
value: `${readyDatasets}/${datasets.length} ready`,
detail: `${vectorDatasets} vector, ${rasterDatasets} raster, ${referenceDatasets} reference`,
label: 'Bronnen',
value: `${readyDatasets}/${datasets.length} beschikbaar`,
detail: `${vectorDatasets} kaartlagen, ${rasterDatasets} luchtbeelden, ${referenceDatasets} officiële referenties`,
state: datasets.length === 0 ? 'waiting' : readyDatasets === datasets.length ? 'ready' : 'warning',
},
{
key: 'map',
label: 'Map',
value: `${activeLayerFeatureCount} feature${activeLayerFeatureCount === 1 ? '' : 's'}`,
detail: selectedAreaHasGeometry ? 'AOI overlay available' : 'AOI overlay missing',
label: 'Kaart',
value: `${activeLayerFeatureCount} zichtbare objecten`,
detail: selectedAreaHasGeometry ? 'Gebiedsgrens beschikbaar' : 'Gebiedsgrens ontbreekt',
state: selectedAreaHasGeometry || activeLayerFeatureCount > 0 ? 'ready' : 'waiting',
},
{
key: 'qa',
label: 'QA/QC',
value: `${qualityChecks.length} check${qualityChecks.length === 1 ? '' : 's'}`,
detail: latestQualityCheck ? `${latestQualityCheck.check_type}: ${latestQualityCheck.status}` : 'no persisted QA/QC result',
label: 'Kwaliteit',
value: `${qualityChecks.length} controles`,
detail: latestQualityCheck ? `${latestQualityCheck.check_type}: ${latestQualityCheck.status}` : 'Nog geen controle uitgevoerd',
state: qualityChecks.length > 0 ? 'ready' : 'waiting',
},
{
key: 'exports',
label: 'Exports',
value: `${exports.length} export${exports.length === 1 ? '' : 's'}`,
detail: latestExport ? `${latestExport.export_type}: ${latestExport.status}` : 'no handoff artifact yet',
label: 'Downloads',
value: `${exports.length} bestanden`,
detail: latestExport ? `${latestExport.export_type}: ${latestExport.status}` : 'Nog geen bestand gemaakt',
state: exports.length > 0 ? 'ready' : 'waiting',
},
]
const readyCount = items.filter((item) => item.state === 'ready').length
const coreReady = items.slice(0, 4).every((item) => item.state === 'ready')
return (
<section className="workbench-status-strip" aria-label="Workbench readiness">
<div className="status-strip-header">
<div>
<p className="eyebrow">V1 readiness</p>
<p className="eyebrow">Platformstatus</p>
<h2>
<span className="sr-only">Workbench status: </span>
{readyCount === items.length ? 'Mol workbench is ready' : 'Mol workbench needs attention'}
{coreReady ? 'Kaartwerkruimte is gebruiksklaar' : 'Kaartwerkruimte vraagt aandacht'}
</h2>
<p className="status-next-action">{nextAction(items)}</p>
</div>
<div className="status-readiness" aria-label={`${readyCount} of ${items.length} workbench stages ready`}>
<strong>{readyCount}/{items.length}</strong>
<span>stages ready</span>
<span>onderdelen gereed</span>
</div>
</div>
<div className="status-strip-grid">
@@ -1,5 +1,6 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../../types'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
interface DatasetFormState {
datasetType: string
@@ -70,25 +71,25 @@ function normalizeDatasetRole(dataset: DatasetCreateResponse): 'reference' | 'ca
function datasetRoleLabel(role: string): string {
if (role === 'reference') {
return 'Reference'
return 'Officiële referentie'
}
if (role === 'candidate') {
return 'Candidate'
return 'Analyse-resultaat'
}
return 'Source'
return 'Eigen bron'
}
function datasetActionHint(dataset: DatasetCreateResponse, role: string): string {
if (role === 'reference') {
return 'QA reference layer. Select it when comparing candidates against trusted geometry.'
return 'Officiële laag voor kaartanalyse en kwaliteitscontrole.'
}
if (role === 'candidate') {
return 'QA candidate layer. Open on the map or send to Export / QA.'
return 'Bewaard resultaat dat opnieuw op de kaart, in kwaliteit of als download kan worden gebruikt.'
}
if (dataset.dataset_type === 'raster') {
return 'Raster source. Use the raster tools in the inspector after selecting it.'
return 'Luchtbeeld of rasterbron voor kaart- en beeldanalyse.'
}
return 'Source dataset. Inspect details before using it in downstream workflows.'
return 'Ingeladen gegevensbron voor verdere analyse.'
}
export function DatasetPanel({
@@ -111,30 +112,30 @@ export function DatasetPanel({
const roleSummaries = [
{
key: 'selected',
label: 'Selected',
label: 'Geselecteerd',
count: selectedDatasetId ? 1 : 0,
hint: 'Active',
hint: 'Actief',
className: 'dataset-role-selected',
},
{
key: 'reference',
label: 'Reference',
label: 'Referentie',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
hint: 'QA truth',
hint: 'Officieel',
className: 'dataset-role-reference',
},
{
key: 'candidate',
label: 'Candidate',
label: 'Resultaat',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
hint: 'QA candidate',
hint: 'Afgeleid',
className: 'dataset-role-candidate',
},
{
key: 'source',
label: 'Source',
label: 'Eigen bron',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
hint: 'Uploads',
hint: 'Ingeladen',
className: 'dataset-role-source',
},
]
@@ -143,23 +144,23 @@ export function DatasetPanel({
<section className="workspace-panel" data-testid="dataset-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Data catalog</p>
<h2>Datasets</h2>
<p className="eyebrow">Ingeladen gegevens</p>
<h2>Bronnen</h2>
</div>
<span className="count-pill">{readyDatasets}/{datasets.length} ready</span>
<span className="count-pill">{readyDatasets}/{datasets.length} beschikbaar</span>
</div>
<div className="data-selection-summary data-selection-summary-dataset">
<span>Selected dataset</span>
<strong>{selectedDataset?.name ?? 'No dataset selected'}</strong>
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} / ${selectedDataset.status}` : 'Inspect a dataset to load details and tools.'}</small>
<span>Actieve bron</span>
<strong>{selectedDataset ? getDatasetDisplayName(selectedDataset) : 'Geen bron geselecteerd'}</strong>
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} · ${selectedDataset.status === 'ready' ? 'beschikbaar' : selectedDataset.status}` : 'Kies een bron om deze op de kaart te openen.'}</small>
</div>
<details className="dataset-upload-block data-panel-form-block">
<summary>Upload source data</summary>
<summary>Eigen bronbestand toevoegen</summary>
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
<label>
Type
Bestandstype
<select
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
@@ -170,7 +171,7 @@ export function DatasetPanel({
</select>
</label>
<label>
Source
Bronnaam
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
@@ -178,12 +179,12 @@ export function DatasetPanel({
/>
</label>
<label>
AOI
Gebied
<select
value={datasetForm.areaId}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
>
<option value="">No area</option>
<option value="">Volledige bron</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
@@ -192,7 +193,7 @@ export function DatasetPanel({
</select>
</label>
<label className="file-input-label">
File
Bestand
<input
type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff"
@@ -200,7 +201,7 @@ export function DatasetPanel({
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Upload dataset
Bron inladen
</button>
</form>
</details>
@@ -215,15 +216,15 @@ export function DatasetPanel({
))}
</div>
{loadingDatasets ? <p>Loading datasets...</p> : null}
{loadingDatasets ? <p>Bronnen laden...</p> : null}
{datasets.length === 0 ? (
<div className="empty-state">
<strong>No datasets yet</strong>
<p>Upload vector, GeoJSON or raster data after selecting a project.</p>
<strong>Nog geen bronnen beschikbaar</strong>
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
</div>
) : null}
<div className="data-panel-list-block dataset-catalog-block">
<p className="data-section-label">Available datasets</p>
<p className="data-section-label">Beschikbare bronnen</p>
<ul className="dataset-list">
{datasets.map((dataset) => {
const datasetRole = normalizeDatasetRole(dataset)
@@ -236,22 +237,16 @@ export function DatasetPanel({
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
<span>{dataset.dataset_type}</span>
</div>
<strong className="dataset-card-title">{dataset.name}</strong>
<div className="entity-meta">
<span>Source: {dataset.source_name ?? dataset.source}</span>
{dataset.reference_layer_name ? <span>Layer: {dataset.reference_layer_name}</span> : null}
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'n/a'}</span>
<strong className="dataset-card-title">{getDatasetDisplayName(dataset)}</strong>
<div className="entity-meta dataset-card-primary-meta">
<span>{(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
<span>{dataset.source_name ?? dataset.source}</span>
</div>
</div>
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
{dataset.status === 'ready' ? 'beschikbaar' : dataset.status === 'failed' ? 'mislukt' : 'bezig'}
</span>
</div>
<div className="dataset-metrics data-density-grid">
<span>size: {formatBytes(dataset.size_bytes)}</span>
<span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
<span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<p className="dataset-action-hint">{datasetActionHint(dataset, datasetRole)}</p>
<div className="dataset-action-grid">
<button
@@ -261,17 +256,17 @@ export function DatasetPanel({
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`}
>
<span>Inspect</span>
<small>Select / details</small>
<span>Bekijken</span>
<small>Details en acties</small>
</button>
<button
className="secondary-action dataset-action-button"
type="button"
onClick={() => onOpenDatasetInMap(dataset)}
aria-label="Open in map"
aria-label="Open op kaart"
>
<span>Map</span>
<small>Open layer</small>
<span>Kaart</span>
<small>Laag openen</small>
</button>
<button
className="secondary-action dataset-action-button"
@@ -279,20 +274,33 @@ export function DatasetPanel({
onClick={() => onOpenDatasetExport(dataset)}
disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}
>
<span>Export / QA</span>
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'Compare or export' : 'Vector/GeoJSON only'}</small>
<span>Downloaden</span>
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'GeoJSON of rapport' : 'Enkel vectorlagen'}</small>
</button>
</div>
<details className="dataset-technical-details">
<summary>Broninformatie</summary>
<div className="entity-meta">
<span>Bestand: {dataset.name}</span>
<span>Bron: {dataset.source_name ?? dataset.source}</span>
{dataset.reference_layer_name ? <span>Laag: {dataset.reference_layer_name}</span> : null}
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'onbekend'}</span>
</div>
<div className="dataset-metrics data-density-grid">
<span>Grootte: {formatBytes(dataset.size_bytes)}</span>
<span>Objecten: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'onbekend'}</span>
<span>Begrenzing: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<button
className="secondary-action dataset-action-button"
className="secondary-action"
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
aria-label="Refresh metadata"
aria-label="Bronmetadata vernieuwen"
>
<span>Metadata</span>
<small>{dataset.dataset_type === 'raster' ? 'Raster metadata is automatic' : 'Refresh vector stats'}</small>
Metadata vernieuwen
</button>
</div>
</details>
</li>
)
})}
@@ -145,6 +145,14 @@ export function DetectionLab({
}: DetectionLabProps): JSX.Element {
const selectedDetectionModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) ?? null
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,
)
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null
@@ -178,19 +186,19 @@ export function DetectionLab({
detectionHasExplicitModelAsset &&
detectionHasTileManifest
const detectionRunBlockedReason = !selectedProjectId
? 'Select or create a project first'
? 'De regionale werkruimte is nog niet geladen'
: !detectionHasDataset
? 'Select a raster dataset'
? 'Kies een luchtbeeld'
: !detectionHasModel
? 'Select a detection model'
? 'Kies een analysemodel'
: selectedDetectionModelId === 'manual-fixture-detector'
? 'Fixture model is explicit test/demo-only'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Selected model is not configured'
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
: !detectionHasExplicitModelAsset
? 'Select a local model asset deliberately'
? 'Kies een lokaal modelbestand onder beheer'
: !detectionHasTileManifest
? 'Provide a raster tile manifest for configured YOLO'
? 'Maak eerst beeldtegels voor het gekozen luchtbeeld'
: null
const calibrationRunReady = detectionRunReady && detectionReferenceDatasetId.length > 0 && calibrationThresholdText.trim().length > 0
@@ -198,18 +206,41 @@ export function DetectionLab({
<section className="workspace-panel ai-lab-shell detection-lab-shell">
<div className="panel-title-row">
<div>
<p className="eyebrow">Object detection</p>
<h2>Detection Lab</h2>
<p className="eyebrow">Analyse van luchtbeelden</p>
<h2>Gebouwen herkennen</h2>
</div>
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
Refresh models
Status vernieuwen
</button>
</div>
<div className="ai-user-summary" aria-label="Status gebouwdetectie">
<div className="ai-user-summary-card ai-user-summary-card-primary">
<span>Actieve analyse</span>
<strong>Gebouwdetectie</strong>
<p>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}</p>
</div>
<div className={yoloRuntimeReady ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>PyTorch-runtime</span>
<strong>{yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}</strong>
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
</div>
<div className="ai-user-summary-card">
<span>Gevalideerde kwaliteit</span>
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
<p>{selectedOperatorProfile ? `${selectedOperatorProfile.positiveSampleCount} testgebieden · resultaten blijven controleplichtig` : 'Kies het goedgekeurde lokale profiel.'}</p>
</div>
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>Beschikbare luchtbeelden</span>
<strong>{rasterDatasets.length}</strong>
<p>{rasterDatasets.length > 0 ? 'Klaar om een beeld te kiezen.' : 'Laad eerst een gegeorefereerd luchtbeeld in.'}</p>
</div>
</div>
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
<summary>
<span>Model registry</span>
<strong>{detectionModels.length} models</strong>
<span>Technische modelinformatie</span>
<strong>{detectionModels.length} registraties</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
@@ -257,21 +288,25 @@ export function DetectionLab({
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<div className="ai-lab-model-surface" aria-label="Local model asset selection">
<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>Explicit model asset</h3>
<p>Local model assets are read-only. No model file is selected automatically. Choose an existing local model asset before submitting configured YOLO.</p>
<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 ? 'asset selected' : 'no explicit asset'}
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="model-asset-guidance">
<strong>Operator profiles</strong>
<strong>Gevalideerde profielen</strong>
<p>
Candidate profiles apply a local model asset and confidence threshold only after an explicit click.
Promoted profiles still require explicit operator action and do not mutate the runtime environment.
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">
@@ -288,22 +323,22 @@ export function DetectionLab({
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
{profile.defaultApproved ? 'default-approved' : 'Candidate only - not default-approved'}
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat · extra controle vereist'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>threshold {profile.confidenceThreshold.toFixed(2)}</span>
<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>positive AOIs {profile.positiveSampleCount}</span>
<span>max background FP {profile.maxBackgroundDetections}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
</div>
<div className="entity-meta">
<span>asset: {profile.modelAssetId}</span>
<span>promotionRecommendation: {profile.promotionRecommendation}</span>
<span>available: {profileAsset ? 'yes' : 'not mounted'}</span>
<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
@@ -312,7 +347,7 @@ export function DetectionLab({
onClick={() => onApplyOperatorProfile(profile)}
disabled={!profileAsset}
>
Apply profile
Profiel gebruiken
</button>
</div>
)
@@ -320,17 +355,16 @@ export function DetectionLab({
</div>
{selectedModelAsset ? (
<div className="model-asset-guidance">
<strong>Selected model asset status</strong>
<strong>Status gekozen model</strong>
<p>
{selectedModelAsset.display_name} is selected for this browser-run request. Runtime default activation
remains a separate guarded operator action backed by a promotion report.
{selectedModelAsset.display_name} wordt voor deze analyse gebruikt. De standaard serverconfiguratie blijft ongewijzigd.
</p>
</div>
) : null}
<label>
Local model file
Lokaal modelbestand
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Select explicit local model asset</option>
<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)' : ''}
@@ -340,8 +374,8 @@ export function DetectionLab({
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>No local model assets found.</strong>
<p>Mount model files into the backend model directory or continue with the configured YOLO_MODEL_PATH.</p>
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
</div>
) : null}
{selectedModelAsset ? (
@@ -356,13 +390,14 @@ export function DetectionLab({
<p>{selectedModelAsset.limitation_message}</p>
</div>
) : null}
</div>
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
<summary>
<span>YOLO runtime preflight</span>
<strong>{yoloPreflight?.status ?? 'not loaded'}</strong>
<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">
@@ -447,72 +482,72 @@ export function DetectionLab({
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<h3>Run detection</h3>
<h3>Nieuwe beeldanalyse</h3>
<div
className={detectionRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
aria-label="Detection run readiness"
>
<div className="ai-lab-section-header">
<div>
<h3>Run readiness</h3>
<p>Checks the selected dataset, model and tile manifest before submitting a detection job.</p>
<h3>Wat is nog nodig?</h3>
<p>De analyse start zodra een luchtbeeld en de bijbehorende beeldtegels beschikbaar zijn.</p>
</div>
<span className={detectionRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{detectionRunReady ? 'Ready to submit' : 'Blocked'}
{detectionRunReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={detectionHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Raster dataset</span>
<strong>{detectionHasDataset ? 'Selected' : 'Select a raster dataset'}</strong>
<span>Luchtbeeld</span>
<strong>{detectionHasDataset ? 'Geselecteerd' : 'Nog geen luchtbeeld'}</strong>
</div>
<div className={detectionModelReady ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Model availability</span>
<span>Analysemodel</span>
<strong>
{detectionModelReady
? `${selectedDetectionModel?.display_name ?? selectedDetectionModelId} is configured`
: selectedDetectionModel?.limitation_message ?? 'Select a configured model'}
? 'Lokaal gebouwmodel beschikbaar'
: 'Model niet beschikbaar'}
</strong>
</div>
<div className={detectionHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile manifest</span>
<span>Beeldtegels</span>
<strong>
{detectionRequiresTileManifest
? detectionHasTileManifest
? 'Provided for configured YOLO'
: 'Required for configured YOLO'
: 'Not required for this model'}
? 'Beschikbaar'
: 'Maak eerst tegels vanuit het luchtbeeld'
: 'Niet vereist'}
</strong>
</div>
<div className={detectionHasExplicitModelAsset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Local model asset</span>
<span>Lokale modelkeuze</span>
<strong>
{selectedDetectionModelId === 'yolo-configured'
? selectedModelAsset
? selectedModelAsset.display_name
? selectedOperatorProfile?.displayName ?? selectedModelAsset.display_name
: modelAssets.length > 0
? 'Select a local model asset deliberately'
: 'No local assets reported; backend configured path only'
: 'Not required for this model'}
? 'Kies een lokaal model onder beheer'
: 'Geen lokaal model gevonden'
: 'Niet vereist'}
</strong>
</div>
</div>
</div>
<div className={detectionRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
<span>Run action</span>
<strong>{detectionRunReady ? 'Ready to submit a detection job' : detectionRunBlockedReason}</strong>
<span>Analyse</span>
<strong>{detectionRunReady ? 'Klaar om gebouwen te zoeken' : detectionRunBlockedReason}</strong>
</div>
{rasterDatasets.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No raster datasets available for detection.</strong>
<p>Upload or select a raster dataset in Data before running object detection.</p>
<strong>Geen luchtbeeld beschikbaar in deze werkruimte.</strong>
<p>Voeg onder Bronnen een gegeorefereerde GeoTIFF toe. Daarna kan GeoIntel er beeldtegels en een detectierun van maken.</p>
</div>
) : null}
<div className="lab-form-grid">
<label>
Raster dataset
Luchtbeeld
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
<option value="">Kies een luchtbeeld</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -521,7 +556,7 @@ export function DetectionLab({
</select>
</label>
<label>
Model
Analysemodel
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
@@ -531,7 +566,7 @@ export function DetectionLab({
</select>
</label>
<label>
Min confidence
Minimale zekerheid
<input
type="number"
min="0"
@@ -542,14 +577,14 @@ export function DetectionLab({
/>
{selectedDetectionModelId === 'yolo-configured' ? (
<span className="field-guidance">
Use a validated operator profile, or enter a threshold manually for calibration.
Het actieve gevalideerde profiel gebruikt standaard 0,15 voor een evenwichtige gebouwcontrole.
</span>
) : null}
</label>
</div>
{selectedDetectionModelId === 'yolo-configured' ? (
<label>
Tile manifest
Beeldtegelbestand
<input
type="text"
placeholder="Raster tile manifest path"
@@ -560,13 +595,13 @@ export function DetectionLab({
) : null}
{selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? (
<div className="linked-manifest-card">
<strong>Linked tile manifest</strong>
<strong>Gekoppelde beeldtegels</strong>
<p>{detectionTileManifestPath}</p>
<span>Refresh preflight after changing model asset or manifest path.</span>
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
</div>
) : null}
<button className="primary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
Run detection
Zoek gebouwen
</button>
</div>
</div>
@@ -590,7 +625,12 @@ export function DetectionLab({
) : null}
</div>
<div className="ai-lab-run-surface guided-calibration-surface" aria-label="Guided calibration runner">
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Guided calibration runner">
<summary>
<span>Modelkalibratie voor beheerders</span>
<strong>{detectionCalibrationRows.length > 0 ? `${detectionCalibrationRows.length} drempels getest` : 'gesloten'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Guided calibration runner</h3>
@@ -705,23 +745,24 @@ export function DetectionLab({
<p>Choose a reference dataset and threshold set, then start the explicit sweep.</p>
</div>
)}
</div>
</div>
</details>
<div className="ai-lab-results-surface" aria-label="Detection results">
<div className="panel-title-row">
<div>
<h3>Detection results</h3>
<p className="muted">Load persisted detections and filter by class or confidence.</p>
<h3>Gevonden objecten</h3>
<p className="muted">Bekijk eerder bewaarde analyses en filter op type 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={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select detection run</option>
<option value="">Kies een bewaarde analyse</option>
{detectionRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'detection'} - {run.status} - {run.id}
@@ -730,16 +771,16 @@ export function DetectionLab({
</select>
</label>
<label>
Class
Type object
<input
type="text"
placeholder="Class filter"
placeholder="bijvoorbeeld building"
value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
</label>
<label>
Min confidence
Minimale zekerheid
<input
type="number"
min="0"
@@ -751,18 +792,18 @@ export function DetectionLab({
</label>
</div>
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
Load detections
Resultaten laden
</button>
{loadingDetectionResults ? (
<div className="result-state result-state-loading">
<strong>Loading detection results.</strong>
<p>Retrieving persisted detections for the selected run.</p>
<strong>Resultaten laden.</strong>
<p>De bewaarde objecten van deze analyse worden opgehaald.</p>
</div>
) : null}
<div className="ai-lab-state-stack">
<div className="result-state result-state-ready">
<strong>Detections loaded: {detectionItems.length}</strong>
<p>{selectedDetectionRunId ? 'Loaded from persisted detection records.' : 'Select a detection run before loading results.'}</p>
<strong>{detectionItems.length} objecten geladen</strong>
<p>{selectedDetectionRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
</div>
</div>
{detectionItems.length > 0 ? (
@@ -98,44 +98,45 @@ export function ExportCenter({
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length
const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length
const handoffStatus = selectedProjectId ? 'project selected' : 'select project'
const handoffStatus = selectedProjectId ? 'werkruimte gekozen' : 'kies een werkruimte'
const exportTypes = useMemo(() => Array.from(new Set(exports.map((item) => item.export_type))).sort(), [exports])
const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
const latestHandoffArtifacts = useMemo(
() => [
{
key: 'project_report_html',
label: 'Project report',
detail: 'HTML report for human review',
label: 'Leesbaar rapport',
detail: 'HTML-rapport om te bekijken en te delen',
item: getLatestExportByType(exports, 'project_report_html'),
},
{
key: 'project_metadata_json',
label: 'Project metadata',
detail: 'Machine-readable project inventory',
label: 'Werkruimtedata',
detail: 'Technische inventaris in JSON-formaat',
item: getLatestExportByType(exports, 'project_metadata_json'),
},
{
key: 'dataset_geojson',
label: 'Dataset GeoJSON',
detail: 'Selected vector/reference layer',
label: 'Kaartlaag (GeoJSON)',
detail: 'De geselecteerde vector- of referentielaag',
item: getLatestExportByType(exports, 'dataset_geojson'),
},
{
key: 'detection_geojson',
label: 'Detection GeoJSON',
detail: 'Persisted detection geometries',
label: 'Herkende gebouwen (GeoJSON)',
detail: 'Bewaarde geometrieën uit beeldanalyse',
item: getLatestExportByType(exports, 'detection_geojson'),
},
{
key: 'segmentation_geojson',
label: 'Segmentation GeoJSON',
detail: 'Persisted segmentation polygons',
label: 'Segmentaties (GeoJSON)',
detail: 'Bewaarde segmentatiepolygonen',
item: getLatestExportByType(exports, 'segmentation_geojson'),
},
],
[exports],
)
const availableLatestArtifacts = latestHandoffArtifacts.filter((artifact) => artifact.item)
const filteredExports = useMemo(
() =>
exports.filter((item) => {
@@ -154,10 +155,10 @@ export function ExportCenter({
<div className="export-center-shell">
<div className="panel-title-row">
<div>
<h2>Export Center</h2>
<p className="muted">Create handoff artifacts from persisted datasets, AI runs and project metadata.</p>
<h2>Downloads en rapporten</h2>
<p className="muted">Bewaar de geselecteerde kaartlaag, analyseresultaten of een leesbaar projectrapport.</p>
</div>
<span className="count-pill">{exports.length} exports</span>
<span className="count-pill">{exports.length} bestanden</span>
</div>
<div className="export-summary-surface" aria-label="Export summary">
@@ -167,12 +168,12 @@ export function ExportCenter({
<strong>{geojsonExports}</strong>
</div>
<div>
<span>Reports</span>
<span>Rapporten</span>
<strong>{reportExports}</strong>
</div>
<div>
<span>Selected dataset</span>
<strong>{selectedDataset?.name ?? 'none'}</strong>
<span>Geselecteerde kaartlaag</span>
<strong>{selectedDataset?.name ?? 'geen'}</strong>
</div>
</div>
</div>
@@ -188,20 +189,20 @@ export function ExportCenter({
</div>
<div className="handoff-readiness-grid">
<div>
<span>Vector dataset</span>
<strong>{canExportDataset ? selectedDataset?.name : 'none ready'}</strong>
<span>Vectorkaartlaag</span>
<strong>{canExportDataset ? selectedDataset?.name : 'geen beschikbaar'}</strong>
</div>
<div>
<span>Detection run</span>
<strong>{selectedDetectionRunId || 'none'}</strong>
<span>Gebouwanalyse</span>
<strong>{selectedDetectionRunId || 'geen'}</strong>
</div>
<div>
<span>Segmentation run</span>
<strong>{selectedSegmentationRunId || 'none'}</strong>
<span>Segmentatieanalyse</span>
<strong>{selectedSegmentationRunId || 'geen'}</strong>
</div>
<div>
<span>Latest handoff artifact</span>
<strong>{latestExport ? formatExportType(latestExport.export_type) : 'none'}</strong>
<span>Laatste bestand</span>
<strong>{latestExport ? formatExportType(latestExport.export_type) : 'geen'}</strong>
</div>
</div>
</div>
@@ -209,16 +210,16 @@ export function ExportCenter({
<div className="latest-artifact-surface" aria-label="Latest handoff artifacts">
<div>
<h3>Latest handoff artifacts</h3>
<p className="muted">Open the newest artifact of each handoff type without searching the full export history.</p>
<h3>Laatste downloads</h3>
<p className="muted">Open de recentste bestanden zonder in de volledige geschiedenis te zoeken.</p>
</div>
<div className="latest-artifact-grid">
{latestHandoffArtifacts.map((artifact) => {
{availableLatestArtifacts.map((artifact) => {
const item = artifact.item
return (
<div className={item ? 'latest-artifact-card' : 'latest-artifact-card latest-artifact-card-empty'} key={artifact.key}>
<span>{artifact.label}</span>
<strong>{item ? item.status : 'No artifact yet'}</strong>
<strong>{item ? (item.status === 'ready' ? 'Beschikbaar' : item.status) : 'Nog niet gemaakt'}</strong>
<p>{item ? compactPath(item.storage_path) : artifact.detail}</p>
{item ? (
<>
@@ -226,13 +227,13 @@ export function ExportCenter({
<div className="latest-artifact-actions">
{canPreviewJson(item) ? (
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
Preview
Bekijken
</button>
) : (
<span className="status-badge">download-only</span>
<span className="status-badge">alleen downloaden</span>
)}
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
Download
Downloaden
</button>
</div>
</>
@@ -240,19 +241,25 @@ export function ExportCenter({
</div>
)
})}
{availableLatestArtifacts.length === 0 ? (
<div className="result-state result-state-empty latest-download-empty">
<strong>Nog geen downloads gemaakt</strong>
<p>Kies hieronder een rapport of de actieve kaartlaag.</p>
</div>
) : null}
</div>
</div>
<div className="export-actions-surface" aria-label="Export artifact actions">
<div className="panel-title-row">
<div>
<h3>Artifact actions</h3>
<p className="muted">Create review artifacts from the current project, dataset or selected AI analysis run.</p>
<h3>Wat wil je bewaren?</h3>
<p className="muted">Maak een leesbaar rapport of download gegevens voor verder gebruik.</p>
</div>
</div>
<div className="export-action-grid">
<div className="handoff-action-grid">
<div className="handoff-action-card">
<div className="handoff-action-card handoff-action-card-technical">
<span>Refresh</span>
<strong>Export registry</strong>
<p>Reload the persisted artifact list for the selected project.</p>
@@ -267,17 +274,17 @@ export function ExportCenter({
</button>
</div>
<div className="handoff-action-card handoff-action-card-primary">
<span>Project report</span>
<strong>HTML summary</strong>
<p>Generate the human-readable V1 handoff report from persisted project state.</p>
<span>Rapport</span>
<strong>Leesbaar projectoverzicht</strong>
<p>Maak een HTML-rapport met de beschikbare bronnen, controles en resultaten.</p>
<button type="button" className="primary-action" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
Export project report HTML
Projectrapport maken
</button>
</div>
<div className="handoff-action-card">
<span>Metadata</span>
<strong>Project JSON</strong>
<p>Export project, dataset, QA and artifact metadata for machine-readable review.</p>
<span>Technische gegevens</span>
<strong>Projectgegevens (JSON)</strong>
<p>Download de volledige projectinventaris voor geautomatiseerde verwerking.</p>
<button
type="button"
className="secondary-action"
@@ -285,33 +292,33 @@ export function ExportCenter({
disabled={!selectedProjectId || exporting}
data-testid="export-project-metadata"
>
Export project metadata JSON
Projectgegevens downloaden
</button>
</div>
<div className="handoff-action-card">
<span>Vector dataset</span>
<strong>Selected GeoJSON</strong>
<p>Export the active vector/reference dataset when it is selected and ready.</p>
<span>Kaartlaag</span>
<strong>Geselecteerde bron (GeoJSON)</strong>
<p>Download de actieve vector- of referentielaag.</p>
<button type="button" className="secondary-action" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
Export selected vector GeoJSON
Kaartlaag downloaden
</button>
</div>
<div className="handoff-action-card">
{selectedDetectionRunId ? <div className="handoff-action-card">
<span>Detection run</span>
<strong>Detections GeoJSON</strong>
<p>Export persisted detection geometries for the selected analysis run.</p>
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
Export selected detection run GeoJSON
</button>
</div>
<div className="handoff-action-card">
</div> : null}
{selectedSegmentationRunId ? <div className="handoff-action-card">
<span>Segmentation run</span>
<strong>Segmentations GeoJSON</strong>
<p>Export persisted segmentation polygons for the selected analysis run.</p>
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
Export selected segmentation run GeoJSON
</button>
</div>
</div> : null}
</div>
</div>
</div>
@@ -344,7 +351,12 @@ export function ExportCenter({
) : null}
</div>
<div className="export-history-surface" aria-label="Export history">
<details className="export-history-surface export-history-disclosure" aria-label="Export history">
<summary>
<span>Downloadgeschiedenis</span>
<strong>{exports.length} bestanden</strong>
</summary>
<div className="export-history-body">
<div>
<h3>Export history</h3>
<p className="muted">Review persisted artifacts and open JSON previews where supported.</p>
@@ -444,7 +456,8 @@ export function ExportCenter({
</li>
))}
</ul>
</div>
</div>
</details>
</div>
</section>
)
+10 -27
View File
@@ -4,6 +4,7 @@ import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, Qa
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -443,7 +444,6 @@ interface MapWorkspaceProps {
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onSelectProject: (projectId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onSetAreaLayerVisible: (visible: boolean) => void
onSetAreaLayerOpacity: (opacity: number) => void
@@ -512,7 +512,6 @@ export function MapWorkspace({
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
onSelectProject,
onOpenDatasetInMap,
onSetAreaLayerVisible,
onSetAreaLayerOpacity,
@@ -590,16 +589,9 @@ export function MapWorkspace({
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const operationalScopeProjects = useMemo(() => {
const scoped = projects.filter((project) => project.name === MOL_PROJECT_NAME || project.name === KEMPEN_PROJECT_NAME)
const selected = projects.find((project) => project.id === selectedProjectId)
if (selected && !scoped.some((project) => project.id === selected.id)) {
return [selected, ...scoped]
}
return scoped
}, [projects, selectedProjectId])
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
const activeTemporalSeriesGroups = useMemo(
() => listThemeTemporalSeries(availableMapDatasets, activeTheme),
[activeTheme, availableMapDatasets],
@@ -964,19 +956,11 @@ export function MapWorkspace({
<p>Dit zijn databronnen, geen AI-modellen.</p>
</div>
</div>
<label className="geo-project-scope-select">
Regio
<select
aria-label="Regio"
value={selectedProjectId ?? ''}
onChange={(event) => onSelectProject(event.target.value)}
disabled={operationalScopeProjects.length < 2}
>
{operationalScopeProjects.map((project) => (
<option key={project.id} value={project.id}>{operationalScopeProjectLabel(project)}</option>
))}
</select>
</label>
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
<span>Ingeladen bereik</span>
<strong>{activeScopeLabel}</strong>
<small>{municipalityAreaCount > 0 ? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar` : 'Regionale gegevens worden geladen'}</small>
</div>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
@@ -1006,7 +990,7 @@ export function MapWorkspace({
<strong>
{analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'Geen tijdreeks beschikbaar'
: activeThemeDataset?.name ?? 'Geen databron beschikbaar'}
: activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'}
</strong>
<small>
{analysisMode === 'evolution'
@@ -1065,9 +1049,8 @@ export function MapWorkspace({
) : null}
<label className="geo-scope-select">
Werkgebied
Gemeente of volledige regio
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
<option value="">Geen werkgebied</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
))}
@@ -1321,7 +1304,7 @@ export function MapWorkspace({
{analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks'
: activeThemeDataset
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.name}`
? getDatasetDisplayName(activeThemeDataset)
: 'niet beschikbaar'}
</span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
+19 -19
View File
@@ -36,28 +36,28 @@ export function AreaPanel({
<section data-testid="area-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Spatial scope</p>
<h2>AOI manager</h2>
<p className="eyebrow">Geografisch bereik</p>
<h2>Gebieden</h2>
</div>
<span className="count-pill">{areas.length}</span>
</div>
<p className="muted">{selectedProject ? `Project: ${selectedProject.name}` : 'Select a project first'}</p>
<p className="muted">{selectedProject ? selectedProject.name : 'Geen werkruimte actief'}</p>
<div className="data-selection-summary data-selection-summary-area">
<span>Shown on map</span>
<strong>{selectedArea?.name ?? 'No AOI selected'}</strong>
<small>{selectedArea?.area_m2 ? `${selectedArea.area_m2.toFixed(2)} m2` : 'Select an AOI with geometry for spatial review.'}</small>
<span>Getoond op de kaart</span>
<strong>{selectedArea?.name ?? 'Geen gebied geselecteerd'}</strong>
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente of de volledige regio op de kaart.'}</small>
</div>
<details className="data-panel-form-block">
<summary>Create spatial scope</summary>
<summary>Eigen gebied toevoegen (GeoJSON)</summary>
<form className="compact-form" onSubmit={onCreateArea}>
<label>
AOI name
Naam
<input
value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="AOI name"
placeholder="Naam van het gebied"
/>
</label>
<label>
@@ -69,7 +69,7 @@ export function AreaPanel({
/>
</label>
<label>
GeoJSON geometry
GeoJSON-geometrie
<textarea
value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
@@ -77,22 +77,22 @@ export function AreaPanel({
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Create area
Gebied toevoegen
</button>
</form>
</details>
{loadingAreas ? <p>Loading areas...</p> : null}
{areas.length === 0 ? <p>No areas yet</p> : null}
<div className="data-panel-list-block">
<p className="data-section-label">Saved AOIs</p>
{loadingAreas ? <p>Gebieden laden...</p> : null}
{areas.length === 0 ? <p>Nog geen gebieden beschikbaar.</p> : null}
<details className="data-panel-list-block area-catalog-disclosure">
<summary>{areas.length} beschikbare gemeenten en regiogrenzen</summary>
<ul className="entity-list">
{areas.map((area) => (
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
<strong>{area.name}</strong>
<div className="entity-meta">
<span>{area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}</span>
<span>{area.geometry?.type ?? area.geometry_type ?? 'n/a'}</span>
<span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span>
<span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span>
</div>
<button
className="secondary-action"
@@ -101,12 +101,12 @@ export function AreaPanel({
disabled={!area.geometry}
data-testid={`area-show-${area.id}`}
>
{selectedMapAreaId === area.id ? 'Shown on map' : 'Show on map'}
{selectedMapAreaId === area.id ? 'Op kaart' : 'Toon op kaart'}
</button>
</li>
))}
</ul>
</div>
</details>
</section>
)
}
@@ -1,6 +1,12 @@
import type { FormEvent } from 'react'
import type { ProjectCreate, ProjectRead } from '../../types'
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
function isTechnicalProject(project: ProjectRead): boolean {
return TECHNICAL_PROJECT_PATTERN.test(project.name)
}
interface ProjectPanelProps {
projects: ProjectRead[]
selectedProjectId: string | null
@@ -27,68 +33,33 @@ export function ProjectPanel({
onLoadDemoWorkflow,
}: ProjectPanelProps): JSX.Element {
const selectedProject = projects.find((project) => project.id === selectedProjectId)
const regularProjects = projects.filter((project) => !isTechnicalProject(project))
const technicalProjects = projects.filter(isTechnicalProject)
const visibleProjects = selectedProject && isTechnicalProject(selectedProject)
? [selectedProject, ...regularProjects]
: regularProjects
return (
<section className="workspace-panel" data-testid="project-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Context</p>
<h2>Projects</h2>
<p className="eyebrow">Gegevenscontext</p>
<h2>Werkruimtes</h2>
</div>
<span className="count-pill">{projects.length}</span>
<span className="count-pill">{regularProjects.length} beschikbaar</span>
</div>
{loadingProjects ? <p>Loading projects...</p> : null}
{loadingProjects ? <p>Werkruimtes laden...</p> : null}
<div className="data-selection-summary data-selection-summary-project">
<span>Selected project</span>
<strong>{selectedProject?.name ?? 'No active project'}</strong>
<small>{selectedProject?.region ?? 'Create or load a project to start the workbench.'}</small>
</div>
<details className="data-panel-form-block">
<summary>Create project</summary>
<form className="compact-form" onSubmit={onCreateProject}>
<label>
Name
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Project name"
/>
</label>
<label>
Description
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Description"
/>
</label>
<label>
Region
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Region"
/>
</label>
<button className="primary-action" type="submit">
Create project
</button>
</form>
</details>
<div className="demo-actions">
<button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
{loadingDemoWorkflow ? 'Loading demo...' : 'Load demo workflow'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
<span>Actieve werkruimte</span>
<strong>{selectedProject?.name ?? 'Geen werkruimte actief'}</strong>
<small>{selectedProject?.region ?? 'De regionale werkruimte wordt automatisch gekozen.'}</small>
</div>
<div className="data-panel-list-block">
<p className="data-section-label">Saved projects</p>
<p className="data-section-label">Beschikbare werkruimtes</p>
<ul className="entity-list">
{projects.map((project) => (
{visibleProjects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
@@ -98,18 +69,78 @@ export function ProjectPanel({
data-testid={`project-select-${project.id}`}
>
<strong>{project.name}</strong>
<span>{project.region || 'No region'}</span>
<span>{project.region || 'Regio niet opgegeven'}</span>
</button>
</li>
))}
{projects.length === 0 ? (
{regularProjects.length === 0 ? (
<li className="empty-state">
<strong>No projects yet</strong>
<p>Create a project or load the offline demo workflow to populate the workbench.</p>
<strong>Nog geen werkruimte beschikbaar</strong>
<p>Maak een werkruimte aan of laad de demonstratiegegevens.</p>
</li>
) : null}
</ul>
</div>
<details className="data-panel-form-block technical-management-block">
<summary>Geavanceerd projectbeheer</summary>
<div className="technical-management-body">
<form className="compact-form" onSubmit={onCreateProject}>
<label>
Naam
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Naam van de werkruimte"
/>
</label>
<label>
Beschrijving
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Korte beschrijving"
/>
</label>
<label>
Regio
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Regio"
/>
</label>
<button className="primary-action" type="submit">Nieuwe werkruimte</button>
</form>
<div className="demo-actions">
<button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
{loadingDemoWorkflow ? 'Demo laden...' : 'Demonstratiegegevens laden'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div>
{technicalProjects.length > 0 ? (
<details className="technical-run-list">
<summary>{technicalProjects.length} technische test- en benchmarkruns</summary>
<ul className="entity-list">
{technicalProjects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
type="button"
onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId}
data-testid={`project-select-${project.id}`}
>
<strong>{project.name}</strong>
<span>{project.region || 'Technische run'}</span>
</button>
</li>
))}
</ul>
</details>
) : null}
</div>
</details>
</section>
)
}
@@ -7,6 +7,23 @@ interface ProviderPanelProps {
onRefresh: () => void
}
function providerStatusLabel(provider: ProviderCapability): string {
if (provider.configured) {
return 'Beschikbaar'
}
return 'Live koppeling uit'
}
function authorityLabel(value: string): string {
const labels: Record<string, string> = {
authoritative: 'Officieel',
contextual: 'Aanvullend',
manual: 'Eigen upload',
fixture: 'Testgegevens',
}
return labels[value] ?? value
}
export function ProviderPanel({
providers,
loadingCapabilities,
@@ -18,40 +35,40 @@ export function ProviderPanel({
<div className="system-provider-shell">
<div className="panel-title-row">
<div>
<h2>Provider Capabilities</h2>
<p className="muted">Review configured local providers and honest not-configured external sources.</p>
<h2>Databronnen</h2>
<p className="muted">Beschikbaarheid en herkomst van officiële, aanvullende en eigen bronnen.</p>
</div>
<button type="button" className="secondary-action" onClick={onRefresh} disabled={loadingCapabilities}>
Refresh providers
Status vernieuwen
</button>
</div>
<div className="system-provider-state-stack">
{loadingCapabilities ? (
<div className="result-state result-state-loading">
<strong>Loading provider capabilities.</strong>
<p>Checking local, fixture and external provider registry states.</p>
<strong>Databronnen laden.</strong>
<p>De lokale en externe bronstatus wordt gecontroleerd.</p>
</div>
) : null}
{capabilitiesError ? (
<div className="result-state result-state-error">
<strong>Provider registry unavailable.</strong>
<strong>Bronregister niet bereikbaar.</strong>
<p>{capabilitiesError}</p>
</div>
) : null}
{providers.length === 0 && !loadingCapabilities ? (
<div className="result-state result-state-empty">
<strong>No providers reported by backend.</strong>
<p>Refresh provider capabilities after the backend is reachable.</p>
<strong>Geen databronnen gemeld.</strong>
<p>Vernieuw de status zodra de backend bereikbaar is.</p>
</div>
) : null}
</div>
<div className="system-provider-capability-surface" aria-label="Provider capability registry">
<div className="provider-detail-stack">
<strong>Official reference sources</strong>
<strong>Officiële referentiebronnen</strong>
<div>
GRB and OSM are reference-data provider capabilities, not AI model choices. Manual upload is the configured path for real reference datasets today; fixtures remain test/demo only.
Reeds ingeladen GRB-lagen blijven lokaal beschikbaar. De live GRB- en OSM-koppelingen staan uit en halen dus niet ongemerkt externe gegevens op.
</div>
</div>
<ul className="system-provider-list">
@@ -62,24 +79,16 @@ export function ProviderPanel({
<strong>{provider.display_name}</strong>
<span>{provider.provider_name}</span>
</div>
<span className={provider.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{provider.status}</span>
<span className={provider.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{providerStatusLabel(provider)}</span>
</div>
<div className="system-provider-grid">
<div>
<span>Authority</span>
<strong>{provider.authority_level}</strong>
<span>Herkomst</span>
<strong>{authorityLabel(provider.authority_level)}</strong>
</div>
<div>
<span>Configured</span>
<strong>{provider.configured ? 'yes' : 'no'}</strong>
</div>
<div>
<span>Geometry</span>
<strong>{provider.supported_geometry_types.join(', ')}</strong>
</div>
<div>
<span>Query modes</span>
<strong>{provider.supported_query_modes.join(', ')}</strong>
<span>Status</span>
<strong>{providerStatusLabel(provider)}</strong>
</div>
</div>
<div className="provider-layer-list" aria-label={`${provider.provider_name} supported layers`}>
@@ -89,22 +98,29 @@ export function ProviderPanel({
</span>
))}
</div>
<div className="provider-detail-stack">
<div>limitation: {provider.limitation_message}</div>
{!provider.configured && provider.not_configured_reason ? (
<div>reason: {provider.not_configured_reason}</div>
) : null}
</div>
<div className="provider-provenance-grid">
<div className="provider-provenance-card">
<span>Attribution</span>
<strong>{provider.attribution}</strong>
<details className="provider-technical-details">
<summary>Herkomst en technische mogelijkheden</summary>
<div className="provider-detail-stack">
<div>{provider.limitation_message}</div>
{!provider.configured && provider.not_configured_reason ? (
<div>{provider.not_configured_reason}</div>
) : null}
</div>
<div className="provider-provenance-card">
<span>License</span>
<strong>{provider.license_note}</strong>
<div className="provider-provenance-grid">
<div className="provider-provenance-card">
<span>Naamsvermelding</span>
<strong>{provider.attribution}</strong>
</div>
<div className="provider-provenance-card">
<span>Licentie</span>
<strong>{provider.license_note}</strong>
</div>
</div>
</div>
<div className="entity-meta">
<span>Geometrie: {provider.supported_geometry_types.join(', ')}</span>
<span>Zoekwijzen: {provider.supported_query_modes.join(', ')}</span>
</div>
</details>
</li>
))}
</ul>
@@ -147,50 +147,63 @@ export function QualityResultsPanel({
const hasActiveQualityFilters = qualityStatusFilter !== 'all' || qualityTypeFilter !== 'all' || qualitySearchQuery.trim().length > 0
return (
<section className="quality-results-panel" data-testid="quality-results-panel">
<section className={qualityChecks.length === 0 ? 'quality-results-panel quality-results-panel-empty' : 'quality-results-panel'} data-testid="quality-results-panel">
<div className="quality-results-shell">
<div className="panel-title-row">
<div>
<h2>QA/QC Results</h2>
<p className="muted">Persisted quality checks from dataset, detection and segmentation comparisons.</p>
<h2>Kwaliteitsresultaten</h2>
<p className="muted">Bewaarde controles van kaartlagen en beeldanalyses.</p>
</div>
<span className="count-pill">{qualityChecks.length} checks</span>
<span className="count-pill">{qualityChecks.length} controles</span>
</div>
<div className="quality-summary-surface" aria-label="QA/QC result summary">
<div className="quality-summary-grid">
<div>
<span>Completed</span>
<span>Afgerond</span>
<strong>{completedChecks}</strong>
</div>
<div>
<span>Latest score</span>
<span>Laatste score</span>
<strong>{latestCheck?.score ?? 'n/a'}</strong>
</div>
<div>
<span>Latest status</span>
<span>Laatste status</span>
<strong>{latestCheck?.status ?? 'waiting'}</strong>
</div>
</div>
</div>
{qualityChecks.length === 0 ? (
<div className="result-state result-state-empty quality-user-empty-state">
<strong>Nog geen kwaliteitscontrole uitgevoerd</strong>
<p>Selecteer een gebied op de kaart en vergelijk een bewaard resultaat met een officiële referentielaag.</p>
</div>
) : null}
{qualityChecks.length > 0 ? (
<details className="quality-advanced-disclosure">
<summary>
<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>
<span>Candidate layers</span>
<span>Te controleren lagen</span>
<strong>{candidateDatasets.length}</strong>
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'No candidate layer selected yet'}</p>
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'Nog geen te controleren laag gekozen'}</p>
</div>
<div>
<span>Reference layers</span>
<span>Referentielagen</span>
<strong>{referenceDatasets.length}</strong>
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'No reference layer available yet'}</p>
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'Nog geen referentielaag beschikbaar'}</p>
</div>
<div>
<span>Latest comparison</span>
<span>Laatste vergelijking</span>
<strong>{latestCheck?.status ?? 'waiting'}</strong>
<p className="quality-dataset-name">
Candidate {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n/a'} / Reference {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n/a'}
Te controleren: {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n.v.t.'} / referentie: {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n.v.t.'}
</p>
</div>
</div>
@@ -199,8 +212,8 @@ export function QualityResultsPanel({
<div className="quality-drilldown-surface" aria-label="QA/QC evidence drilldown">
<div className="panel-title-row">
<div>
<h3>QA/QC evidence drilldown</h3>
<p className="muted">Inspect the persisted check, metric evidence and dataset handoff context before exporting or sharing results.</p>
<h3>Bewijs van de kwaliteitscontrole</h3>
<p className="muted">Bekijk de bewaarde vergelijking, scores en kaartlagen voordat je resultaten deelt.</p>
</div>
<button
type="button"
@@ -208,7 +221,7 @@ export function QualityResultsPanel({
onClick={() => setSelectedQualityCheckId(latestCheck?.id ?? null)}
disabled={!latestCheck}
>
Inspect latest check
Laatste controle bekijken
</button>
<button
type="button"
@@ -216,12 +229,12 @@ export function QualityResultsPanel({
onClick={() => latestCheck?.id && onOpenEvidenceMap?.(latestCheck.id)}
disabled={!latestCheck || evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Loading map evidence...' : 'Show latest on map'}
{evidenceLoading ? 'Kaartbewijs laden...' : 'Laatste op kaart tonen'}
</button>
</div>
{evidenceError ? (
<div className="result-state result-state-error">
<strong>QA/QC evidence overlay could not be loaded.</strong>
<strong>Het bewijs kon niet op de kaart worden geladen.</strong>
<p>{evidenceError}</p>
</div>
) : null}
@@ -229,22 +242,22 @@ export function QualityResultsPanel({
<>
<div className="quality-drilldown-grid">
<div>
<span>Selected check</span>
<span>Geselecteerde controle</span>
<strong>{selectedQualityCheck.check_type}</strong>
<p>{selectedQualityCheck.id}</p>
</div>
<div>
<span>Candidate layer</span>
<span>Te controleren laag</span>
<strong>{selectedCandidateName}</strong>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'candidate not stored'}</p>
</div>
<div>
<span>Reference layer</span>
<span>Referentielaag</span>
<strong>{selectedReferenceName}</strong>
<p>{selectedQualityCheck.reference_dataset_id}</p>
</div>
<div>
<span>Analysis run</span>
<span>Analyserun</span>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n/a'}</strong>
<p>Job: {selectedQualityCheck.job_id ?? 'n/a'}</p>
</div>
@@ -254,39 +267,39 @@ export function QualityResultsPanel({
<p>Score: {selectedQualityCheck.score ?? 'n/a'}</p>
</div>
<div>
<span>Completed</span>
<span>Afgerond</span>
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
<p>Created: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<div className="quality-evidence-token-grid">
<div>
<span>False positive evidence</span>
<span>Onterecht gevonden</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_positive_count'))}</strong>
<p>Candidate geometries not matched to the reference layer.</p>
<p>Gevonden vormen zonder overeenkomst in de referentielaag.</p>
</div>
<div>
<span>False negative evidence</span>
<span>Gemiste objecten</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_negative_count'))}</strong>
<p>Reference geometries not matched by the candidate layer.</p>
<p>Objecten uit de referentielaag die niet werden gevonden.</p>
</div>
<div className="quality-evidence-map-handoff">
<span>Map evidence handoff</span>
<span>Controle op de kaart</span>
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
<p>Use the candidate and reference datasets as map layers for spatial review.</p>
<p>Toon beide lagen samen op de kaart voor een ruimtelijke controle.</p>
<button
type="button"
className="secondary-action"
onClick={() => onOpenEvidenceMap?.(selectedQualityCheck.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Loading overlay...' : 'Show evidence overlay'}
{evidenceLoading ? 'Lagen laden...' : 'Bewijs op kaart tonen'}
</button>
</div>
</div>
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
<div>
<span>Matched feature ids</span>
<span>Overeenkomende object-ID's</span>
{selectedMatchEvidence.length > 0 ? (
<ul>
{selectedMatchEvidence.slice(0, 6).map((item, index) => (
@@ -294,11 +307,11 @@ export function QualityResultsPanel({
))}
</ul>
) : (
<p>No matched feature ids persisted for this check.</p>
<p>Voor deze controle zijn geen overeenkomende object-ID's bewaard.</p>
)}
</div>
<div>
<span>False positive feature ids</span>
<span>ID's van onterecht gevonden objecten</span>
{selectedFalsePositiveEvidence.length > 0 ? (
<ul>
{selectedFalsePositiveEvidence.slice(0, 6).map((item, index) => (
@@ -306,11 +319,11 @@ export function QualityResultsPanel({
))}
</ul>
) : (
<p>No false-positive feature ids persisted for this check.</p>
<p>Er zijn geen ID's van onterecht gevonden objecten bewaard.</p>
)}
</div>
<div>
<span>False negative feature ids</span>
<span>ID's van gemiste objecten</span>
{selectedFalseNegativeEvidence.length > 0 ? (
<ul>
{selectedFalseNegativeEvidence.slice(0, 6).map((item, index) => (
@@ -318,19 +331,19 @@ export function QualityResultsPanel({
))}
</ul>
) : (
<p>No false-negative feature ids persisted for this check.</p>
<p>Er zijn geen ID's van gemiste objecten bewaard.</p>
)}
</div>
</div>
<div className="quality-provenance-grid">
<div>
<span>Parameters</span>
<span>Instellingen</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.parameters_json ?? {}, null, 2)}
</pre>
</div>
<div>
<span>Findings</span>
<span>Bevindingen</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.findings_json ?? {}, null, 2)}
</pre>
@@ -339,8 +352,8 @@ export function QualityResultsPanel({
</>
) : (
<div className="result-state result-state-empty">
<strong>No QA/QC check selected.</strong>
<p>Run or load a persisted QA/QC check to inspect metric and map evidence.</p>
<strong>Geen kwaliteitscontrole geselecteerd.</strong>
<p>Kies een bewaarde controle om scores en kaartbewijs te bekijken.</p>
</div>
)}
</div>
@@ -354,21 +367,21 @@ export function QualityResultsPanel({
disabled={!selectedProjectId}
data-testid="refresh-quality-results"
>
Refresh QA/QC results
Kwaliteitsresultaten vernieuwen
</button>
</div>
<div className="quality-result-state-stack">
{qualityChecksError ? (
<div className="result-state result-state-error">
<strong>QA/QC results could not be loaded.</strong>
<strong>De kwaliteitsresultaten konden niet worden geladen.</strong>
<p>{qualityChecksError}</p>
</div>
) : null}
{qualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No persisted QA/QC results yet</strong>
<p>Run dataset, detection or segmentation QA against a reference layer to populate this workspace.</p>
<strong>Nog geen bewaarde kwaliteitsresultaten</strong>
<p>Vergelijk een kaartlaag of beeldanalyse met een referentielaag om deze werkruimte te vullen.</p>
</div>
) : null}
</div>
@@ -376,7 +389,7 @@ export function QualityResultsPanel({
{qualityChecks.length > 0 ? (
<div className="quality-history-controls" aria-label="QA/QC result filters">
<label>
Search QA/QC results
Kwaliteitsresultaten zoeken
<input
type="search"
placeholder="Type, id or dataset"
@@ -387,7 +400,7 @@ export function QualityResultsPanel({
<label>
Status
<select value={qualityStatusFilter} onChange={(event) => setQualityStatusFilter(event.target.value)}>
<option value="all">All statuses</option>
<option value="all">Alle statussen</option>
{qualityStatuses.map((status) => (
<option key={status} value={status}>
{status}
@@ -396,9 +409,9 @@ export function QualityResultsPanel({
</select>
</label>
<label>
Check type
Type controle
<select value={qualityTypeFilter} onChange={(event) => setQualityTypeFilter(event.target.value)}>
<option value="all">All check types</option>
<option value="all">Alle types</option>
{qualityTypes.map((checkType) => (
<option key={checkType} value={checkType}>
{checkType}
@@ -417,33 +430,33 @@ export function QualityResultsPanel({
}}
disabled={!hasActiveQualityFilters && !showAllQualityChecks}
>
Reset view
Filters wissen
</button>
</div>
) : null}
{filteredQualityChecks.length > 8 ? (
<div className="list-limit-banner">
<span className="quality-list-count">
Showing {visibleQualityChecks.length} of {filteredQualityChecks.length} matching QA/QC results.
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} older checks hidden.` : ' All matching checks shown.'}
{visibleQualityChecks.length} van {filteredQualityChecks.length} kwaliteitsresultaten getoond.
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} oudere controles verborgen.` : ' Alle resultaten zijn zichtbaar.'}
</span>
<button type="button" className="secondary-action" onClick={() => setShowAllQualityChecks((value) => !value)}>
{showAllQualityChecks ? 'Show latest 8' : 'Show all QA/QC results'}
{showAllQualityChecks ? 'Alleen laatste 8 tonen' : 'Alle kwaliteitsresultaten tonen'}
</button>
</div>
) : null}
{qualityChecks.length > 0 && filteredQualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No QA/QC results match the current filters.</strong>
<p>Clear the search, status or check type filter to return to the latest quality results.</p>
<strong>Geen kwaliteitsresultaten voldoen aan de filters.</strong>
<p>Wis de zoek-, status- of typefilter om de laatste resultaten opnieuw te tonen.</p>
</div>
) : null}
</div>
<div className="quality-history-surface" aria-label="QA/QC result history">
<div className="panel-title-row">
<h3>Result history</h3>
<span className="count-pill">{visibleQualityChecks.length} shown</span>
<h3>Historiek</h3>
<span className="count-pill">{visibleQualityChecks.length} getoond</span>
</div>
<ul className="quality-check-list">
{visibleQualityChecks.map((check) => (
@@ -466,7 +479,7 @@ export function QualityResultsPanel({
</div>
<div className="quality-check-actions">
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
Inspect check
Controle bekijken
</button>
<button
type="button"
@@ -474,7 +487,7 @@ export function QualityResultsPanel({
onClick={() => onOpenEvidenceMap?.(check.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
Show evidence on map
Bewijs op kaart tonen
</button>
</div>
<div className="quality-score-row">
@@ -483,16 +496,16 @@ export function QualityResultsPanel({
<strong>{check.score ?? 'n/a'}</strong>
</div>
<div>
<span>Metrics</span>
<span>Meetwaarden</span>
<strong>{check.metrics.length}</strong>
</div>
<div>
<span>Quality check</span>
<span>Controle-ID</span>
<strong>{check.id}</strong>
</div>
</div>
<div className="quality-metric-section">
<span>Metric evidence</span>
<span>Gemeten kwaliteit</span>
<div className="quality-metric-grid">
{CORE_METRIC_ORDER.map((metricKey) => {
const metric = check.metrics.find((item) => item.metric_key === metricKey)
@@ -506,7 +519,7 @@ export function QualityResultsPanel({
})}
</div>
</div>
<span className="metric-list-label">Raw metrics</span>
<span className="metric-list-label">Alle meetwaarden</span>
<ul className="metric-list">
{check.metrics.map((metric) => (
<li key={metric.id}>
@@ -519,6 +532,8 @@ export function QualityResultsPanel({
))}
</ul>
</div>
</details>
) : null}
</div>
</section>
)
+2
View File
@@ -2,6 +2,8 @@ import type { DatasetCreateResponse, ProjectRead } from '../types'
export const PRIMARY_FOCUS_LABEL = 'Mol'
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
export const PRIMARY_FOCUS_CENTER: [number, number] = [5.1167, 51.1919]
export const PRIMARY_FOCUS_AREA_NAME = 'Mol primary AOI'
export const PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'
+25 -7
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
@@ -72,10 +72,10 @@ export function useDetectionWorkflow({
const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
const [modelAssetError, setModelAssetError] = useState<string | null>(null)
const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-configured')
const [selectedModelAssetId, setSelectedModelAssetId] = useState('')
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5)
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
@@ -98,6 +98,12 @@ export function useDetectionWorkflow({
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
useEffect(() => {
if (!selectedDetectionDatasetId && rasterDatasets.length > 0) {
setSelectedDetectionDatasetId(rasterDatasets[0].id)
}
}, [rasterDatasets, selectedDetectionDatasetId])
const loadDetectionModels = async () => {
setLoadingDetectionModels(true)
setDetectionModelError(null)
@@ -105,8 +111,12 @@ export function useDetectionWorkflow({
try {
const response = await detectionApi.listModels()
setDetectionModels(response.models)
if (!response.models.some((model) => model.model_id === selectedDetectionModelId) && response.models.length > 0) {
setSelectedDetectionModelId(response.models[0].model_id)
const selectedModelStillAvailable = response.models.some((model) => model.model_id === selectedDetectionModelId)
if (!selectedModelStillAvailable && response.models.length > 0) {
const configuredModel = response.models.find(
(model) => model.configured && model.model_id !== 'manual-fixture-detector',
)
setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id)
}
} catch (error) {
setDetectionModelError(formatError(error, 'Failed to load detection models'))
@@ -114,8 +124,16 @@ export function useDetectionWorkflow({
try {
const assetResponse = await detectionApi.listModelAssets()
setModelAssets(assetResponse.items)
if (!assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)) {
setSelectedModelAssetId('')
const activeAsset = assetResponse.items.find((asset) => asset.active) ?? null
const selectedAssetStillAvailable = assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)
const nextAssetId = selectedAssetStillAvailable ? selectedModelAssetId : activeAsset?.model_asset_id ?? ''
setSelectedModelAssetId(nextAssetId)
try {
const preflight = await detectionApi.getYoloPreflight({ model_asset_id: nextAssetId || null })
setYoloPreflight(preflight)
setYoloPreflightError(null)
} catch (error) {
setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status'))
}
} catch (error) {
setModelAssets([])
+12
View File
@@ -3,6 +3,7 @@ import {
PRIMARY_FOCUS_AREA_GEOJSON,
PRIMARY_FOCUS_AREA_NAME,
PRIMARY_FOCUS_REGION,
REGIONAL_WORKSPACE_PROJECT_NAME,
isPrimaryFocusMunicipalityBoundaryDataset,
isPrimaryFocusMunicipalityProject,
isPrimaryFocusProject,
@@ -79,6 +80,17 @@ export function useProjectWorkspace() {
if (selectedProjectId && items.some((project) => project.id === selectedProjectId)) {
return selectedProjectId
}
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
if (regionalProject) {
try {
const data = await fetchProjectData(regionalProject.id)
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
return regionalProject.id
}
} catch {
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
}
}
const municipalityProject = items.find(isPrimaryFocusMunicipalityProject)
if (municipalityProject) {
try {
+24
View File
@@ -0,0 +1,24 @@
import type { DatasetCreateResponse } from '../types'
const DATASET_LABEL_BY_LAYER: Record<string, string> = {
buildings: 'Bebouwing',
roads: 'Wegen',
water: 'Water',
parcels: 'Percelen',
population: 'Bevolking',
forest: 'Bos en groen',
regional_boundary: 'Grens vervoerregio Kempen',
municipality_boundaries: 'Gemeentegrenzen Kempen',
}
export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? '')
.toString()
.toLowerCase()
const label = DATASET_LABEL_BY_LAYER[layer]
if (!label) {
return dataset.name
}
const source = (dataset.source_name ?? dataset.source).toUpperCase()
return `${label} · ${source}`
}
+279
View File
@@ -1493,6 +1493,285 @@ details.ai-lab-model-surface > summary strong {
}
}
/* End-user workbench simplification */
.geo-loaded-scope {
display: grid;
gap: 0.2rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.65rem 0.7rem;
background: #f7faf9;
}
.geo-loaded-scope span,
.geo-loaded-scope small {
color: var(--muted);
font-size: 0.72rem;
}
.geo-loaded-scope strong {
color: var(--text);
font-size: 0.88rem;
}
.technical-management-body {
display: grid;
gap: 0.8rem;
border-top: 1px solid var(--line);
padding: 0.8rem;
background: #ffffff;
}
.technical-management-body > .compact-form {
padding: 0;
}
.technical-run-list,
.area-catalog-disclosure,
.dataset-technical-details,
.quality-advanced-disclosure,
.provider-technical-details,
.secondary-analysis-disclosure,
.status-details-disclosure,
.export-history-disclosure {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 6px;
background: #f8fafb;
}
.technical-run-list > summary,
.area-catalog-disclosure > summary,
.dataset-technical-details > summary,
.quality-advanced-disclosure > summary,
.provider-technical-details > summary,
.secondary-analysis-disclosure > summary,
.status-details-disclosure > summary,
.export-history-disclosure > summary {
display: flex;
min-height: 2.7rem;
cursor: pointer;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
padding: 0.62rem 0.75rem;
color: #33424d;
font-size: 0.8rem;
font-weight: 750;
list-style: none;
}
.technical-run-list > summary::-webkit-details-marker,
.area-catalog-disclosure > summary::-webkit-details-marker,
.dataset-technical-details > summary::-webkit-details-marker,
.quality-advanced-disclosure > summary::-webkit-details-marker,
.provider-technical-details > summary::-webkit-details-marker,
.secondary-analysis-disclosure > summary::-webkit-details-marker,
.status-details-disclosure > summary::-webkit-details-marker,
.export-history-disclosure > summary::-webkit-details-marker {
display: none;
}
.technical-run-list > summary::after,
.area-catalog-disclosure > summary::after,
.dataset-technical-details > summary::after,
.quality-advanced-disclosure > summary::after,
.provider-technical-details > summary::after,
.secondary-analysis-disclosure > summary::after,
.status-details-disclosure > summary::after,
.export-history-disclosure > summary::after {
content: '+';
margin-left: auto;
color: var(--accent-strong);
font-size: 1rem;
}
.technical-run-list[open] > summary::after,
.area-catalog-disclosure[open] > summary::after,
.dataset-technical-details[open] > summary::after,
.quality-advanced-disclosure[open] > summary::after,
.provider-technical-details[open] > summary::after,
.secondary-analysis-disclosure[open] > summary::after,
.status-details-disclosure[open] > summary::after,
.export-history-disclosure[open] > summary::after {
content: '-';
}
.technical-run-list .entity-list,
.area-catalog-disclosure .entity-list {
max-height: 28rem;
overflow: auto;
border-top: 1px solid var(--line);
padding: 0.65rem;
background: #ffffff;
}
.quality-advanced-disclosure {
margin-top: 0.75rem;
}
.quality-advanced-disclosure > summary strong {
margin-right: 0.25rem;
color: var(--ink);
font-size: 0.76rem;
}
.quality-advanced-disclosure > div {
margin: 0;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
}
.dataset-technical-details {
margin-top: 0.65rem;
}
.dataset-technical-details > div,
.dataset-technical-details > button,
.provider-technical-details > div {
margin: 0 0.7rem 0.7rem;
}
.dataset-technical-details > summary,
.provider-technical-details > summary {
min-height: 2.3rem;
padding-block: 0.45rem;
}
.dataset-card-primary-meta {
margin-top: 0.25rem;
}
.ai-user-summary {
order: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.ai-user-summary-card {
display: grid;
min-width: 0;
gap: 0.25rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.75rem;
background: #ffffff;
}
.ai-user-summary-card-primary {
border-color: #bfd8d2;
background: var(--accent-soft);
}
.ai-user-summary-card-ready {
border-left: 3px solid var(--success);
}
.ai-user-summary-card span,
.ai-user-summary-card p {
margin: 0;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
}
.ai-user-summary-card strong {
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.95rem;
}
.secondary-analysis-disclosure {
align-self: start;
}
.secondary-analysis-disclosure > summary strong,
.status-details-disclosure > summary strong,
.export-history-disclosure > summary strong {
margin-left: auto;
color: var(--muted);
font-size: 0.72rem;
}
.secondary-analysis-disclosure > section,
.secondary-analysis-disclosure > .workspace-panel {
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
}
.status-details-disclosure > section {
margin: 0;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
}
.quality-results-panel-empty .quality-evidence-surface,
.quality-results-panel-empty .quality-drilldown-surface,
.quality-results-panel-empty .quality-control-surface,
.quality-results-panel-empty .quality-history-surface {
display: none;
}
.quality-user-empty-state {
margin-top: 0.75rem;
}
.export-summary-surface,
.export-handoff-surface,
.handoff-action-card-technical {
display: none;
}
.latest-download-empty {
grid-column: 1 / -1;
}
.export-history-body {
display: grid;
gap: 0.75rem;
border-top: 1px solid var(--line);
padding: 0.75rem;
background: #ffffff;
}
.export-history-body > div:first-child {
display: none;
}
@media (min-width: 1100px) {
.workspace-grid-analysis {
grid-template-columns: minmax(0, 1fr);
}
.workspace-grid-analysis > section:nth-child(n),
.workspace-grid-analysis > details:nth-child(n) {
max-height: none;
overflow: visible;
}
}
@media (max-width: 1180px) {
.ai-user-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.ai-user-summary {
grid-template-columns: minmax(0, 1fr);
}
.context-bar > div:nth-child(n + 3) {
display: none;
}
}
@media (min-width: 621px) {
.status-strip-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));