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

This commit is contained in:
Codex
2026-07-17 14:19:02 +02:00
parent cc36dabcbe
commit 78a5f0b0b2
81 changed files with 2248 additions and 1467 deletions
+15
View File
@@ -7,6 +7,21 @@
# Changelog
## Sprint 234 Full audit closure and workspace lifecycle cleanup (2026-07-17)
- Added reversible active/archived project lifecycle handling and made active
workspaces the default project-list contract.
- Added a dry-run-first, strict-allowlist operator command for archiving
benchmark, calibration and smoke-test projects while preserving all related
persistence and both canonical operational workspaces.
- Moved overview orchestration, detection model management and pure map helpers
into focused modules without changing API or persistence behavior.
- Reduced end-user noise across Map, Quality, Detection and Segmentation by
using Dutch task language and placing UUIDs, paths, checksums and runtime
diagnostics behind technical disclosures.
- Added widescreen layout guardrails for AI workspaces and regression coverage
for project lifecycle, packaged cleanup and the new component boundaries.
## Sprint 233 Operational correctness and result completion (2026-07-17)
- Fixed the map-first contract so drawn/manual selections use `bbox ∩ Area`
+28
View File
@@ -1680,3 +1680,31 @@ DatasetService/VectorFeatureService, creates new temporal Datasets and retains
all previous snapshots. Do not add `--force` to this coordinator; a source
refetch remains a separate deliberate recovery action in the lower-level
operators.
## Safe project lifecycle cleanup
Operational validation, calibration and benchmark runs can create technical
projects. The default project API now returns active workspaces only, while
archived workspaces remain queryable with `GET /api/v1/projects?status=archived`.
Use the packaged cleanup command to archive only the strict technical-name
allowlist:
```bash
# Dry-run: inspect the number of matches without changing the database.
python scripts/archive_technical_projects.py
# Apply the exact allowlisted plan.
python scripts/archive_technical_projects.py --apply
```
Inside the all-in-one Unraid container:
```bash
docker exec geointel python /app/scripts/archive_technical_projects.py
docker exec geointel python /app/scripts/archive_technical_projects.py --apply
```
The command never deletes projects or related datasets, jobs, analyses,
quality checks and exports. It always preserves `Kempen Regional Workbench`
and `Mol Municipality Workbench`, defaults to dry-run and can print every
matched name with `--show-names`.
+11 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -18,9 +19,16 @@ def list_projects(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
name: str | None = Query(default=None, min_length=1, max_length=255),
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
db: Session = Depends(get_db),
):
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset, name=name)
projects, total = ProjectService.list_projects(
db,
limit=limit,
offset=offset,
name=name,
project_status=project_status,
)
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
@@ -41,6 +49,8 @@ def get_project(project_id: UUID, db: Session = Depends(get_db)):
@router.patch("/{project_id}", response_model=dict)
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
project = ProjectService.update_project(db, project_id, payload)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return envelope(ProjectRead.model_validate(project).model_dump())
+2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel
@@ -16,6 +17,7 @@ class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
region: str | None = None
status: Literal["active", "archived"] | None = None
class ProjectRead(BaseModel):
+4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from typing import Literal
from sqlalchemy.orm import Session
@@ -16,8 +17,11 @@ class ProjectService:
limit: int = 50,
offset: int = 0,
name: str | None = None,
project_status: Literal["active", "archived", "all"] = "active",
) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted")
if project_status != "all":
query = query.filter(Project.status == project_status)
if name:
query = query.filter(Project.name == name.strip())
query = query.order_by(Project.created_at.desc())
@@ -22,7 +22,7 @@ def test_raster_tile_manifest_can_handoff_to_segmentation_lab() -> None:
assert "setSegmentationTileManifestPath(manifestPath)" in app
assert "setSelectedSegmentationDatasetId(selectedDataset.id)" in app
assert "onUseTileManifestForSegmentation" in detail_panel
assert "Use in Segmentation Lab" in raster_controls
assert "Gebruik voor segmentatie" in raster_controls
assert "disabled={!latestRasterTileManifestPath}" in raster_controls
assert "Tile manifest" in segmentation_lab
assert "Raster tile manifest path" in segmentation_lab
assert "Beeldtegelmanifest" in segmentation_lab
assert "Beeldtegelmanifest" in segmentation_lab
@@ -15,7 +15,7 @@ def test_detection_lab_exposes_run_readiness_contract() -> None:
assert "detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'" in lab
assert "detectionTileManifestPath.trim().length > 0" in lab
assert "detectionRunReady" in lab
assert 'aria-label="Detection run readiness"' in lab
assert 'aria-label="Startklaar voor gebouwdetectie"' in lab
assert "Wat is nog nodig?" in lab
assert "Luchtbeeld" in lab
assert "Analysemodel" in lab
@@ -33,12 +33,12 @@ def test_segmentation_lab_exposes_run_readiness_contract() -> None:
assert "segmentationRunReady" in lab
assert "selectedSegmentationModelConfigured" in lab
assert "selectedSegmentationModelLimitation" in lab
assert 'aria-label="Segmentation run readiness"' in lab
assert "Run readiness" in lab
assert "Raster dataset" in lab
assert "Model availability" in lab
assert "Tile manifest" in lab
assert "Ready to submit" in lab
assert 'aria-label="Startklaar voor segmentatie"' in lab
assert "Wat is nog nodig?" in lab
assert "Rasterbestand" in lab
assert "Analysemodel" in lab
assert "Beeldtegels" in lab
assert "Klaar om te starten" in lab
def test_ai_lab_run_readiness_css_contract() -> None:
@@ -27,8 +27,8 @@ def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action
assert "segmentationModelUiRunnable" in lab
assert "selectedSegmentationModelId !== 'fixture-segmenter'" in lab
assert "segmentationRunBlockedReason" in lab
assert "Fixture segmenter is explicit test/demo-only" in lab
assert "Run action" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Analyse" in lab
assert "disabled={runningSegmentation || !segmentationRunReady}" in lab
@@ -11,13 +11,13 @@ def test_map_workspace_exposes_feature_extract_actions() -> None:
encoding="utf-8"
)
assert "Selection & extract" in map_workspace
assert "Selectie en extractie" in map_workspace
assert "downloadSelectedMapFeature" in map_workspace
assert "copySelectedMapFeatureProperties" in map_workspace
assert "selected-feature.geojson" in map_workspace
assert "Download selected GeoJSON" in map_workspace
assert "Copy selected properties" in map_workspace
assert "Clear selection" in map_workspace
assert "Geselecteerde GeoJSON downloaden" in map_workspace
assert "Eigenschappen kopiëren" in map_workspace
assert "Selectie wissen" in map_workspace
assert "featureGeometrySummary" in map_workspace
assert "featureExtractionEntries" in map_workspace
@@ -268,9 +268,9 @@ def test_frontend_exposes_map_bbox_selection_contracts() -> None:
assert "selectVectorFeatures" in api_client
assert "Area selection" in map_workspace
assert "Start map bbox" in map_workspace
assert "Run area extract" in map_workspace
assert "Download area GeoJSON" in map_workspace
assert "Teken rechthoek" in map_workspace
assert "Objecten in gebied ophalen" in map_workspace
assert "Gebiedsdownload bewaren" in map_workspace
assert "bboxSelectionMode" in geomap
assert "selection-bbox" in geomap
assert "selection-result" in geomap
@@ -258,7 +258,7 @@ def test_frontend_exposes_map_selection_export_action() -> None:
assert "area_id?: string" in exports_api
assert "exportMapSelectionGeoJson" in export_hook
assert "vector_selection" in export_hook
assert "Save area export" in map_workspace
assert "Gebiedsdownload bewaren" in map_workspace
assert "onExportMapSelection" in map_workspace
assert "selectionExportError" in map_workspace
assert "onExportMapSelection={exportMapSelectionGeoJson}" in app
@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
import json
from pathlib import Path
@@ -220,5 +220,5 @@ def test_frontend_exposes_map_selection_derive_action() -> None:
assert "VectorSelectionDeriveRequest" in types
assert "deriveVectorSelection" in datasets_api
assert "deriveMapSelectionDataset" in app
assert "Save as dataset" in map_workspace
assert "Als resultaatlaag bewaren" in map_workspace
assert "selectionDatasetError" in map_workspace
@@ -1,4 +1,4 @@
from pathlib import Path
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
@@ -19,7 +19,7 @@ def test_map_selection_qa_shortcut_uses_existing_qa_workflow_contract() -> None:
assert "useMapSelectionQa" in app
assert "mapQaReferenceDatasets={referenceDatasets}" in app
assert "onRunMapSelectionQa={runMapSelectionQa}" in app
assert "Run QA on saved dataset" in map_workspace
assert "Bewaarde laag controleren" in map_workspace
assert "mapSelectionQaError" in map_workspace
assert "mapSelectionQaResult" in map_workspace
@@ -24,13 +24,13 @@ def test_map_qa_result_exposes_quality_check_evidence_contract():
assert "latestMapSelectionQualityCheckId={latestMapSelectionQualityCheckId}" in app
assert "onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence}" in app
assert "QA evidence" in workspace
assert "Quality check id" in workspace
assert "Mean IoU" in workspace
assert "False positives" in workspace
assert "False negatives" in workspace
assert "Map selection QA warnings" in workspace
assert "Open QA/QC evidence" in workspace
assert "Kaartbewijs" in workspace
assert "Status bewijs" in workspace
assert "Gemiddelde overlap" in workspace
assert "Onterecht gevonden" in workspace
assert "Gemist" in workspace
assert "Aandachtspunten bij de kwaliteitscontrole" in workspace
assert "Kaartbewijs openen" in workspace
def test_map_qa_evidence_keeps_backend_contract_unchanged():
@@ -18,7 +18,7 @@ def test_qa_feature_evidence_contract_is_documented_and_rendered() -> None:
assert field_name in quality_panel
assert field_name in api_contracts
assert "Feature-level QA/QC evidence" in quality_panel
assert "Kaartbewijs per object" in quality_panel
assert "Overeenkomende object-ID's" in quality_panel
assert "ID's van onterecht gevonden objecten" in quality_panel
assert "ID's van gemiste objecten" in quality_panel
@@ -24,24 +24,24 @@ def test_map_workspace_can_select_persisted_database_layer_and_run_query() -> No
styles = (REPO_ROOT / "frontend/src/styles/app.css").read_text(encoding="utf-8")
assert "map-database-layer-select" in map_workspace
assert "Select persisted vector layer" in map_workspace
assert "Basemap usage notice" in map_workspace
assert "Kies een bewaarde vectorlaag" in map_workspace
assert "Gebruik van de kaartondergrond" in map_workspace
assert "VITE_MAP_STYLE_URL" in map_workspace
assert "Operational GIS run" in map_workspace
assert "Guided operational GIS workflow" in map_workspace
assert "vector_features" in map_workspace
assert "Run AOI/layer query" in map_workspace
assert "Save result dataset" in map_workspace
assert "Save GeoJSON export" in map_workspace
assert "Run QA/QC" in map_workspace
assert "Run full GIS workflow" in map_workspace
assert "Operationele GIS-controle" in map_workspace
assert "Begeleide operationele GIS-werkstroom" in map_workspace
assert "Bewaarde databankobjecten" in map_workspace
assert "Werkgebied of laag doorzoeken" in map_workspace
assert "Resultaatlaag bewaren" in map_workspace
assert "GeoJSON-download bewaren" in map_workspace
assert "Kwaliteit controleren" in map_workspace
assert "Volledige GIS-werkstroom uitvoeren" in map_workspace
assert "runFullGisWorkflow" in map_workspace
assert "fullWorkflowStatus" in map_workspace
assert "Query, save, QA and export" in map_workspace
assert "Selecteren, bewaren, controleren en downloaden" in map_workspace
assert "fullWorkflowMode" in map_workspace
assert "Create new dataset/export" in map_workspace
assert "Reuse latest saved dataset for QA" in map_workspace
assert "Reusing latest saved dataset for QA/QC" in map_workspace
assert "Nieuwe resultaatlaag en download maken" in map_workspace
assert "Laatste resultaatlaag opnieuw controleren" in map_workspace
assert "Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd." in map_workspace
assert "latestSelectionDataset" in map_workspace
assert "selectedMapDatasetId=" in app_shell
assert ".basemap-policy-notice" in styles
@@ -5,15 +5,18 @@ ROOT = Path(__file__).resolve().parents[2]
def test_detection_lab_surfaces_yolo_runtime_preflight() -> None:
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
hook = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8")
api = (ROOT / "frontend" / "src" / "services" / "api" / "detection.ts").read_text(encoding="utf-8")
types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
assert "YOLO runtime preflight" in lab
assert "Technische YOLO-runtimecontrole" in lab
assert "torch_version" in lab
assert "ultralytics_version" in lab
assert "cuda_available" in lab
@@ -26,8 +29,11 @@ def test_detection_lab_surfaces_yolo_runtime_preflight() -> None:
def test_detection_lab_surfaces_local_model_asset_selection() -> None:
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
hook = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8")
api = (ROOT / "frontend" / "src" / "services" / "api" / "detection.ts").read_text(encoding="utf-8")
@@ -44,7 +50,7 @@ def test_detection_lab_surfaces_local_model_asset_selection() -> None:
assert "modelAssets" in hook
assert "selectedModelAssetId" in hook
assert "model_asset_id: selectedModelAssetId || null" in hook
assert "Local model assets" in lab
assert "Lokaal modelbestand" in lab
assert "onSelectModelAsset" in lab
assert "modelAssets={modelAssets}" in app
assert "Officiële referentiebronnen" in provider_panel
@@ -16,21 +16,27 @@ def test_detection_workflow_selects_only_the_active_runtime_model_asset_automati
def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -> None:
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
assert "Lokaal modelbestand" in lab
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
assert "Gevalideerde profielen" in lab
assert "Gevalideerde YOLO-profielen" in lab
assert "DETECTION_OPERATOR_PROFILES" in lab
assert "kandidaat · extra controle vereist" in lab
assert "kandidaat, extra controle vereist" in lab
assert "will_download_models" in lab
def test_detection_run_readiness_requires_explicit_asset_when_local_assets_exist() -> None:
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
assert "detectionHasExplicitModelAsset" in lab
@@ -21,17 +21,20 @@ def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None:
)
assert "latestRasterTileManifest" in controls
assert "Tile count" in controls
assert "Tile size" in controls
assert "Aantal tegels" in controls
assert "Tegelgrootte" in controls
assert "Overlap" in controls
assert "Use manifest in Detection Lab" in controls
assert "Use manifest in Segmentation Lab" in controls
assert "Gebruik voor gebouwdetectie" in controls
assert "Gebruik voor segmentatie" in controls
def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
assert "setDetectionTileManifestPath(manifestPath)" in app
@@ -41,6 +44,6 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl
assert "loadYoloPreflight(manifestPath).catch(() => null)" in app
assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))]
assert "Gekoppelde beeldtegels" in lab
assert "Tile manifest validation" in lab
assert "Tile count" in lab
assert "will_run_inference" in lab
assert "Gekoppelde beeldtegels" in lab
assert "Aantal beeldtegels" in lab
assert "yoloPreflight.tile_count" in lab
@@ -22,7 +22,7 @@ def test_detection_lab_exposes_persisted_threshold_calibration_comparison() -> N
assert "Minste foutieve meldingen" in source
assert "Drempel" in source
assert "Precisie" in source
assert "Recall" in source
assert "Herkenningsgraad" in source
assert "F1" in source
assert "Fout positief" in source
assert "Fout negatief" in source
@@ -33,11 +33,11 @@ def test_detection_lab_has_guided_threshold_calibration_runner() -> None:
assert "Configured YOLO calibration requires a tile manifest" in hook_source
assert "Select a local model asset before calibration" in hook_source
assert "Guided calibration runner" in lab_source
assert "This runs real configured YOLO jobs" in lab_source
assert "Threshold set" in lab_source
assert "Run calibration sweep" in lab_source
assert "Calibration run progress" in lab_source
assert "Modelkalibratie voor beheerders" in lab_source
assert "Voert het lokale model en een kwaliteitscontrole uit" in lab_source
assert "Zekerheidsdrempels" in lab_source
assert "Drempels vergelijken" in lab_source
assert "Voortgang modelkalibratie" in lab_source
assert "detectionCalibrationRows.map" in lab_source
assert "runningDetectionCalibration" in lab_source
assert "detectionCalibrationError" in lab_source
@@ -14,7 +14,7 @@ def test_guided_calibration_rows_link_to_existing_qa_evidence_map() -> None:
todo_source = todo.read_text(encoding="utf-8")
assert "onOpenCalibrationEvidence" in lab_source
assert "Open evidence map" in lab_source
assert "Toon kaartbewijs" in lab_source
assert "disabled={!row.quality_check_id || row.status !== 'success'}" in lab_source
assert "onOpenCalibrationEvidence(row.quality_check_id)" in lab_source
assert "Evidence" in lab_source
@@ -16,7 +16,7 @@ def test_guided_calibration_runner_exports_review_summary() -> None:
assert "downloadJsonFile('detection-calibration-summary.json'" in lab_source
assert "evidence_geojson_url" in lab_source
assert "/api/v1/projects/${projectId}/quality-checks/${row.quality_check_id}/evidence/geojson" in lab_source
assert "Download calibration summary" in lab_source
assert "Samenvatting downloaden" in lab_source
assert "disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}" in lab_source
assert "calibration_thresholds" in lab_source
assert "quality_check_ids" in lab_source
@@ -31,15 +31,18 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> None:
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
assert "DETECTION_OPERATOR_PROFILES" in lab
assert "Gevalideerde profielen" in lab
assert "Gevalideerde YOLO-profielen" in lab
assert "profile.displayName" in lab
assert "profile.confidenceThreshold" in lab
assert "kandidaat · extra controle vereist" in lab
assert "kandidaat, extra controle vereist" in lab
assert "standaardprofiel" in lab
assert "Profiel gebruiken" in lab
assert "onApplyOperatorProfile(profile)" in lab
@@ -99,7 +99,7 @@ def test_detection_results_table_uses_bounded_local_pagination() -> None:
assert "DETECTION_PAGE_SIZE_OPTIONS" in lab
assert "visibleDetectionItems" in lab
assert "detectionItems.slice" in lab
assert "Detection result pagination" in lab
assert "Paginering van gevonden objecten" in lab
assert "Vorige resultatenpagina" in lab
assert "Volgende resultatenpagina" in lab
assert "formatSourceTilePath(detection.source_tile_path)" in lab
@@ -60,13 +60,18 @@ def test_map_prioritizes_controls_map_and_collapsed_diagnostics() -> None:
def test_ai_workspaces_prioritize_runs_and_collapse_registry_detail() -> None:
detection = read("frontend/src/components/detection/DetectionLab.tsx")
detection = "\n".join(
(
read("frontend/src/components/detection/DetectionLab.tsx"),
read("frontend/src/components/detection/DetectionModelManagement.tsx"),
)
)
segmentation = read("frontend/src/components/segmentation/SegmentationLab.tsx")
css = read("frontend/src/styles/premium.css")
assert '<details className="ai-lab-model-surface" aria-label="Detection model capabilities">' in detection
assert '<details className="ai-lab-model-surface" aria-label="YOLO runtime preflight">' in detection
assert '<details className="ai-lab-model-surface" aria-label="Segmentation model capabilities">' in segmentation
assert '<details className="ai-lab-model-surface" aria-label="Technische modelmogelijkheden">' in detection
assert '<details className="ai-lab-model-surface" aria-label="Technische YOLO-runtimecontrole">' in detection
assert '<details className="ai-lab-model-surface" aria-label="Technische informatie over segmentatiemodellen">' in segmentation
assert ".ai-lab-shell > .lab-block {\n order: 2;" in css
assert ".ai-lab-shell > .ai-lab-model-surface {\n order: 7;" in css
assert "details.ai-lab-model-surface > summary" in css
@@ -11,7 +11,7 @@ def test_map_source_mode_keeps_database_layers_distinct_from_analysis_results()
assert "const [mapContentMode, setMapContentMode]" in app
assert "mapContentMode === 'analysis' && analysisMapLayerAvailable" in app
assert "setMapContentMode('dataset')" in app
assert 'aria-label="Map content source"' in workspace
assert 'aria-label="Bron van de kaartinhoud"' in workspace
assert "onSetMapContentMode('dataset')" in workspace
assert "onSetMapContentMode('analysis')" in workspace
assert "disabled={!analysisLayerAvailable}" in workspace
+6 -1
View File
@@ -121,7 +121,12 @@ def test_scope_api_pagination_respects_canonical_limit() -> None:
def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None:
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
workspace = "\n".join(
(
(ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend/src/components/map/mapWorkspaceUtils.ts").read_text(encoding="utf-8"),
)
)
app = (ROOT / "frontend/src/App.tsx").read_text(encoding="utf-8")
assert "COPY scripts/geographic_scopes.py" in dockerfile
+3 -3
View File
@@ -28,7 +28,7 @@ def test_app_wires_map_workbench_component() -> None:
assert "selectedMapFeature" in app
assert "<MapWorkspace" in app
assert "onSelectMapFeature={setSelectedMapFeature}" in app
assert "Active layer" in component
assert "Layer opacity" in component
assert "Feature inspector" in component
assert "Actieve kaartlaag" in component
assert "Dekking van de kaartlaag" in component
assert "Objectinspectie" in component
assert "onFeatureSelect={onSelectMapFeature}" in component
@@ -54,7 +54,7 @@ def test_frontend_wires_selected_area_map_overlay_contract() -> None:
assert "areaFeatureCollection" in app
assert "areaFeatureCollection={areaFeatureCollection}" in app
assert "areaData={areaFeatureCollection}" in map_workspace
assert "AOI" in map_workspace
assert "Werkgebied" in map_workspace
assert "area-fill" in geomap
assert "area-line" in geomap
assert "onSelectMapArea" in area_panel
@@ -231,7 +231,7 @@ def test_source_freshness_operator_and_ui_contract_are_read_only() -> None:
script = (root / "scripts" / "audit_source_freshness.py").read_text(encoding="utf-8")
dockerfile = (root / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (root / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
app = (root / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
app = (root / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
api = (root / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8")
assert "Request(endpoint" in script
@@ -8,12 +8,13 @@ ROOT = Path(__file__).resolve().parents[2]
def test_frontend_wires_v1_workbench_status_strip() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
overview = (ROOT / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
component = (ROOT / "frontend" / "src" / "components" / "WorkbenchStatusStrip.tsx").read_text(encoding="utf-8")
css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
assert "WorkbenchStatusStrip" in app
assert "selectedProject={selectedProject}" in app
assert "qualityChecks={qualityChecks}" in app
assert "WorkbenchStatusStrip" in overview
assert "selectedProject={selectedProject}" in overview
assert "qualityChecks={qualityChecks}" in overview
assert "activeLayerFeatureCount={mapFeatureCount}" in app
assert "selectedAreaHasGeometry={Boolean(areaFeatureCollection)}" in app
assert "Platformstatus" in component
@@ -248,8 +248,8 @@ def test_project_list_supports_exact_canonical_workspace_lookup(monkeypatch) ->
project_id = uuid4()
captured: dict = {}
def fake_list(_db, *, limit, offset, name):
captured.update(limit=limit, offset=offset, name=name)
def fake_list(_db, *, limit, offset, name, project_status):
captured.update(limit=limit, offset=offset, name=name, project_status=project_status)
return [
ProjectRead(
id=project_id,
@@ -271,6 +271,7 @@ def test_project_list_supports_exact_canonical_workspace_lookup(monkeypatch) ->
"limit": 1,
"offset": 0,
"name": "Kempen Regional Workbench",
"project_status": "active",
}
@@ -0,0 +1,149 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.routes import projects as project_routes
from app.main import app
from app.schemas.project import ProjectRead, ProjectUpdate
from app.services.project_service import ProjectService
ROOT = Path(__file__).resolve().parents[2]
def load_cleanup_module():
script = ROOT / "scripts" / "archive_technical_projects.py"
spec = importlib.util.spec_from_file_location("archive_technical_projects_test", script)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_project_list_defaults_to_active_and_can_request_archived(monkeypatch) -> None:
captured: list[str] = []
def fake_list(_db, *, limit, offset, name, project_status):
del limit, offset, name
captured.append(project_status)
return [
ProjectRead(
id=uuid4(),
name=f"{project_status} project",
region="Kempen",
status=project_status if project_status != "all" else "active",
)
], 1
monkeypatch.setattr(ProjectService, "list_projects", fake_list)
client = TestClient(app)
assert client.get("/api/v1/projects").status_code == 200
assert client.get("/api/v1/projects", params={"status": "archived"}).status_code == 200
assert captured == ["active", "archived"]
def test_project_update_schema_allows_only_active_or_archived() -> None:
from pydantic import ValidationError
from app.schemas.project import ProjectUpdate
assert ProjectUpdate(status="archived").status == "archived"
try:
ProjectUpdate(status="deleted")
except ValidationError:
pass
else:
raise AssertionError("ProjectUpdate must not expose deleted as an ordinary lifecycle state")
def test_project_update_returns_404_when_project_is_missing(monkeypatch) -> None:
monkeypatch.setattr(ProjectService, "update_project", lambda *_args, **_kwargs: None)
with pytest.raises(HTTPException) as exc_info:
project_routes.update_project(uuid4(), ProjectUpdate(status="archived"), db=SimpleNamespace())
assert exc_info.value.status_code == 404
def test_cleanup_allowlist_preserves_real_workspaces() -> None:
module = load_cleanup_module()
assert module.is_technical_project_name("GeoIntel Detection Quality Matrix 42")
assert module.is_technical_project_name("GeoIntel hard-negative Mol 20260709")
assert module.is_technical_project_name("GeoIntel Detection Calibration 0.15 20260709T090018Z")
assert module.is_technical_project_name("GeoIntel Real Data Validation 20260707T000620Z")
assert module.is_technical_project_name("GeoIntel Operational YOLO Geel Smoke 20260711T133656Z")
assert module.is_technical_project_name("GeoIntel Demo - Building QA")
assert not module.is_technical_project_name("Kempen Regional Workbench")
assert not module.is_technical_project_name("Mol Municipality Workbench")
assert not module.is_technical_project_name("Vrij project van een gebruiker")
def test_cleanup_plan_selects_active_allowlisted_projects_only() -> None:
module = load_cleanup_module()
rows = [
SimpleNamespace(
id=uuid4(),
name="GeoIntel Detection Quality Matrix 1",
status="active",
),
SimpleNamespace(
id=uuid4(),
name="Kempen Regional Workbench",
status="active",
),
]
class Query:
def filter(self, *_args):
return self
def order_by(self, *_args):
return self
def all(self):
return rows
class Session:
def query(self, _model):
return Query()
plan = module.build_archive_plan(Session())
assert plan.count == 1
assert plan.names == ("GeoIntel Detection Quality Matrix 1",)
def test_cleanup_script_is_packaged_and_readiness_checked() -> None:
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
assert "COPY scripts/archive_technical_projects.py" in dockerfile
assert "py_compile scripts/archive_technical_projects.py" in readiness
def test_frontend_lifecycle_and_component_boundaries_are_wired() -> None:
app_source = (ROOT / "frontend/src/App.tsx").read_text(encoding="utf-8")
project_panel = (ROOT / "frontend/src/components/project/ProjectPanel.tsx").read_text(encoding="utf-8")
detection_lab = (ROOT / "frontend/src/components/detection/DetectionLab.tsx").read_text(encoding="utf-8")
map_workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
premium_css = (ROOT / "frontend/src/styles/premium.css").read_text(encoding="utf-8")
assert "OverviewWorkspace" in app_source
assert "onArchiveProject={archiveProject}" in app_source
assert "DetectionModelManagement" in detection_lab
assert "from './mapWorkspaceUtils'" in map_workspace
assert "Werkruimte archiveren" in project_panel
assert "PROTECTED_PROJECT_NAMES" in project_panel
assert ".technical-inline-details" in premium_css
assert ".workspace-grid-ai" in premium_css
@@ -19,8 +19,8 @@ def test_app_uses_dataset_presentational_components() -> None:
assert "from '../datasets/DatasetDetailPanel'" in inspector
assert "<DatasetDetailPanel {...datasetDetailProps} />" in inspector
assert "<form onSubmit={uploadDataset}" not in app
assert "<h3>Raster operations</h3>" not in app
assert "<h3>Vector operations</h3>" not in app
assert "<h3>Rasterbewerkingen</h3>" not in app
assert "<h3>Vectorbewerkingen</h3>" not in app
def test_dataset_panel_owns_upload_and_list_markup() -> None:
@@ -46,9 +46,9 @@ def test_dataset_detail_panel_composes_raster_and_vector_controls() -> None:
assert "<RasterControls" in detail_panel
assert "<VectorControls" in detail_panel
assert "Jobs" in detail_panel
assert "Raster operations" in raster_controls
assert "Compute NDVI" in raster_controls
assert "Generate tiles" in raster_controls
assert "Vector operations" in vector_controls
assert "Run intersect" in vector_controls
assert "Technische verwerking" in detail_panel
assert "Rasterbewerkingen" in raster_controls
assert "NDVI berekenen" in raster_controls
assert "Beeldtegels maken" in raster_controls
assert "Vectorbewerkingen" in vector_controls
assert "Overlap berekenen" in vector_controls
@@ -53,9 +53,9 @@ def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None:
assert "GeoMap" in map_workspace
assert "map-toolbar" in map_workspace
assert "Area" in map_workspace
assert "AOI" in map_workspace
assert "Active layer" in map_workspace
assert "Feature inspector" in map_workspace
assert "Werkgebied" in map_workspace
assert "Actieve kaartlaag" in map_workspace
assert "Objectinspectie" in map_workspace
assert "onFeatureSelect={onSelectMapFeature}" in map_workspace
assert "fetch(" not in map_workspace
assert "api" not in map_workspace.lower()
@@ -31,8 +31,11 @@ def test_map_and_ai_workspaces_use_task_blocks_not_raw_stacks() -> None:
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(
encoding="utf-8"
)
detection_lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
detection_lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
segmentation_lab = (
ROOT / "frontend" / "src" / "components" / "segmentation" / "SegmentationLab.tsx"
@@ -41,7 +44,7 @@ def test_map_and_ai_workspaces_use_task_blocks_not_raw_stacks() -> None:
assert "map-toolbar" in map_workspace
assert "layer-control-card" in map_workspace
assert "Spatial review" in map_workspace
assert "Kwaliteit controleren" in map_workspace
assert "model-list" in detection_lab
assert "lab-block" in detection_lab
assert "lab-form-grid" in detection_lab
@@ -27,10 +27,10 @@ def test_workbench_inspector_exposes_context_dataset_quality_and_ai_tabs() -> No
assert 'data-testid="workbench-inspector-panel"' in inspector
assert "data-testid={`inspector-tab-${tab.key}`}" in inspector
assert "<DatasetDetailPanel {...datasetDetailProps} />" in inspector
assert "Latest QA/QC" in inspector
assert "Latest export" in inspector
assert "Detection run" in inspector
assert "Segmentation run" in inspector
assert "Laatste kwaliteitscontrole" in inspector
assert "Laatste download" in inspector
assert "Gebouwdetectie" in inspector
assert "Segmentatie" in inspector
assert "fetch(" not in inspector
@@ -63,9 +63,9 @@ def test_inspector_exposes_navigation_actions_without_api_calls() -> None:
assert "onOpenQualityWorkspace" in inspector
assert "onOpenExportsWorkspace" in inspector
assert "onOpenAiWorkspace" in inspector
assert "Data catalog" in inspector
assert "Map layer" in inspector
assert "Open QA/QC" in inspector
assert "Open exports" in inspector
assert "Open AI Labs" in inspector
assert "Gegevenscatalogus" in inspector
assert "Kaartlaag" in inspector
assert "Kwaliteit openen" in inspector
assert "Downloads openen" in inspector
assert "Beeldanalyse openen" in inspector
assert "fetch(" not in inspector
@@ -28,5 +28,5 @@ def test_map_workspace_has_clear_empty_result_layer_guidance() -> None:
encoding="utf-8"
)
assert "No active vector or result layer" in map_workspace
assert "Open a dataset, detection run, segmentation run or change result to draw it here." in map_workspace
assert "Geen actieve vector- of resultaatlaag" in map_workspace
assert "Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen." in map_workspace
@@ -12,9 +12,9 @@ def test_map_empty_state_surfaces_ready_vector_dataset_actions() -> None:
assert "availableMapDatasets" in map_workspace
assert "onOpenDatasetInMap" in map_workspace
assert "map-empty-action-grid" in map_workspace
assert "Open a ready vector dataset" in map_workspace
assert "Open in map" in map_workspace
assert "No ready vector datasets available yet" in map_workspace
assert "Open een beschikbare vectorlaag" in map_workspace
assert "Open op kaart" in map_workspace
assert "Nog geen gebruiksklare vectorlagen beschikbaar" in map_workspace
def test_app_passes_available_vector_datasets_to_map_workspace() -> None:
@@ -21,8 +21,11 @@ def test_ai_labs_mobile_density_css_contracts() -> None:
def test_detection_and_segmentation_keep_ai_lab_workflow_markup() -> None:
detection_lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
detection_lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
segmentation_lab = (
ROOT / "frontend" / "src" / "components" / "segmentation" / "SegmentationLab.tsx"
@@ -21,12 +21,12 @@ def test_global_focus_visible_contracts_are_defined() -> None:
def test_primary_navigation_has_keyboard_labels() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
assert 'aria-label={`Open ${item.label} workspace: ${item.description}`}' in app
assert 'aria-label={`Switch to ${item.label} workspace`}' in app
assert 'aria-label="Open data setup workspace"' in app
assert 'aria-label="Inspect map workspace"' in app
assert 'aria-label="Review QA/QC workspace"' in app
assert 'aria-label="Manage exports workspace"' in app
assert 'aria-label={`Open ${item.label}: ${item.description}`}' in app
assert 'aria-label={`Ga naar ${item.label}`}' in app
assert "label: 'Bronnen'" in app
assert "label: 'Kaart'" in app
assert "label: 'Kwaliteit'" in app
assert "label: 'Downloads'" in app
def test_inspector_tabs_are_bound_to_tab_panels() -> None:
@@ -30,8 +30,8 @@ def test_raster_controls_expose_readable_operation_groups() -> None:
assert 'className="dataset-tool-label"' in raster_controls
assert 'className="dataset-tool-action-row"' in raster_controls
assert 'className="dataset-tool-error"' in raster_controls
assert "Target CRS for the derived raster artifact." in raster_controls
assert "Tile size must be > 0." in raster_controls
assert "Coördinatenstelsel van het afgeleide raster." in raster_controls
assert "De tegelgrootte moet groter zijn dan nul." in raster_controls
def test_vector_controls_expose_readable_operation_groups() -> None:
@@ -45,5 +45,5 @@ def test_vector_controls_expose_readable_operation_groups() -> None:
assert 'className="dataset-tool-field"' in vector_controls
assert 'className="dataset-tool-label"' in vector_controls
assert 'className="dataset-tool-action-row"' in vector_controls
assert "Clip features to the selected project area." in vector_controls
assert "Intersect with another persisted vector dataset." in vector_controls
assert "Beperk objecten tot het gekozen werkgebied." in vector_controls
assert "Bereken de overlap met een andere bewaarde vectorlaag." in vector_controls
@@ -11,7 +11,7 @@ def test_workbench_shell_has_skip_link_and_main_focus_target() -> None:
assert 'className="skip-link"' in app
assert 'href="#workspace-main"' in app
assert 'nav aria-label="Primary workspaces"' in app
assert 'nav aria-label="Hoofdonderdelen"' in app
assert 'id="workspace-main"' in app
assert "tabIndex={-1}" in app
@@ -7,12 +7,12 @@ ROOT = Path(__file__).resolve().parents[2]
def test_overview_uses_named_hierarchy_regions() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
app = (ROOT / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
assert 'className="overview-action-copy"' in app
assert 'className="quick-action-grid overview-quick-actions"' in app
assert 'className="quick-action-button"' in app
assert 'aria-label="Recommended next workbench actions"' in app
assert 'aria-label="Aanbevolen acties"' in app
def test_status_strip_has_compact_section_surface_contracts() -> None:
@@ -13,9 +13,9 @@ def test_map_workspace_exposes_structured_surfaces() -> None:
assert 'className="map-workspace-shell"' in map_workspace
assert 'className="map-context-summary"' in map_workspace
assert 'aria-label="Map layer status"' in map_workspace
assert 'aria-label="Status van de kaartlagen"' in map_workspace
assert 'className="map-control-surface"' in map_workspace
assert 'aria-label="Map workspace controls"' in map_workspace
assert 'aria-label="Bediening van de kaartwerkruimte"' in map_workspace
assert 'className="map-frame-surface"' in map_workspace
assert 'className="map-inspection-surface"' in map_workspace
@@ -26,7 +26,7 @@ def test_map_workspace_summary_uses_current_layer_and_selection_state() -> None:
)
assert "const selectedMapArea = areas.find" in map_workspace
assert "selectedMapArea?.name ?? 'No area selected'" in map_workspace
assert "selectedMapArea?.name ?? 'Geen gebied geselecteerd'" in map_workspace
assert "mapLayerLabel" in map_workspace
assert "mapLayerSourceLabel" in map_workspace
assert "mapFeatureCount" in map_workspace
@@ -14,14 +14,14 @@ def test_quality_results_panel_exposes_structured_surfaces() -> None:
assert "'quality-results-panel quality-results-panel-empty' : 'quality-results-panel'" in panel
assert 'className="quality-results-shell"' in panel
assert 'className="quality-summary-surface"' in panel
assert 'aria-label="QA/QC result summary"' in panel
assert 'aria-label="Samenvatting kwaliteitsresultaten"' in panel
assert 'className="quality-evidence-surface"' in panel
assert 'aria-label="QA/QC dataset evidence"' in panel
assert 'aria-label="Databronnen van de kwaliteitscontrole"' in panel
assert 'className="quality-control-surface"' in panel
assert 'aria-label="QA/QC refresh and filters"' in panel
assert 'aria-label="Kwaliteitsresultaten vernieuwen en filteren"' in panel
assert 'className="quality-result-state-stack"' in panel
assert 'className="quality-history-surface"' in panel
assert 'aria-label="QA/QC result history"' in panel
assert 'aria-label="Geschiedenis van kwaliteitsresultaten"' in panel
assert 'className="quality-advanced-disclosure"' in panel
@@ -14,10 +14,10 @@ def test_change_detection_panel_exposes_structured_surfaces() -> None:
assert 'className="panel change-detection-shell"' in panel
assert 'className="panel-header change-detection-heading"' in panel
assert 'className="change-detection-input-surface"' in panel
assert 'aria-label="Change detection input controls"' in panel
assert 'aria-label="Instellingen voor veranderingsanalyse"' in panel
assert 'className="change-detection-state-stack"' in panel
assert 'className="change-detection-result-surface"' in panel
assert 'aria-label="Change detection result summary"' in panel
assert 'aria-label="Samenvatting veranderingsanalyse"' in panel
assert 'className="change-detection-warning-surface"' in panel
@@ -31,8 +31,8 @@ def test_change_detection_panel_preserves_existing_compare_controls() -> None:
assert "onIouThresholdChange" in panel
assert "onIncludeUnchangedChange" in panel
assert "onRun" in panel
assert "Compare vectors" in panel
assert "Upload at least two vector datasets to compare." in panel
assert "Kaartlagen vergelijken" in panel
assert "Voeg minstens twee vectorlagen toe om periodes te vergelijken." in panel
assert "result.added_count" in panel
assert "result.removed_count" in panel
assert "result.unchanged_count" in panel
+14 -11
View File
@@ -7,18 +7,21 @@ ROOT = Path(__file__).resolve().parents[2]
def test_detection_lab_exposes_structured_surfaces() -> None:
lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
lab = "\n".join(
(
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"),
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionModelManagement.tsx").read_text(encoding="utf-8"),
)
)
assert 'className="workspace-panel ai-lab-shell detection-lab-shell"' in lab
assert 'className="ai-lab-model-surface"' in lab
assert 'aria-label="Detection model capabilities"' in lab
assert 'aria-label="Technische modelmogelijkheden"' in lab
assert 'className="ai-lab-run-surface"' in lab
assert 'aria-label="Detection run controls"' in lab
assert 'aria-label="Gebouwdetectie starten"' in lab
assert 'className="ai-lab-state-stack"' in lab
assert 'className="ai-lab-results-surface"' in lab
assert 'aria-label="Detection results"' in lab
assert 'aria-label="Resultaten van de beeldanalyse"' in lab
assert 'className="ai-lab-qa-surface"' in lab
assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
@@ -30,14 +33,14 @@ def test_segmentation_lab_exposes_structured_surfaces() -> None:
assert 'className="workspace-panel ai-lab-shell segmentation-lab-shell"' in lab
assert 'className="ai-lab-model-surface"' in lab
assert 'aria-label="Segmentation model capabilities"' in lab
assert 'aria-label="Technische informatie over segmentatiemodellen"' in lab
assert 'className="ai-lab-run-surface"' in lab
assert 'aria-label="Segmentation run controls"' in lab
assert 'aria-label="Segmentatie starten"' in lab
assert 'className="ai-lab-state-stack"' in lab
assert 'className="ai-lab-results-surface"' in lab
assert 'aria-label="Segmentation results"' in lab
assert 'aria-label="Segmentatieresultaten"' in lab
assert 'className="ai-lab-qa-surface"' in lab
assert 'aria-label="Segmentation QA controls and results"' in lab
assert 'aria-label="Kwaliteitscontrole voor segmentatie"' in lab
def test_ai_lab_preserves_existing_detection_and_segmentation_controls() -> None:
@@ -59,8 +62,8 @@ def test_ai_lab_preserves_existing_detection_and_segmentation_controls() -> None
assert "onLoadResults" in segmentation
assert "onRunQa" in segmentation
assert "selectedSegmentationModelConfigured" in segmentation
assert "Segmentations loaded: {segmentationItems.length}" in segmentation
assert "Compare segmentations to reference" in segmentation
assert "{segmentationItems.length} vlakken geladen" in segmentation
assert "Vergelijk met referentielaag" in segmentation
def test_ai_lab_density_css_contracts() -> None:
@@ -7,28 +7,29 @@ ROOT = Path(__file__).resolve().parents[2]
def test_overview_exposes_end_to_end_workflow_guidance() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
overview = (ROOT / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
assert 'aria-label="V1 workflow guidance"' in app
assert 'className="workflow-guidance-panel"' in app
assert 'className="workflow-guidance-steps"' in app
assert "workflowGuidanceSteps.map" in app
assert "Project & AOI" in app
assert "Data" in app
assert "Map" in app
assert "QA / AI" in app
assert "Export" in app
assert 'aria-label="Voortgang van de werkstroom"' in overview
assert 'className="workflow-guidance-panel"' in overview
assert 'className="workflow-guidance-steps"' in overview
assert "steps.map" in overview
assert "Werkruimte en gebied" in overview
assert "Databronnen" in overview
assert "Kaart" in overview
assert "Controle en analyse" in overview
assert "Downloads" in overview
def test_workflow_guidance_routes_to_existing_workspaces() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
overview = (ROOT / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
assert "target: 'data'" in app
assert "target: 'map'" in app
assert "target: 'analysis'" in app
assert "target: 'exports'" in app
assert "onOpenAiWorkspace={() => setActiveWorkspace('ai')}" in app
assert "onClick={() => openWorkflowGuidanceStep(step.target)}" in app
assert "target: 'data'" in overview
assert "target: 'map'" in overview
assert "target: 'analysis'" in overview
assert "target: 'exports'" in overview
assert "onOpenWorkspace={openWorkflowGuidanceStep}" in app
assert "onClick={() => onOpenWorkspace(step.target)}" in overview
assert "setActiveWorkspace(target)" in app
@@ -44,21 +45,23 @@ def test_workflow_guidance_has_responsive_contracts() -> None:
def test_workflow_guidance_complete_state_and_map_copy_are_precise() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
overview = (ROOT / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
assert "workflowGuidanceComplete" in app
assert "Ready for handoff" in app
assert "layer feature" in app
assert "+ AOI" in app
assert "AOI loaded" in app
assert "workflowComplete" in overview
assert "Klaar om te delen" in overview
assert "objecten op de kaart" in overview
assert "selectedAreaHasGeometry" in overview
assert "Werkgebied ingeladen" in overview
def test_workflow_guidance_map_and_export_steps_reuse_dataset_context() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
overview = (ROOT / "frontend" / "src" / "components" / "overview" / "OverviewWorkspace.tsx").read_text(encoding="utf-8")
assert "openWorkflowGuidanceStep" in app
assert "target === 'map'" in app
assert "openDatasetInMap(availableMapDatasets[0])" in app
assert "target === 'exports'" in app
assert "openDatasetExport(availableMapDatasets[0])" in app
assert "onClick={() => openWorkflowGuidanceStep(step.target)}" in app
assert "onOpenWorkspace={openWorkflowGuidanceStep}" in app
assert "onClick={() => onOpenWorkspace(step.target)}" in overview
@@ -11,7 +11,7 @@ def test_quality_results_panel_exposes_selected_check_drilldown() -> None:
assert "selectedQualityCheckId" in panel
assert "selectedQualityCheck" in panel
assert "QA/QC evidence drilldown" in panel
assert "Details van het kaartbewijs" in panel
assert "Geselecteerde controle" in panel
assert "Te controleren laag" in panel
assert "Referentielaag" in panel
@@ -11,13 +11,13 @@ def test_raster_controls_expose_pipeline_readiness_and_handoff() -> None:
assert "rasterReadinessItems" in raster_controls
assert "rasterGuardrailItems" in raster_controls
assert "Raster pipeline readiness" in raster_controls
assert "Metadata profile" in raster_controls
assert "CRS readiness" in raster_controls
assert "Preview artifact" in raster_controls
assert "Tile manifest handoff" in raster_controls
assert "Clip AOI" in raster_controls
assert "Processing guardrails" in raster_controls
assert "Gereedheid van rasterverwerking" in raster_controls
assert "Bestandsprofiel" in raster_controls
assert "Coördinatenstelsel" in raster_controls
assert "Voorbeeldweergave" in raster_controls
assert "Laatste beeldtegelmanifest" in raster_controls
assert "Werkgebied voor begrenzing" in raster_controls
assert "Veiligheidscontroles" in raster_controls
assert "selectedRasterMetadata?.crs" in raster_controls
assert "rasterPreview ?" in raster_controls
assert "isRasterTileInputValid" in raster_controls
@@ -27,6 +27,6 @@ def test_ai_labs_explain_missing_raster_input_before_disabled_runs() -> None:
assert 'aria-label="Luchtbeeld toevoegen"' in detection_lab
assert "onUploadRaster" in detection_lab
assert "rasterDatasets.length === 0" in detection_lab
assert "No raster datasets available for segmentation." in segmentation_lab
assert "Upload or select a raster dataset in Data before running segmentation." in segmentation_lab
assert "Geen rasterbestand beschikbaar voor segmentatie." in segmentation_lab
assert "Voeg eerst een rasterbestand toe onder Bronnen." in segmentation_lab
assert "rasterDatasets.length === 0" in segmentation_lab
@@ -20,6 +20,6 @@ def test_raster_tile_manifest_is_visible_and_can_handoff_to_detection_lab() -> N
assert "setSelectedDetectionModelId('yolo-configured')" in app
assert "latestRasterTileManifestPath" in detail_panel
assert "onUseTileManifestForDetection" in detail_panel
assert "Latest tile manifest" in raster_controls
assert "Use in Detection Lab" in raster_controls
assert "Laatste beeldtegelmanifest" in raster_controls
assert "Gebruik voor gebouwdetectie" in raster_controls
assert "disabled={!latestRasterTileManifestPath}" in raster_controls
+1
View File
@@ -126,6 +126,7 @@ COPY scripts/build_detection_model_promotion_report.py /app/scripts/build_detect
COPY scripts/build_mol_operational_benchmark_report.py /app/scripts/build_mol_operational_benchmark_report.py
COPY scripts/run_split_background_promotion_workflow.sh /app/scripts/run_split_background_promotion_workflow.sh
COPY scripts/activate_promoted_yolo_candidate.py /app/scripts/activate_promoted_yolo_candidate.py
COPY scripts/archive_technical_projects.py /app/scripts/archive_technical_projects.py
COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf
COPY deploy/unraid/all-in-one-start.sh /usr/local/bin/geointel-all-in-one-start
COPY --from=frontend-build /frontend/dist/ /usr/share/nginx/html/
+13 -5
View File
@@ -90,12 +90,15 @@ Returns enabled feature flags and tool availability.
### GET `/api/v1/projects`
Returns active projects with `limit`/`offset` pagination. Optional exact
`name` filtering supports stable lookup of a canonical operational workspace
without depending on its position among newer operator or benchmark projects:
Returns active projects by default with `limit`/`offset` pagination. The
optional `status` query accepts `active`, `archived` or `all`; deleted projects
are never returned. Optional exact `name` filtering supports stable lookup of
a canonical operational workspace without depending on its position among
newer operator or benchmark projects:
```text
GET /api/v1/projects?name=Kempen%20Regional%20Workbench&limit=1
GET /api/v1/projects?status=archived&limit=50
```
### POST `/api/v1/projects`
@@ -118,11 +121,16 @@ Returns one project with summary counts.
### PATCH `/api/v1/projects/{project_id}`
Updates name/description/region.
Updates name, description, region or the ordinary lifecycle status. The status
can only be `active` or `archived`. Archiving keeps datasets, jobs, analyses,
quality checks and exports intact while removing the workspace from the
default active-project list.
### DELETE `/api/v1/projects/{project_id}`
Soft-delete in V1 preferred. Hard-delete only if storage cleanup is also implemented.
Marks the project as deleted. This route does not remove storage artifacts or
related persistence and is not the ordinary workspace-cleanup path. Operators
and the UI use `PATCH` with `status: "archived"` for reversible cleanup.
## Areas
+27
View File
@@ -9963,3 +9963,30 @@ Final live acceptance:
- Browser acceptance passed at 3440x1440 and 390x844 without horizontal page
overflow. Workspace navigation reset the real scrolling main container and
the browser console contained no warning or error.
## Sprint 234 - Full audit closure and workspace lifecycle cleanup (2026-07-17)
Implemented:
- Closed the remaining P1 project-pollution finding with reversible
`active`/`archived` lifecycle filtering. The default API and frontend load
active workspaces only; archived workspaces and all dependent persistence
remain available.
- Added `scripts/archive_technical_projects.py`. It is dry-run-first, uses a
strict technical-name allowlist and always preserves the canonical Kempen
and Mol workspaces. The all-in-one image packages it and readiness compiles
it.
- Added an explicit archive action for a selected non-canonical workspace.
The UI explains that data and results are retained and protects the two
canonical workspaces.
- Extracted Overview orchestration, Detection model management and pure Map
helpers from the largest frontend components. Shared state and API clients
remain unchanged.
- Reworded remaining visible operator terminology in the Map, Quality,
Detection, Segmentation and dataset controls. Technical UUIDs, file paths,
hashes and raw runtime states remain available only under labelled details.
- Added ultrawide AI-workspace constraints so controls use the available width
without creating multiple narrow nested columns.
Validation evidence is recorded after the complete compile, pytest, typecheck,
build, readiness, migration, deployment, live cleanup and browser acceptance
chain has completed.
+8
View File
@@ -35,6 +35,14 @@ geen open productroadmap meer.
hoogte-, overstromings- of thematische beleidsrasters.
- [x] Draai de volledige applicatie in de beheersbare Unraid-container op poort
1202 met PostGIS, backend, frontend, Ollama-koppeling en modelconfiguratie.
- [x] Archiveer allowlisted benchmark-, kalibratie- en rooktestwerkruimtes
om de actieve projectlijst beheersbaar te houden, zonder gegevens of
resultaten te verwijderen.
- [x] Verberg technische identifiers, modeldetails en interne statussen achter
expliciete technische details en gebruik uniforme Nederlandse taaktaal in
kaart-, kwaliteits-, detectie- en segmentatiewerkstromen.
- [x] Begrens de grootste frontendverantwoordelijkheden met afzonderlijke
overzichts-, modelbeheer- en kaarthelpermodules zonder gedrag te wijzigen.
Bewuste, niet-blokkerende grenzen:
+21
View File
@@ -98,6 +98,27 @@ The workbench uses a task-based shell instead of a single long panel stack. `App
The premium V1 presentation layer lives in `src/styles/premium.css`. It groups navigation by Workspace, Analyze and Deliver, removes the permanent inspector column, gives desktop/ultrawide workspaces stable readable widths and switches narrow screens to full-width content with a horizontally scrollable navigation rail. API calls and workflow state remain owned by the existing hooks.
## Current component boundaries
`App.tsx` remains the shared workspace orchestrator, while focused surfaces
and pure map helpers are kept outside it:
- `components/overview/OverviewWorkspace.tsx` owns status, source freshness,
workflow progress and overview actions.
- `components/detection/DetectionLab.tsx` owns the end-user detection flow;
`components/detection/DetectionModelManagement.tsx` contains the collapsed
operator-only model registry, profiles, assets and runtime diagnostics.
- `components/map/MapWorkspace.tsx` owns the map workflow;
`components/map/mapWorkspaceUtils.ts` owns pure bbox, metric, download and
display helpers.
- `components/project/ProjectPanel.tsx` owns explicit workspace management,
including reversible archiving for non-canonical workspaces.
Normal screens use Dutch task language and friendly labels. UUIDs, file paths,
checksums, raw model states and job terminology stay behind labelled technical
disclosures. Archiving a workspace does not remove its data and the two
canonical regional workspaces cannot be archived through the UI.
Data and Map are organized around the core daily workflow. Data shows Project, AOI and Dataset columns together on normal desktop widths, bounds populated lists inside their own panels and keeps create/upload forms in explicit disclosures. Map keeps the layer/AOI command surface and MapLibre frame first, then exposes provenance, BBox controls and raw feature inspection only when requested. Existing selection, export and QA actions are unchanged.
Wide and ultrawide screens keep a readable sidebar and centered work area, expand the MapLibre review frame and use extra horizontal space for Data, Analysis, AI and Export grids. The detail drawer overlays the work area only while open, so it does not permanently consume ultrawide canvas space.
+36 -190
View File
@@ -10,11 +10,10 @@ import { ExportCenter } from './components/exports/ExportCenter'
import { ExportPreview } from './components/exports/ExportPreview'
import { WorkbenchInspector } from './components/inspector/WorkbenchInspector'
import { MapWorkspace } from './components/map/MapWorkspace'
import { OverviewWorkspace, type WorkspaceKey } from './components/overview/OverviewWorkspace'
import { AreaPanel } from './components/project/AreaPanel'
import { ProjectPanel } from './components/project/ProjectPanel'
import { QualityResultsPanel } from './components/quality/QualityResultsPanel'
import { WorkbenchStatusStrip } from './components/WorkbenchStatusStrip'
import { SourceFreshnessPanel } from './components/status/SourceFreshnessPanel'
import type { DatasetCreateResponse } from './types'
import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
@@ -43,8 +42,6 @@ function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
{ key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' },
{ key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' },
@@ -83,6 +80,7 @@ function App(): JSX.Element {
loadingProjects,
loadingAreas,
loadingDatasets,
archivingProjectId,
errorMessage,
projectForm,
areaForm,
@@ -90,6 +88,7 @@ function App(): JSX.Element {
loadProjectData,
createProject,
createArea,
archiveProject,
resetProjectData,
setSelectedProjectId,
setErrorMessage,
@@ -586,24 +585,24 @@ function App(): JSX.Element {
return 'Detectierun'
}
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
return `${selectedDataset.dataset_type}-dataset`
return selectedDataset.dataset_type === 'raster' ? 'Rasterdatabron' : 'Vectordatabron'
}
return 'Geen actieve gegevens- of analyselaag'
}, [analysisMapLayerActive, changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled])
const mapLayerProvenance = useMemo(() => {
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
return `source ${changeSourceDatasetId || 'n/a'} -> target ${changeTargetDatasetId || 'n/a'}`
return 'Vergelijking van twee bewaarde kaartlagen'
}
if (analysisMapLayerActive && segmentationGeoJson) {
return selectedSegmentationRunId ? `analysis run ${selectedSegmentationRunId}` : 'segmentation results loaded'
return selectedSegmentationRunId ? 'Bewaarde segmentatieronde' : 'Segmentatieresultaten geladen'
}
if (analysisMapLayerActive && detectionGeoJson) {
return selectedDetectionRunId ? `analysis run ${selectedDetectionRunId}` : 'detection results loaded'
return selectedDetectionRunId ? 'Bewaarde detectieronde' : 'Detectieresultaten geladen'
}
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
return `${selectedDataset.dataset_role ?? 'source'} / ${selectedDataset.source_name ?? selectedDataset.source}`
return `Bewaarde ${selectedDataset.dataset_role === 'reference' ? 'referentielaag' : 'databron'}`
}
return 'Open a dataset, detection run, segmentation run or change result to draw it here.'
return 'Open een databron, detectieronde, segmentatieronde of veranderingsresultaat om het hier te tekenen.'
}, [
analysisMapLayerActive,
changeDetectionResult?.geojson,
@@ -658,73 +657,11 @@ function App(): JSX.Element {
}
setActiveWorkspace(target)
}
const hasAnalysisOutput = qualityChecks.length > 0 || Boolean(changeDetectionResult) || detectionItems.length > 0 || segmentationItems.length > 0
const hasMapContext = mapFeatureCount > 0 || areaFeatureCount > 0
const workflowGuidanceComplete = Boolean(selectedProjectId) && datasets.length > 0 && hasMapContext && hasAnalysisOutput && exports.length > 0
const recommendedWorkflowTarget: WorkspaceKey = !selectedProjectId
? 'data'
: datasets.length === 0
? 'data'
: !hasMapContext
? 'map'
: !hasAnalysisOutput
? 'analysis'
: exports.length === 0
? 'exports'
: 'exports'
const workflowGuidanceSteps: Array<{
step: string
title: string
detail: string
status: string
ready: boolean
target: WorkspaceKey
}> = [
{
step: '1',
title: 'Project & AOI',
detail: selectedProjectId ? `${areas.length} AOI record${areas.length === 1 ? '' : 's'} available` : 'Create or load a project context',
status: selectedProjectId ? 'ready' : 'next',
ready: Boolean(selectedProjectId),
target: 'data',
},
{
step: '2',
title: 'Data',
detail: datasets.length > 0 ? `${datasets.length} dataset${datasets.length === 1 ? '' : 's'} loaded` : 'Upload source and reference datasets',
status: datasets.length > 0 ? 'ready' : 'waiting',
ready: datasets.length > 0,
target: 'data',
},
{
step: '3',
title: 'Map',
detail: hasMapContext
? mapFeatureCount > 0
? `${mapFeatureCount} layer feature${mapFeatureCount === 1 ? '' : 's'}${areaFeatureCount > 0 ? ' + AOI' : ''}`
: 'AOI loaded'
: 'Inspect AOI and selected layer',
status: hasMapContext ? 'ready' : 'waiting',
ready: hasMapContext,
target: 'map',
},
{
step: '4',
title: 'QA / AI',
detail: hasAnalysisOutput ? 'QA, change, detection or segmentation output exists' : 'Run checks after data is ready',
status: hasAnalysisOutput ? 'ready' : 'waiting',
ready: hasAnalysisOutput,
target: 'analysis',
},
{
step: '5',
title: 'Export',
detail: exports.length > 0 ? `${exports.length} export artifact${exports.length === 1 ? '' : 's'} recorded` : 'Package validated project outputs',
status: exports.length > 0 ? 'ready' : 'waiting',
ready: exports.length > 0,
target: 'exports',
},
]
const hasAnalysisOutput =
qualityChecks.length > 0
|| Boolean(changeDetectionResult)
|| detectionItems.length > 0
|| segmentationItems.length > 0
const projectContextLabel = selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME
? REGIONAL_WORKSPACE_LABEL
: selectedProject?.name ?? 'Geen werkruimte'
@@ -759,7 +696,7 @@ function App(): JSX.Element {
<p>GeoAI-werkruimte · Mol en de Kempen</p>
</div>
</div>
<div className="context-bar" aria-label="Active workbench context">
<div className="context-bar" aria-label="Actieve werkcontext">
<div>
<span>Werkruimte</span>
<strong title={projectContextLabel}>{projectContextLabel}</strong>
@@ -782,8 +719,8 @@ function App(): JSX.Element {
{errorMessage ? <p className="error">{errorMessage}</p> : null}
<div className="workbench-layout">
<aside className="workbench-sidebar" aria-label="Workbench navigation">
<nav aria-label="Primary workspaces">
<aside className="workbench-sidebar" aria-label="Navigatie van de werkruimte">
<nav aria-label="Hoofdonderdelen">
{workspaceNavGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p className="nav-section-label">{group.label}</p>
@@ -799,7 +736,7 @@ function App(): JSX.Element {
className={item.key === activeWorkspace ? 'nav-item nav-item-active' : 'nav-item'}
onClick={() => setActiveWorkspace(item.key)}
aria-current={item.key === activeWorkspace ? 'page' : undefined}
aria-label={`Open ${item.label} workspace: ${item.description}`}
aria-label={`Open ${item.label}: ${item.description}`}
data-testid={`workspace-nav-${item.key}`}
>
<span>{item.label}</span>
@@ -833,7 +770,7 @@ function App(): JSX.Element {
) : null}
</div>
</div> : null}
<div className="workspace-command-bar" aria-label="Workspace shortcuts">
<div className="workspace-command-bar" aria-label="Snelkoppelingen">
<div className="workspace-nav-cluster">
{workspaceNavItems.slice(0, 5).map((item) => (
<button
@@ -842,7 +779,7 @@ function App(): JSX.Element {
className={item.key === activeWorkspace ? 'command-chip command-chip-active' : 'command-chip'}
onClick={() => setActiveWorkspace(item.key)}
aria-pressed={item.key === activeWorkspace}
aria-label={`Switch to ${item.label} workspace`}
aria-label={`Ga naar ${item.label}`}
>
{item.label}
</button>
@@ -852,112 +789,19 @@ function App(): JSX.Element {
</div>
{activeWorkspace === 'overview' ? (
<div className="workspace-stack">
<WorkbenchStatusStrip
selectedProject={selectedProject}
areas={areas}
datasets={datasets}
qualityChecks={qualityChecks}
exports={exports}
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
/>
<SourceFreshnessPanel
report={sourceFreshness.report}
loading={sourceFreshness.loading}
error={sourceFreshness.error}
onRefresh={() => { void sourceFreshness.refresh() }}
catalogReport={sourceFreshness.catalogReport}
catalogLoading={sourceFreshness.catalogLoading}
catalogError={sourceFreshness.catalogError}
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
grbRefreshPlan={sourceFreshness.grbRefreshPlan}
grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
<strong>{workflowGuidanceComplete ? 'voltooid' : 'optionele stappen open'}</strong>
</summary>
<section className="workflow-guidance-panel" aria-label="V1 workflow guidance">
<div className="panel-title-row">
<div>
<p className="eyebrow">Workbench flow</p>
<h2>Project to export path</h2>
</div>
<span className="status-badge">
{workflowGuidanceComplete
? 'Ready for handoff'
: `Next: ${workspaceNavItems.find((item) => item.key === recommendedWorkflowTarget)?.label ?? 'Overview'}`}
</span>
</div>
<div className="workflow-guidance-steps">
{workflowGuidanceSteps.map((step) => (
<button
key={`${step.step}-${step.title}`}
type="button"
className={
step.target === recommendedWorkflowTarget && !step.ready
? 'workflow-guidance-step workflow-guidance-step-active'
: step.ready
? 'workflow-guidance-step workflow-guidance-step-ready'
: 'workflow-guidance-step'
}
onClick={() => openWorkflowGuidanceStep(step.target)}
aria-label={`Open ${step.title} step`}
>
<span className="workflow-step-status">{step.status}</span>
<strong>
{step.step}. {step.title}
</strong>
<small>{step.detail}</small>
</button>
))}
</div>
</section>
<section className="overview-actions">
<div className="overview-action-copy">
<p className="eyebrow">Quick access</p>
<h2>Continue working</h2>
</div>
<div className="quick-action-grid overview-quick-actions" aria-label="Recommended next workbench actions">
<button
type="button"
className="quick-action-button"
onClick={() => setActiveWorkspace('data')}
aria-label="Open data setup workspace"
>
Open data setup
</button>
<button
type="button"
className="quick-action-button"
onClick={() => setActiveWorkspace('map')}
aria-label="Inspect map workspace"
>
Inspect map
</button>
<button
type="button"
className="quick-action-button"
onClick={() => setActiveWorkspace('analysis')}
aria-label="Review QA/QC workspace"
>
Review QA/QC
</button>
<button
type="button"
className="quick-action-button"
onClick={() => setActiveWorkspace('exports')}
aria-label="Manage exports workspace"
>
Manage exports
</button>
</div>
</section>
</details>
</div>
<OverviewWorkspace
selectedProject={selectedProject}
selectedProjectId={selectedProjectId}
areas={areas}
datasets={datasets}
qualityChecks={qualityChecks}
exports={exports}
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
hasAnalysisOutput={hasAnalysisOutput}
sourceFreshness={sourceFreshness}
onOpenWorkspace={openWorkflowGuidanceStep}
/>
) : null}
{activeWorkspace === 'data' ? (
@@ -967,12 +811,14 @@ function App(): JSX.Element {
projects={projects}
selectedProjectId={selectedProjectId}
loadingProjects={loadingProjects}
archivingProjectId={archivingProjectId}
projectForm={projectForm}
loadingDemoWorkflow={loadingDemoWorkflow}
demoWorkflowMessage={demoWorkflowMessage}
onCreateProject={createProject}
onUpdateProjectForm={setProjectForm}
onSelectProject={selectProject}
onArchiveProject={archiveProject}
onLoadDemoWorkflow={loadDemoWorkflow}
/>
@@ -1287,7 +1133,7 @@ function App(): JSX.Element {
</main>
{inspectorOpen ? (
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Current selection inspector">
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
<WorkbenchInspector
selectedProject={selectedProject}
selectedArea={selectedArea}
@@ -40,20 +40,20 @@ export function ChangeDetectionPanel({
<section className="panel change-detection-shell">
<div className="panel-header change-detection-heading">
<div>
<p className="eyebrow">Analysis</p>
<h2>Change Detection</h2>
<p className="eyebrow">Historische analyse</p>
<h2>Veranderingen vergelijken</h2>
</div>
<button disabled={running || vectorDatasets.length < 2} onClick={onRun} type="button">
{running ? 'Comparing...' : 'Compare vectors'}
{running ? 'Vergelijken...' : 'Kaartlagen vergelijken'}
</button>
</div>
<div className="change-detection-input-surface" aria-label="Change detection input controls">
<div className="change-detection-input-surface" aria-label="Instellingen voor veranderingsanalyse">
<div className="form-grid">
<label>
Source vector
Eerdere kaartlaag
<select value={sourceDatasetId} onChange={(event) => onSourceDatasetChange(event.target.value)}>
<option value="">Select source</option>
<option value="">Kies de eerdere kaartlaag</option>
{vectorDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{datasetLabel(dataset)}
@@ -62,9 +62,9 @@ export function ChangeDetectionPanel({
</select>
</label>
<label>
Target vector
Latere kaartlaag
<select value={targetDatasetId} onChange={(event) => onTargetDatasetChange(event.target.value)}>
<option value="">Select target</option>
<option value="">Kies de latere kaartlaag</option>
{vectorDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{datasetLabel(dataset)}
@@ -73,7 +73,7 @@ export function ChangeDetectionPanel({
</select>
</label>
<label>
IoU threshold
Minimale geometrische overlap
<input
max="1"
min="0"
@@ -89,7 +89,7 @@ export function ChangeDetectionPanel({
type="checkbox"
onChange={(event) => onIncludeUnchangedChange(event.target.checked)}
/>
Include unchanged geometry
Ongewijzigde objecten opnemen
</label>
</div>
</div>
@@ -97,36 +97,36 @@ export function ChangeDetectionPanel({
<div className="change-detection-state-stack">
{error ? (
<div className="result-state result-state-error">
<strong>Change detection failed.</strong>
<strong>De veranderingsanalyse is mislukt.</strong>
<p>{error}</p>
</div>
) : null}
{!error && vectorDatasets.length < 2 ? (
<div className="result-state result-state-empty">
<strong>Not enough vector datasets</strong>
<p>Upload at least two vector datasets to compare.</p>
<strong>Onvoldoende vergelijkbare kaartlagen</strong>
<p>Voeg minstens twee vectorlagen toe om periodes te vergelijken.</p>
</div>
) : null}
</div>
{result ? (
<div className="change-detection-result-surface" aria-label="Change detection result summary">
<div className="change-detection-result-surface" aria-label="Samenvatting veranderingsanalyse">
<div className="summary-grid">
<div>
<span className="metric">{result.added_count}</span>
<span>Added</span>
<span>Nieuw</span>
</div>
<div>
<span className="metric">{result.removed_count}</span>
<span>Removed</span>
<span>Verdwenen</span>
</div>
<div>
<span className="metric">{result.unchanged_count}</span>
<span>Unchanged</span>
<span>Ongewijzigd</span>
</div>
<div>
<span className="metric">{result.geojson.features.length}</span>
<span>Map features</span>
<span>Kaartobjecten</span>
</div>
</div>
</div>
@@ -134,7 +134,7 @@ export function ChangeDetectionPanel({
{result?.warnings.length ? (
<div className="change-detection-warning-surface">
<strong>Warnings</strong>
<strong>Aandachtspunten</strong>
<ul className="compact-list">
{result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
@@ -73,7 +73,7 @@ export interface DatasetDetailPanelProps {
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n/a'
return 'n.v.t.'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
@@ -87,11 +87,11 @@ function formatBytes(value: number | null | undefined): string {
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n/a'
return 'n.v.t.'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n/a'
return 'n.v.t.'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
@@ -157,8 +157,8 @@ export function DatasetDetailPanel({
}: DatasetDetailPanelProps) {
return (
<section className="dataset-detail-panel">
<h2>Dataset details</h2>
{selectedDatasetId ? <p>Selected dataset: {selectedDatasetId}</p> : <p>No dataset selected</p>}
<h2>Details van de databron</h2>
{!selectedDatasetId ? <p>Geen databron geselecteerd.</p> : null}
{selectedDataset ? (
<div>
<p>
@@ -166,13 +166,19 @@ export function DatasetDetailPanel({
</p>
<p>Type: {selectedDataset.dataset_type}</p>
<p>Status: {selectedDataset.status}</p>
<p>Original file: {selectedDataset.original_filename ?? 'n/a'}</p>
<p>Stored file: {selectedDataset.stored_filename ?? 'n/a'}</p>
<p>Content type: {selectedDataset.content_type ?? 'n/a'}</p>
<p>File size: {formatBytes(selectedDataset.size_bytes)}</p>
<p>SHA256: {selectedDataset.checksum_sha256 ?? 'n/a'}</p>
<p>Feature count: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n/a'}</p>
<p>BBox: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
<p>Aantal objecten: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n.v.t.'}</p>
<p>Ruimtelijke begrenzing: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
<details className="technical-inline-details">
<summary>Technische bestandsgegevens</summary>
<div className="entity-meta">
<span>Dataset-ID: {selectedDataset.id}</span>
<span>Oorspronkelijk bestand: {selectedDataset.original_filename ?? 'n.v.t.'}</span>
<span>Bewaard bestand: {selectedDataset.stored_filename ?? 'n.v.t.'}</span>
<span>Inhoudstype: {selectedDataset.content_type ?? 'n.v.t.'}</span>
<span>Bestandsgrootte: {formatBytes(selectedDataset.size_bytes)}</span>
<span>SHA-256: {selectedDataset.checksum_sha256 ?? 'n.v.t.'}</span>
</div>
</details>
{selectedDataset.dataset_type === 'raster' ? (
<RasterControls
areas={areas}
@@ -236,8 +242,9 @@ export function DatasetDetailPanel({
/>
) : null}
<h3>Jobs</h3>
{jobs.length === 0 ? <p>No jobs yet.</p> : null}
<details className="technical-inline-details">
<summary>Technische verwerking ({jobs.length})</summary>
{jobs.length === 0 ? <p>Nog geen verwerkingen.</p> : null}
<ul>
{jobs.map((job) => (
<li key={job.id}>
@@ -247,19 +254,20 @@ export function DatasetDetailPanel({
{job.result_json ? (
<pre className="job-result">{JSON.stringify(job.result_json, null, 2)}</pre>
) : null}
{job.error_message ? <div className="error">error: {job.error_message}</div> : null}
{job.error_message ? <div className="error">Fout: {job.error_message}</div> : null}
{job.result_json?.output_dataset_id ? (
<button type="button" onClick={() => onPickDerivedDataset(String(job.result_json?.output_dataset_id))}>
open derived dataset
Open afgeleide databron
</button>
) : null}
</li>
))}
</ul>
</details>
</div>
) : null}
{loadingDatasetDetails ? <p>Loading dataset details...</p> : null}
{datasetDetailError ? <p className="error">Dataset detail error: {datasetDetailError}</p> : null}
{loadingDatasetDetails ? <p>Details van de databron laden...</p> : null}
{datasetDetailError ? <p className="error">De details konden niet worden geladen: {datasetDetailError}</p> : null}
</section>
)
}
@@ -32,7 +32,7 @@ interface DatasetPanelProps {
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n/a'
return 'n.v.t.'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
@@ -46,11 +46,11 @@ function formatBytes(value: number | null | undefined): string {
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n/a'
return 'n.v.t.'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n/a'
return 'n.v.t.'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
@@ -49,7 +49,7 @@ interface RasterControlsProps {
function formatRasterBounds(bounds: number[] | undefined | null): string {
if (!bounds || bounds.length < 4) {
return 'n/a'
return 'n.v.t.'
}
const [minX, minY, maxX, maxY] = bounds
return `${minX.toFixed(4)}, ${minY.toFixed(4)} -> ${maxX.toFixed(4)}, ${maxY.toFixed(4)}`
@@ -101,57 +101,57 @@ export function RasterControls({
onRunRasterNdwi,
onRunRasterNdbi,
}: RasterControlsProps) {
const previewState = rasterPreview ? 'ready' : 'not generated'
const previewState = rasterPreview ? 'gereed' : 'nog niet aangemaakt'
const rasterReadinessItems = [
{
label: 'Metadata profile',
state: selectedRasterMetadata ? 'ready' : 'inspect needed',
label: 'Bestandsprofiel',
state: selectedRasterMetadata ? 'gereed' : 'controle nodig',
detail: selectedRasterMetadata
? `${selectedRasterMetadata.driver} | ${selectedRasterMetadata.width} x ${selectedRasterMetadata.height} | ${selectedRasterMetadata.band_count} bands`
: 'Run metadata inspection before derived raster operations.',
? `${selectedRasterMetadata.driver} | ${selectedRasterMetadata.width} x ${selectedRasterMetadata.height} | ${selectedRasterMetadata.band_count} banden`
: 'Controleer eerst de metadata voordat je afgeleide rasters maakt.',
},
{
label: 'CRS readiness',
state: selectedRasterMetadata?.crs ? 'ready' : 'missing',
detail: selectedRasterMetadata?.crs ?? 'Raster CRS is required for safe map handoff and clipping.',
label: 'Coördinatenstelsel',
state: selectedRasterMetadata?.crs ? 'gereed' : 'ontbreekt',
detail: selectedRasterMetadata?.crs ?? 'Een coördinatenstelsel is vereist om veilig te begrenzen en op de kaart te tonen.',
},
{
label: 'Preview artifact',
label: 'Voorbeeldweergave',
state: previewState,
detail: rasterPreview
? `${rasterPreview.preview.path} (${rasterPreview.preview.width ?? 'n/a'} x ${rasterPreview.preview.height ?? 'n/a'})`
: 'Generate a preview to confirm visual orientation before AI runs.',
? `${rasterPreview.preview.width ?? 'n.v.t.'} x ${rasterPreview.preview.height ?? 'n.v.t.'} pixels`
: 'Maak een voorbeeld om de oriëntatie te controleren vóór beeldanalyse.',
},
{
label: 'Tile manifest handoff',
state: isRasterTileInputValid ? 'input valid' : 'input blocked',
label: 'Beeldtegels',
state: isRasterTileInputValid ? 'instellingen geldig' : 'instellingen geblokkeerd',
detail: isRasterTileInputValid
? 'Tile generation will create the manifest path used by detection and segmentation requests.'
: 'Tile size must be positive and overlap must stay below tile size.',
? 'GeoIntel kan een manifest maken voor detectie en segmentatie.'
: 'De tegelgrootte moet positief zijn en de overlap moet kleiner blijven.',
},
{
label: 'Clip AOI',
state: areas.length > 0 ? 'available' : 'missing',
detail: areas.length > 0 ? `${areas.length} project area${areas.length === 1 ? '' : 's'} available.` : 'Create an area before clipping.',
label: 'Werkgebied voor begrenzing',
state: areas.length > 0 ? 'beschikbaar' : 'ontbreekt',
detail: areas.length > 0 ? `${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'} beschikbaar.` : 'Maak eerst een gebied aan.',
},
]
const rasterGuardrailItems = [
!selectedDatasetId ? 'Select a raster dataset before running raster operations.' : null,
rasterUnavailableMessage ? `Raster unavailable: ${rasterUnavailableMessage}` : null,
selectedDatasetId && !selectedRasterMetadata ? 'Inspect metadata before reprojecting, clipping, tiling or computing indices.' : null,
selectedRasterMetadata && !selectedRasterMetadata?.crs ? 'CRS is missing; geospatial handoff should be fixed before downstream QA.' : null,
!rasterPreview ? 'Preview is not generated yet; create it before visual review.' : null,
!isRasterTileInputValid ? 'Tile settings are invalid; update tile size and overlap before generating a manifest.' : null,
areas.length === 0 ? 'No project area exists yet, so raster clipping is disabled.' : null,
!selectedDatasetId ? 'Kies eerst een rasterbestand.' : null,
rasterUnavailableMessage ? `Raster niet beschikbaar: ${rasterUnavailableMessage}` : null,
selectedDatasetId && !selectedRasterMetadata ? 'Controleer de metadata vóór herprojectie, begrenzing, tegels of indexberekening.' : null,
selectedRasterMetadata && !selectedRasterMetadata?.crs ? 'Het coördinatenstelsel ontbreekt; herstel dit vóór verdere ruimtelijke analyse.' : null,
!rasterPreview ? 'Er is nog geen voorbeeldweergave gemaakt.' : null,
!isRasterTileInputValid ? 'De tegelinstellingen zijn ongeldig.' : null,
areas.length === 0 ? 'Er bestaat nog geen werkgebied; begrenzen is daarom uitgeschakeld.' : null,
].filter((item): item is string => Boolean(item))
return (
<div className="dataset-tool-panel raster-tool-panel">
<section className="raster-readiness-surface" aria-label="Raster pipeline readiness">
<section className="raster-readiness-surface" aria-label="Gereedheid van rasterverwerking">
<div className="panel-title-row">
<div>
<h3>Raster pipeline readiness</h3>
<p>Operational state for metadata, preview, clipping and tile-manifest handoff.</p>
<h3>Gereedheid van het raster</h3>
<p>Status van metadata, voorbeeldweergave, begrenzing en beeldtegels.</p>
</div>
</div>
<div className="raster-readiness-grid">
@@ -164,54 +164,54 @@ export function RasterControls({
))}
</div>
<div className="raster-manifest-handoff">
<span>Latest tile manifest</span>
<span>Laatste beeldtegelmanifest</span>
<p>
{latestRasterTileManifestPath ||
'No tile manifest generated yet. Generate tiles before handing this raster to Detection Lab.'}
'Nog geen beeldtegels gemaakt. Maak beeldtegels voordat je dit raster gebruikt voor beeldanalyse.'}
</p>
{latestRasterTileManifest ? (
<dl className="raster-manifest-details">
<div>
<dt>Tile count</dt>
<dd>{latestRasterTileManifest.count ?? 'n/a'}</dd>
<dt>Aantal tegels</dt>
<dd>{latestRasterTileManifest.count ?? 'n.v.t.'}</dd>
</div>
<div>
<dt>Tile size</dt>
<dd>{latestRasterTileManifest.tile_size ?? 'n/a'}</dd>
<dt>Tegelgrootte</dt>
<dd>{latestRasterTileManifest.tile_size ?? 'n.v.t.'}</dd>
</div>
<div>
<dt>Overlap</dt>
<dd>{latestRasterTileManifest.overlap ?? 'n/a'}</dd>
<dd>{latestRasterTileManifest.overlap ?? 'n.v.t.'}</dd>
</div>
<div>
<dt>Tile set</dt>
<dd>{latestRasterTileManifest.tile_set_id ?? 'n/a'}</dd>
<dt>Tegelset</dt>
<dd>{latestRasterTileManifest.tile_set_id ?? 'n.v.t.'}</dd>
</div>
</dl>
) : null}
<button
type="button"
aria-label="Use in Detection Lab with current manifest"
aria-label="Gebruik huidig manifest voor gebouwdetectie"
onClick={onUseTileManifestForDetection}
disabled={!latestRasterTileManifestPath}
>
Use manifest in Detection Lab
Gebruik voor gebouwdetectie
</button>
<button
type="button"
aria-label="Use in Segmentation Lab with current manifest"
aria-label="Gebruik huidig manifest voor segmentatie"
onClick={onUseTileManifestForSegmentation}
disabled={!latestRasterTileManifestPath}
>
Use manifest in Segmentation Lab
Gebruik voor segmentatie
</button>
</div>
</section>
<section className="raster-guardrail-surface" aria-label="Processing guardrails">
<section className="raster-guardrail-surface" aria-label="Veiligheidscontroles">
<div className="panel-title-row">
<div>
<h3>Processing guardrails</h3>
<p>Checks that protect downstream GIS and AI operations from ambiguous raster state.</p>
<h3>Veiligheidscontroles</h3>
<p>Controles die onduidelijke of ruimtelijk onveilige verwerking voorkomen.</p>
</div>
</div>
{rasterGuardrailItems.length > 0 ? (
@@ -221,83 +221,83 @@ export function RasterControls({
))}
</ul>
) : (
<p className="raster-readiness-state">Raster controls are ready for safe operation.</p>
<p className="raster-readiness-state">Het raster is gereed voor veilige verwerking.</p>
)}
</section>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Raster metadata</h4>
<p>Raster driver: {selectedRasterMetadata?.driver ?? 'n/a'}</p>
<p>Raster size: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n/a'}</p>
<p>Raster checksum: {selectedRasterMetadata?.checksum_sha256 ?? 'n/a'}</p>
<h4 className="dataset-tool-heading">Rastermetadata</h4>
<p>Bestandsdriver: {selectedRasterMetadata?.driver ?? 'n.v.t.'}</p>
<p>Afmetingen: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n.v.t.'}</p>
<p>Controlesom: {selectedRasterMetadata?.checksum_sha256 ?? 'n.v.t.'}</p>
<p>
Profile: CRS {selectedRasterMetadata?.crs ?? 'n/a'} | bands {selectedRasterMetadata?.band_count ?? 'n/a'} | dtype {
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n/a'}
Profiel: CRS {selectedRasterMetadata?.crs ?? 'n.v.t.'} | banden {selectedRasterMetadata?.band_count ?? 'n.v.t.'} | datatype {
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n.v.t.'}
</p>
<p>Bounds: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
<p>Resolution: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n/a'}</p>
{rasterUnavailableMessage ? <p className="error">Raster unavailable: {rasterUnavailableMessage}</p> : null}
<p>Begrenzing: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
<p>Resolutie: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n.v.t.'}</p>
{rasterUnavailableMessage ? <p className="error">Raster niet beschikbaar: {rasterUnavailableMessage}</p> : null}
</div>
<h3>Raster operations</h3>
<p>Available operations: inspect, stats, reproject, preview, clip by selected area, tile generation.</p>
<p>Preview: {rasterPreview?.preview.path ?? 'not generated'}</p>
<p>Preview size: {rasterPreview?.preview.width ?? 'n/a'} x {rasterPreview?.preview.height ?? 'n/a'}</p>
<h3>Rasterbewerkingen</h3>
<p>Controleer metadata en statistieken, herprojecteer, begrens of maak beeldtegels.</p>
<p>Voorbeeld: {rasterPreview ? 'aangemaakt' : 'nog niet aangemaakt'}</p>
<p>Afmetingen voorbeeld: {rasterPreview?.preview.width ?? 'n.v.t.'} x {rasterPreview?.preview.height ?? 'n.v.t.'}</p>
<button type="button" onClick={onRunRasterInspect}>
Inspect raster metadata
Metadata controleren
</button>
<button type="button" onClick={onRunRasterPreview} disabled={!selectedDatasetId}>
Generate preview
Voorbeeld maken
</button>
<button type="button" onClick={onRunRasterStats}>
Compute band statistics
Bandstatistieken berekenen
</button>
{selectedRasterStats ? (
<div>
<h4>Band statistics</h4>
<p>Generated: {selectedRasterStats.generated_at ?? 'n/a'}</p>
<h4>Bandstatistieken</h4>
<p>Aangemaakt: {selectedRasterStats.generated_at ?? 'n.v.t.'}</p>
<ul>
{selectedRasterStats.bands.map((band) => (
<li key={band.band_index}>
Band {band.band_index}: min {band.min ?? 'n/a'}, max {band.max ?? 'n/a'}, mean {band.mean ?? 'n/a'}, std {band.std ?? 'n/a'},
valid {band.valid_pixel_count}, nodata ratio {(band.nodata_ratio * 100).toFixed(2)}%, dtype {band.dtype ?? 'n/a'}
Band {band.band_index}: minimum {band.min ?? 'n.v.t.'}, maximum {band.max ?? 'n.v.t.'}, gemiddelde {band.mean ?? 'n.v.t.'}, standaardafwijking {band.std ?? 'n.v.t.'},
geldig {band.valid_pixel_count}, aandeel zonder data {(band.nodata_ratio * 100).toFixed(2)}%, datatype {band.dtype ?? 'n.v.t.'}
</li>
))}
</ul>
</div>
) : null}
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Reproject raster</h4>
<p className="dataset-tool-helper">Create a derived raster in a target CRS with the selected resampling method.</p>
<h4 className="dataset-tool-heading">Raster herprojecteren</h4>
<p className="dataset-tool-helper">Maak een afgeleid raster in een ander coördinatenstelsel.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Reproject CRS</span>
<span className="dataset-tool-label">Doel-CRS</span>
<input
value={rasterReprojectCrs}
onChange={(event) => onSetRasterReprojectCrs(event.target.value)}
placeholder="EPSG:31370"
/>
<span className="dataset-tool-helper">Target CRS for the derived raster artifact.</span>
<span className="dataset-tool-helper">Coördinatenstelsel van het afgeleide raster.</span>
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Resampling</span>
<span className="dataset-tool-label">Herbemonstering</span>
<select value={rasterReprojectResampling} onChange={(event) => onSetRasterReprojectResampling(event.target.value)}>
<option value="nearest">nearest</option>
<option value="bilinear">bilinear</option>
<option value="cubic">cubic</option>
</select>
<span className="dataset-tool-helper">Nearest preserves classes; bilinear/cubic smooth continuous rasters.</span>
<span className="dataset-tool-helper">Dichtstbijzijnd behoudt klassen; bilineair en kubisch verzachten continue rasters.</span>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterReproject}>
Reproject raster
Raster herprojecteren
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Clip raster</h4>
<p className="dataset-tool-helper">Clip the selected raster to an existing project area.</p>
<h4 className="dataset-tool-heading">Raster begrenzen</h4>
<p className="dataset-tool-helper">Beperk het raster tot een bestaand werkgebied.</p>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Clip area</span>
<span className="dataset-tool-label">Werkgebied</span>
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
@@ -308,24 +308,24 @@ export function RasterControls({
</label>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterClip} disabled={areas.length === 0}>
Clip raster by area
Raster begrenzen
</button>
</div>
{areas.length === 0 ? <p className="dataset-tool-error">Create an area before raster clipping.</p> : null}
{areas.length === 0 ? <p className="dataset-tool-error">Maak eerst een werkgebied aan.</p> : null}
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Generate tiles</h4>
<p className="dataset-tool-helper">Create a tile manifest for downstream detection or segmentation runs.</p>
<h4 className="dataset-tool-heading">Beeldtegels maken</h4>
<p className="dataset-tool-helper">Maak beeldtegels voor detectie of segmentatie.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Tile size</span>
<span className="dataset-tool-label">Tegelgrootte</span>
<input
type="number"
min={1}
value={rasterTileSize}
onChange={(event) => onSetRasterTileSize(Number(event.target.value))}
/>
<span className="dataset-tool-helper">{'Tile size must be > 0.'}</span>
<span className="dataset-tool-helper">De tegelgrootte moet groter zijn dan nul.</span>
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Overlap</span>
@@ -335,86 +335,86 @@ export function RasterControls({
value={rasterTileOverlap}
onChange={(event) => onSetRasterTileOverlap(Number(event.target.value))}
/>
<span className="dataset-tool-helper">Overlap must be smaller than tile size.</span>
<span className="dataset-tool-helper">De overlap moet kleiner zijn dan de tegelgrootte.</span>
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Tile basename</span>
<span className="dataset-tool-label">Bestandsnaamvoorvoegsel</span>
<input
value={rasterTileOutputName}
onChange={(event) => onSetRasterTileOutputName(event.target.value)}
placeholder="optional"
placeholder="optioneel"
/>
<span className="dataset-tool-helper">Optional artifact name prefix for generated tiles.</span>
<span className="dataset-tool-helper">Optioneel voorvoegsel voor de aangemaakte tegels.</span>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterTile} disabled={!isRasterTileInputValid}>
Generate tiles
Beeldtegels maken
</button>
</div>
{!isRasterTileInputValid ? (
<p className="dataset-tool-error">
Tile size must be {'>'} 0 and overlap must be {'>='} 0 and smaller than tile size.
De tegelgrootte moet groter zijn dan nul en de overlap moet positief en kleiner zijn dan de tegelgrootte.
</p>
) : null}
</div>
<h4>Spectral indices</h4>
<h4>Spectrale indexen</h4>
<div>
<p>Use available band indexes from the raster file (1-based).</p>
<p>Gebruik de beschikbare bandnummers uit het rasterbestand, beginnend bij 1.</p>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">NDVI</h4>
<p className="dataset-tool-helper">Vegetation index from NIR and red bands.</p>
<p className="dataset-tool-helper">Vegetatie-index op basis van nabij-infrarood en rood.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">NIR band</span>
<span className="dataset-tool-label">NIR-band</span>
<input type="number" min={1} value={ndviNirBand} onChange={(event) => onSetNdviNirBand(Number(event.target.value))} />
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Red band</span>
<span className="dataset-tool-label">Rode band</span>
<input type="number" min={1} value={ndviRedBand} onChange={(event) => onSetNdviRedBand(Number(event.target.value))} />
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterNdvi}>
Compute NDVI
NDVI berekenen
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">NDWI</h4>
<p className="dataset-tool-helper">Water index from NIR and green bands.</p>
<p className="dataset-tool-helper">Waterindex op basis van nabij-infrarood en groen.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">NIR band</span>
<span className="dataset-tool-label">NIR-band</span>
<input type="number" min={1} value={ndwiNirBand} onChange={(event) => onSetNdwiNirBand(Number(event.target.value))} />
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">Green band</span>
<span className="dataset-tool-label">Groene band</span>
<input type="number" min={1} value={ndwiGreenBand} onChange={(event) => onSetNdwiGreenBand(Number(event.target.value))} />
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterNdwi}>
Compute NDWI
NDWI berekenen
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">NDBI</h4>
<p className="dataset-tool-helper">Built-up index from SWIR and NIR bands.</p>
<p className="dataset-tool-helper">Bebouwingsindex op basis van kortgolvig en nabij-infrarood.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">SWIR band</span>
<span className="dataset-tool-label">SWIR-band</span>
<input type="number" min={1} value={ndbiSwirBand} onChange={(event) => onSetNdbiSwirBand(Number(event.target.value))} />
</label>
<label className="dataset-tool-field">
<span className="dataset-tool-label">NIR band</span>
<span className="dataset-tool-label">NIR-band</span>
<input type="number" min={1} value={ndbiNirBand} onChange={(event) => onSetNdbiNirBand(Number(event.target.value))} />
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunRasterNdbi}>
Compute NDBI
NDBI berekenen
</button>
</div>
</div>
@@ -25,13 +25,13 @@ export function VectorControls({
}: VectorControlsProps) {
return (
<div className="dataset-tool-panel vector-tool-panel">
<h3>Vector operations</h3>
<h3>Vectorbewerkingen</h3>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Clip vector</h4>
<p className="dataset-tool-helper">Clip features to the selected project area.</p>
<h4 className="dataset-tool-heading">Begrenzen tot gebied</h4>
<p className="dataset-tool-helper">Beperk objecten tot het gekozen werkgebied.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Clip area</span>
<span className="dataset-tool-label">Werkgebied</span>
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
@@ -43,39 +43,39 @@ export function VectorControls({
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunVectorClip} disabled={areas.length === 0}>
Run clip
Begrenzen
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Buffer vector</h4>
<p className="dataset-tool-helper">Create a 25m buffer using the existing vector operation defaults.</p>
<h4 className="dataset-tool-heading">Invloedszone</h4>
<p className="dataset-tool-helper">Maak een zone van 25 meter rond ieder object.</p>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunVectorBuffer}>
Run buffer (25m)
Zone van 25 m maken
</button>
</div>
</div>
<div className="dataset-tool-group">
<h4 className="dataset-tool-heading">Intersect vector</h4>
<p className="dataset-tool-helper">Intersect with another persisted vector dataset.</p>
<h4 className="dataset-tool-heading">Lagen doorsnijden</h4>
<p className="dataset-tool-helper">Bereken de overlap met een andere bewaarde vectorlaag.</p>
<div className="dataset-tool-grid">
<label className="dataset-tool-field">
<span className="dataset-tool-label">Intersect target</span>
<span className="dataset-tool-label">Tweede kaartlaag</span>
<select value={selectedIntersectTargetId} onChange={(event) => onSetSelectedIntersectTargetId(event.target.value)}>
<option value="">auto first vector</option>
<option value="">Automatisch de eerste geschikte laag</option>
{availableVectorTargets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
<span className="dataset-tool-helper">Leave automatic to use the first available vector target.</span>
<span className="dataset-tool-helper">Laat automatisch staan om de eerste beschikbare vectorlaag te gebruiken.</span>
</label>
</div>
<div className="dataset-tool-action-row">
<button type="button" onClick={onRunVectorIntersect}>
Run intersect
Overlap berekenen
</button>
</div>
</div>
@@ -12,17 +12,11 @@ import type {
} from '../../types'
import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../hooks/useDetectionWorkflow'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
function detectionModelLabel(model: DetectionModelCapability): string {
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
return model.display_name
}
function detectionQualityInterpretation(f1: number | null | undefined): string {
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
@@ -31,6 +25,18 @@ function detectionQualityInterpretation(f1: number | null | undefined): string {
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
}
function formatDetectionRunLabel(run: DetectionRunRead): string {
const status = run.status === 'completed' ? 'afgerond' : run.status
const timestamp = run.finished_at ?? run.created_at
const dateLabel = timestamp
? new Intl.DateTimeFormat('nl-BE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(timestamp))
: 'datum onbekend'
return `${run.model_name || 'Gebouwdetectie'} · ${status} · ${dateLabel}`
}
interface CalibrationRow {
analysisRunId: string
qualityCheckId: string
@@ -284,255 +290,29 @@ export function DetectionLab({
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
</p>
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
<summary>
<span>Technische modelinformatie</span>
<strong>{detectionModels.length} registraties</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
{loadingDetectionModels ? (
<div className="result-state result-state-loading">
<strong>Loading detection models.</strong>
<p>Checking backend model registry availability.</p>
</div>
) : null}
{detectionModelError ? (
<div className="result-state result-state-error">
<strong>Detection model registry unavailable.</strong>
<p>{detectionModelError}</p>
</div>
) : null}
{modelAssetError ? (
<div className="result-state result-state-error">
<strong>Local model assets unavailable.</strong>
<p>{modelAssetError}</p>
</div>
) : null}
{detectionModels.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>No detection models reported by backend.</strong>
<p>Refresh models after the backend is reachable.</p>
</div>
) : null}
</div>
<ul className="model-list">
{detectionModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{detectionModelLabel(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div className="entity-meta">
<span>{model.model_id}</span>
<span>{model.framework}</span>
<span>{model.task_type}</span>
</div>
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
<p className="muted">{model.limitation_message}</p>
</li>
))}
</ul>
</div>
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<details className="ai-lab-model-surface" aria-label="Local model asset selection">
<summary>
<span>Modelkeuze voor beheerders</span>
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Lokaal modelbestand</h3>
<p>GeoIntel kiest automatisch het actieve lokale model. Een beheerder kan hier bewust een ander reeds aanwezig, alleen-lezen modelbestand kiezen.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="model-asset-guidance">
<strong>Gevalideerde profielen</strong>
<p>
Een profiel koppelt een lokaal model aan een gemeten zekerheidsdrempel. Een andere keuze geldt alleen voor de huidige analyse en wijzigt de serverconfiguratie niet.
</p>
</div>
<div className="operator-profile-grid" aria-label="Configured YOLO operator profiles">
{DETECTION_OPERATOR_PROFILES.map((profile) => {
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
const profileSelected =
selectedModelAssetId === profile.modelAssetId &&
Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001
return (
<div
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
key={profile.id}
>
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat · extra controle vereist'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
<span>precision {profile.precision.toFixed(3)}</span>
<span>recall {profile.recall.toFixed(3)}</span>
<span>F1 {profile.f1.toFixed(3)}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
</div>
<div className="entity-meta">
<span>modelbestand: {profile.modelAssetId}</span>
<span>beoordeling: {profile.promotionRecommendation}</span>
<span>beschikbaar: {profileAsset ? 'ja' : 'niet gekoppeld'}</span>
</div>
<p className="field-guidance">{profile.limitationMessage}</p>
<button
className="secondary-action"
type="button"
onClick={() => onApplyOperatorProfile(profile)}
disabled={!profileAsset}
>
Profiel gebruiken
</button>
</div>
)
})}
</div>
{selectedModelAsset ? (
<div className="model-asset-guidance">
<strong>Status gekozen model</strong>
<p>
{selectedModelAsset.display_name} wordt voor deze analyse gebruikt. De standaard serverconfiguratie blijft ongewijzigd.
</p>
</div>
) : null}
<label>
Lokaal modelbestand
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Kies een lokaal modelbestand</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(active)' : ''}
</option>
))}
</select>
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
</div>
) : null}
{selectedModelAsset ? (
<div className="result-summary-card">
<p>File: {selectedModelAsset.filename}</p>
<p>Status: {selectedModelAsset.status}</p>
<p>Active runtime env model: {selectedModelAsset.active ? 'yes' : 'no'}</p>
<p>will_download_models: {selectedModelAsset.will_download_models ? 'yes' : 'no'}</p>
<p>Size: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Path: {selectedModelAsset.model_path}</p>
<p>{selectedModelAsset.limitation_message}</p>
</div>
) : null}
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
<summary>
<span>Technische runtimecontrole</span>
<strong>{yoloRuntimeReady ? 'gereed' : yoloPreflight?.status ?? 'niet geladen'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>YOLO runtime preflight</h3>
<p>Read-only runtime status. This does not load a model, run inference or download weights.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
Refresh preflight
</button>
</div>
<div className="ai-lab-state-stack">
{loadingYoloPreflight ? (
<div className="result-state result-state-loading">
<strong>Loading YOLO preflight.</strong>
<p>Checking backend runtime configuration and optional dependency visibility.</p>
</div>
) : null}
{yoloPreflightError ? (
<div className="result-state result-state-error">
<strong>YOLO preflight unavailable.</strong>
<p>{yoloPreflightError}</p>
</div>
) : null}
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
<div className="result-state result-state-empty">
<strong>No YOLO preflight loaded.</strong>
<p>Refresh preflight to inspect the live backend AI runtime before running configured YOLO.</p>
</div>
) : null}
</div>
{yoloPreflight ? (
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
<div className="ai-lab-section-header">
<div>
<h3>Status: {yoloPreflight.status}</h3>
<p>{yoloPreflight.message}</p>
</div>
<span className={yoloPreflight.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{yoloPreflight.checks.dependencies_available ? 'dependencies visible' : 'not ready'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>YOLO enabled</span>
<strong>{yoloPreflight.checks.enabled ? 'true' : 'false'}</strong>
</div>
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Dependencies</span>
<strong>{yoloPreflight.checks.dependencies_available === true ? 'available' : yoloPreflight.checks.dependencies_available === false ? 'unavailable' : 'not checked'}</strong>
</div>
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Local model file</span>
<strong>{yoloPreflight.checks.model_file_exists === true ? 'found' : yoloPreflight.checks.model_path_set ? 'missing' : 'not configured'}</strong>
</div>
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>CUDA</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'available' : yoloPreflight.runtime.cuda_available === false ? 'not available' : 'not checked'}</strong>
</div>
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile manifest validation</span>
<strong>{yoloPreflight.checks.manifest_valid === true ? 'valid' : yoloPreflight.checks.manifest_path_set ? 'not valid' : 'not provided'}</strong>
</div>
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile count</span>
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
</div>
</div>
<div className="entity-meta">
<span>torch_version: {yoloPreflight.runtime.torch_version ?? 'n/a'}</span>
<span>ultralytics_version: {yoloPreflight.runtime.ultralytics_version ?? 'n/a'}</span>
<span>cuda_available: {String(yoloPreflight.runtime.cuda_available ?? 'unknown')}</span>
<span>will_run_inference: {String(yoloPreflight.will_run_inference)}</span>
<span>YOLO_CONFIG_DIR: {yoloPreflight.runtime.yolo_config_dir ?? 'n/a'}</span>
<span>model directory: {yoloPreflight.runtime.model_directory ?? 'n/a'}</span>
<span>model_asset_id: {yoloPreflight.model_asset_id ?? 'n/a'}</span>
</div>
</div>
) : null}
</div>
</details>
<DetectionModelManagement
detectionModels={detectionModels}
modelAssets={modelAssets}
loadingDetectionModels={loadingDetectionModels}
detectionModelError={detectionModelError}
modelAssetError={modelAssetError}
selectedDetectionModelId={selectedDetectionModelId}
selectedModelAssetId={selectedModelAssetId}
detectionConfidenceThreshold={detectionConfidenceThreshold}
yoloPreflight={yoloPreflight}
loadingYoloPreflight={loadingYoloPreflight}
yoloPreflightError={yoloPreflightError}
onRefreshYoloPreflight={onRefreshYoloPreflight}
onSelectModelAsset={onSelectModelAsset}
onApplyOperatorProfile={onApplyOperatorProfile}
/>
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<div className="ai-lab-run-surface" aria-label="Gebouwdetectie starten">
<h3>Nieuwe beeldanalyse</h3>
<div
className={guidedDetectionReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
aria-label="Detection run readiness"
aria-label="Startklaar voor gebouwdetectie"
>
<div className="ai-lab-section-header">
<div>
@@ -720,17 +500,22 @@ export function DetectionLab({
) : null}
{detectionRunResult ? (
<div className="result-summary-card">
<p>Status: {detectionRunResult.status}</p>
<p>Uitleg: {detectionRunResult.message}</p>
<p>Analyse: {detectionRunResult.analysis_run_id}</p>
<p>Verwerking: {detectionRunResult.job_id}</p>
<p>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : detectionRunResult.status}</p>
<p>{detectionRunResult.message}</p>
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
<details className="technical-inline-details">
<summary>Technische verwerking</summary>
<div className="entity-meta">
<span>Analyserun-ID: {detectionRunResult.analysis_run_id}</span>
<span>Taak-ID: {detectionRunResult.job_id}</span>
</div>
</details>
</div>
) : null}
</div>
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Guided calibration runner">
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Modelkalibratie voor beheerders">
<summary>
<span>Modelkalibratie voor beheerders</span>
<strong>{detectionCalibrationRows.length > 0 ? `${detectionCalibrationRows.length} drempels getest` : 'gesloten'}</strong>
@@ -738,28 +523,28 @@ export function DetectionLab({
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Guided calibration runner</h3>
<p>This runs real configured YOLO jobs and QA comparisons for each threshold. It does not promote or mutate model files.</p>
<h3>Zekerheidsdrempels vergelijken</h3>
<p>Voert het lokale model en een kwaliteitscontrole uit voor iedere drempel. Modelbestanden worden niet gewijzigd.</p>
</div>
<span className={calibrationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{calibrationRunReady ? 'ready' : 'needs dataset, model, manifest and reference'}
{calibrationRunReady ? 'startklaar' : 'luchtbeeld, model, beeldtegels en referentie vereist'}
</span>
</div>
<div className="lab-form-grid">
<label>
Threshold set
Zekerheidsdrempels
<input
type="text"
value={calibrationThresholdText}
onChange={(event) => onSetCalibrationThresholdText(event.target.value)}
placeholder="0.50 0.25 0.15"
/>
<span className="field-guidance">Use spaces, commas or semicolons. Values must be between 0 and 1.</span>
<span className="field-guidance">Scheid waarden met spaties, komma's of puntkomma's. Iedere waarde ligt tussen 0 en 1.</span>
</label>
<label>
Reference dataset
Referentielaag
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -774,30 +559,30 @@ export function DetectionLab({
onClick={onRunCalibration}
disabled={runningDetectionCalibration || !calibrationRunReady}
>
Run calibration sweep
Drempels vergelijken
</button>
{detectionCalibrationError ? (
<div className="result-state result-state-error">
<strong>Calibration sweep failed.</strong>
<strong>De kalibratievergelijking is mislukt.</strong>
<p>{detectionCalibrationError}</p>
</div>
) : null}
{detectionCalibrationRows.length > 0 ? (
<div className="calibration-progress-panel" aria-label="Calibration run progress">
<div className="calibration-progress-panel" aria-label="Voortgang modelkalibratie">
<div className="panel-title-row">
<div>
<h3>Calibration run progress</h3>
<p className="muted">Each row is backed by a persisted detection run and QA check when successful.</p>
<h3>Voortgang modelkalibratie</h3>
<p className="muted">Iedere geslaagde rij is gekoppeld aan een bewaarde beeldanalyse en kwaliteitscontrole.</p>
</div>
<div className="panel-action-row">
<span className="count-pill">{detectionCalibrationRows.length} thresholds</span>
<span className="count-pill">{detectionCalibrationRows.length} drempels</span>
<button
className="secondary-action"
type="button"
onClick={() => downloadCalibrationSummary(selectedProjectId, detectionCalibrationRows)}
disabled={detectionCalibrationRows.length === 0 || !selectedProjectId}
>
Download calibration summary
Samenvatting downloaden
</button>
</div>
</div>
@@ -805,15 +590,15 @@ export function DetectionLab({
<table>
<thead>
<tr>
<th>Threshold</th>
<th>Drempel</th>
<th>Status</th>
<th>Detections</th>
<th>Precision</th>
<th>Recall</th>
<th>Objecten</th>
<th>Precisie</th>
<th>Herkenningsgraad</th>
<th>F1</th>
<th>False positives</th>
<th>False negatives</th>
<th>Evidence</th>
<th>Onterecht gevonden</th>
<th>Gemist</th>
<th>Kaartbewijs</th>
</tr>
</thead>
<tbody>
@@ -821,21 +606,21 @@ export function DetectionLab({
<tr key={row.threshold}>
<td>{row.threshold.toFixed(2)}</td>
<td>{row.status}</td>
<td>{row.detection_count ?? 'n/a'}</td>
<td>{row.detection_count ?? 'n.v.t.'}</td>
<td>{formatNullableNumber(row.precision ?? null, 3)}</td>
<td>{formatNullableNumber(row.recall ?? null, 3)}</td>
<td>{formatNullableNumber(row.f1_score ?? null, 3)}</td>
<td>{row.false_positives ?? 'n/a'}</td>
<td>{row.false_negatives ?? 'n/a'}</td>
<td>{row.false_positives ?? 'n.v.t.'}</td>
<td>{row.false_negatives ?? 'n.v.t.'}</td>
<td>
<button
className="secondary-action table-action"
type="button"
onClick={() => row.quality_check_id ? onOpenCalibrationEvidence(row.quality_check_id) : undefined}
disabled={!row.quality_check_id || row.status !== 'success'}
aria-label={`Open evidence map for threshold ${row.threshold.toFixed(2)}`}
aria-label={`Open kaartbewijs voor drempel ${row.threshold.toFixed(2)}`}
>
Open evidence map
Toon op kaart
</button>
</td>
</tr>
@@ -846,14 +631,14 @@ export function DetectionLab({
</div>
) : (
<div className="result-state result-state-empty">
<strong>No calibration sweep has been run in this session.</strong>
<p>Choose a reference dataset and threshold set, then start the explicit sweep.</p>
<strong>In deze sessie zijn nog geen drempels vergeleken.</strong>
<p>Kies een referentielaag en drempelreeks en start daarna de vergelijking.</p>
</div>
)}
</div>
</details>
<div className="ai-lab-results-surface" aria-label="Detection results">
<div className="ai-lab-results-surface" aria-label="Resultaten van de beeldanalyse">
<div className="panel-title-row">
<div>
<h3>Gevonden objecten</h3>
@@ -875,7 +660,7 @@ export function DetectionLab({
<option value="">Kies een bewaarde analyse</option>
{detectionRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'Gebouwdetectie'} · {run.status} · {run.id}
{formatDetectionRunLabel(run)}
</option>
))}
</select>
@@ -918,7 +703,7 @@ export function DetectionLab({
</div>
{detectionItems.length > 0 ? (
<>
<div className="pagination-toolbar" aria-label="Detection result pagination">
<div className="pagination-toolbar" aria-label="Paginering van gevonden objecten">
<p className="pagination-summary" aria-live="polite">
<strong>{detectionPageStart + 1}-{detectionPageEnd}</strong>
<span>van {detectionItems.length}</span>
@@ -1005,7 +790,7 @@ export function DetectionLab({
</div>
{calibrationRows.length > 0 ? (
<>
<div className="calibration-summary-grid" aria-label="Calibration comparison winners">
<div className="calibration-summary-grid" aria-label="Beste kalibratieresultaten">
<CalibrationSummaryCard title="Beste F1-score" row={bestF1Candidate} metric="f1" />
<CalibrationSummaryCard title="Beste precisie" row={bestPrecisionCandidate} metric="precision" />
<CalibrationSummaryCard title="Minste foutieve meldingen" row={lowestFalsePositivePressureCandidate} metric="falsePositives" />
@@ -1018,7 +803,7 @@ export function DetectionLab({
<th>Model</th>
<th>Objecten</th>
<th>Precisie</th>
<th>Recall</th>
<th>Herkenningsgraad</th>
<th>F1</th>
<th>Fout positief</th>
<th>Fout negatief</th>
@@ -1033,13 +818,17 @@ export function DetectionLab({
<strong>{row.modelName}</strong>
<span className="table-subtle">{row.modelAssetId ?? 'geconfigureerd lokaal model'}</span>
</td>
<td>{row.detectionCount ?? 'n/a'}</td>
<td>{row.detectionCount ?? 'n.v.t.'}</td>
<td>{formatNullableNumber(row.precision, 3)}</td>
<td>{formatNullableNumber(row.recall, 3)}</td>
<td>{formatNullableNumber(row.f1, 3)}</td>
<td>{row.falsePositives ?? 'n/a'}</td>
<td>{row.falseNegatives ?? 'n/a'}</td>
<td>{row.qualityCheckId}</td>
<td>{row.falsePositives ?? 'n.v.t.'}</td>
<td>{row.falseNegatives ?? 'n.v.t.'}</td>
<td>
<button className="secondary-action table-action" type="button" onClick={() => onOpenCalibrationEvidence(row.qualityCheckId)}>
Toon kaartbewijs
</button>
</td>
</tr>
))}
</tbody>
@@ -1085,15 +874,18 @@ export function DetectionLab({
) : null}
{detectionQaResult ? (
<div className="result-summary-card">
<p>Status: {detectionQaResult.status}</p>
<p>Kwaliteitscontrole: {detectionQaResult.quality_check_id}</p>
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</p>
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Gemiddelde overlap: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Minimale IoU voor een match: {detectionQaResult.iou_threshold.toFixed(2)}</p>
<p>Fout positief: {detectionQaResult.false_positives}</p>
<p>Fout negatief: {detectionQaResult.false_negatives}</p>
<details className="technical-inline-details">
<summary>Technische referentie</summary>
<span>Kwaliteitscontrole-ID: {detectionQaResult.quality_check_id}</span>
</details>
{detectionQaResult.coverage ? (
<div className="detection-qa-diagnostic">
<span>Gecontroleerd beeldbereik</span>
@@ -1117,7 +909,7 @@ export function DetectionLab({
{detectionQaResult.box_to_footprint_diagnostics.strict_matches} strikte vormmatches
</strong>
<p>
{detectionQaResult.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count} mogelijke vormafwijkingen. Precisie, recall en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
{detectionQaResult.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count} mogelijke vormafwijkingen. Precisie, herkenningsgraad en F1 hierboven blijven gebaseerd op de strikte geometrische overlap.
</p>
</div>
) : null}
@@ -1142,12 +934,12 @@ function CalibrationSummaryCard({
<span>{title}</span>
{row ? (
<>
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n/a' : formatNullableNumber(row[metric], 3)}</strong>
<strong>{metric === 'falsePositives' ? row.falsePositives ?? 'n.v.t.' : formatNullableNumber(row[metric], 3)}</strong>
<p>Threshold {formatNullableNumber(row.threshold, 2)} · {row.modelName}</p>
</>
) : (
<>
<strong>n/a</strong>
<strong>n.v.t.</strong>
<p>Persisted QA metrics are required.</p>
</>
)}
@@ -1155,16 +947,6 @@ function CalibrationSummaryCard({
)
}
function formatModelAssetSize(sizeBytes: number): string {
if (sizeBytes >= 1024 * 1024) {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
}
if (sizeBytes >= 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`
}
return `${sizeBytes} B`
}
function DetectionWorkflowStep({
label,
complete,
@@ -1209,7 +991,7 @@ function buildCalibrationRows(detectionRuns: DetectionRunRead[], qualityChecks:
analysisRunId: run.id,
qualityCheckId: check.id,
threshold,
modelName: run.model_name ?? 'configured detection',
modelName: run.model_name ?? 'geconfigureerde detectie',
modelAssetId: stringFromRecord(run.parameters_json, 'model_asset_id'),
detectionCount: numberFromRecord(run.result_json, 'detection_count'),
precision: metricValue(check, 'precision'),
@@ -1335,12 +1117,12 @@ function downloadJsonFile(filename: string, payload: unknown): void {
}
function formatNullableNumber(value: number | null, digits: number): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n/a'
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'n.v.t.'
}
function formatSourceTilePath(path: string | null | undefined): string {
if (!path) {
return 'n/a'
return 'n.v.t.'
}
const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
return parts[parts.length - 1] ?? path
@@ -0,0 +1,297 @@
import type {
DetectionModelCapability,
ModelAssetRead,
YoloPreflightResponse,
} from '../../types'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
interface DetectionModelManagementProps {
detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean
detectionModelError: string | null
modelAssetError: string | null
selectedDetectionModelId: string
selectedModelAssetId: string
detectionConfidenceThreshold: number
yoloPreflight: YoloPreflightResponse | null
loadingYoloPreflight: boolean
yoloPreflightError: string | null
onRefreshYoloPreflight: () => void
onSelectModelAsset: (modelAssetId: string) => void
onApplyOperatorProfile: (profile: DetectionOperatorProfile) => void
}
export function detectionModelLabel(model: DetectionModelCapability): string {
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
return model.display_name
}
function formatModelAssetSize(sizeBytes: number): string {
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) return 'n.v.t.'
const megabytes = sizeBytes / (1024 * 1024)
return `${megabytes.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} MB`
}
function statusLabel(value: string): string {
if (value === 'configured' || value === 'ready') return 'gereed'
if (value === 'not_configured') return 'niet geconfigureerd'
if (value === 'dependency_unavailable') return 'software ontbreekt'
return value.replace(/_/g, ' ')
}
export function DetectionModelManagement({
detectionModels,
modelAssets,
loadingDetectionModels,
detectionModelError,
modelAssetError,
selectedDetectionModelId,
selectedModelAssetId,
detectionConfidenceThreshold,
yoloPreflight,
loadingYoloPreflight,
yoloPreflightError,
onRefreshYoloPreflight,
onSelectModelAsset,
onApplyOperatorProfile,
}: DetectionModelManagementProps): JSX.Element {
const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null
const selectedOperatorProfile = DETECTION_OPERATOR_PROFILES.find(
(profile) => profile.modelAssetId === selectedModelAssetId,
) ?? null
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled
&& yoloPreflight.checks.dependencies_available
&& yoloPreflight.checks.model_file_exists,
)
return (
<div className="detection-model-management">
<details className="ai-lab-model-surface" aria-label="Technische modelmogelijkheden">
<summary>
<span>Technische modelinformatie</span>
<strong>{detectionModels.length} registraties</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
{loadingDetectionModels ? (
<div className="result-state result-state-loading">
<strong>Analysemodellen worden gecontroleerd.</strong>
<p>GeoIntel leest de modelregistratie en lokale bestanden.</p>
</div>
) : null}
{detectionModelError ? (
<div className="result-state result-state-error">
<strong>De modelregistratie is niet bereikbaar.</strong>
<p>{detectionModelError}</p>
</div>
) : null}
{modelAssetError ? (
<div className="result-state result-state-error">
<strong>De lokale modelbestanden konden niet worden gelezen.</strong>
<p>{modelAssetError}</p>
</div>
) : null}
{detectionModels.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>De backend meldt geen analysemodellen.</strong>
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</p>
</div>
) : null}
</div>
<ul className="model-list">
{detectionModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{detectionModelLabel(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>
{statusLabel(model.status)}
</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
<span>Model-ID: {model.model_id}</span>
<span>Framework: {model.framework}</span>
<span>Taaktype: {model.task_type}</span>
</div>
</details>
</li>
))}
</ul>
</div>
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<details className="ai-lab-model-surface" aria-label="Lokale modelkeuze">
<summary>
<span>Modelkeuze voor beheerders</span>
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Lokaal modelbestand</h3>
<p>GeoIntel kiest automatisch het actieve lokale model. Een andere keuze geldt alleen voor deze analyse.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="operator-profile-grid" aria-label="Gevalideerde YOLO-profielen">
{DETECTION_OPERATOR_PROFILES.map((profile) => {
const profileAsset = modelAssets.find((asset) => asset.model_asset_id === profile.modelAssetId)
const profileSelected =
selectedModelAssetId === profile.modelAssetId
&& Math.abs(detectionConfidenceThreshold - profile.confidenceThreshold) < 0.0001
return (
<div
className={profileSelected ? 'operator-profile-card operator-profile-card-selected' : 'operator-profile-card'}
key={profile.id}
>
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat, extra controle vereist'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
<span>precisie {profile.precision.toFixed(3)}</span>
<span>herkenningsgraad {profile.recall.toFixed(3)}</span>
<span>F1 {profile.f1.toFixed(3)}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
</div>
<p className="field-guidance">{profile.limitationMessage}</p>
<button
className="secondary-action"
type="button"
onClick={() => onApplyOperatorProfile(profile)}
disabled={!profileAsset}
>
Profiel gebruiken
</button>
</div>
)
})}
</div>
<label>
Lokaal modelbestand
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Kies een lokaal modelbestand</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(actief)' : ''}
</option>
))}
</select>
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
</div>
) : null}
{selectedModelAsset ? (
<details className="technical-inline-details">
<summary>Bestands- en integriteitsgegevens</summary>
<div className="result-summary-card">
<p>Bestand: {selectedModelAsset.filename}</p>
<p>Status: {statusLabel(selectedModelAsset.status)}</p>
<p>Actief servermodel: {selectedModelAsset.active ? 'ja' : 'nee'}</p>
<p>Automatisch downloaden: {selectedModelAsset.will_download_models ? 'ja' : 'nee'}</p>
<p>Grootte: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Pad: {selectedModelAsset.model_path}</p>
</div>
</details>
) : null}
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="Technische YOLO-runtimecontrole">
<summary>
<span>Technische runtimecontrole</span>
<strong>{yoloRuntimeReady ? 'gereed' : statusLabel(yoloPreflight?.status ?? 'niet geladen')}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>YOLO-runtimecontrole</h3>
<p>Deze alleen-lezen controle start geen analyse en downloadt geen modelbestanden.</p>
</div>
<button className="secondary-action" type="button" onClick={onRefreshYoloPreflight} disabled={loadingYoloPreflight}>
Controle vernieuwen
</button>
</div>
<div className="ai-lab-state-stack">
{loadingYoloPreflight ? (
<div className="result-state result-state-loading">
<strong>De YOLO-runtime wordt gecontroleerd.</strong>
<p>GeoIntel controleert configuratie, optionele software en lokale bestanden.</p>
</div>
) : null}
{yoloPreflightError ? (
<div className="result-state result-state-error">
<strong>De YOLO-runtimecontrole is niet beschikbaar.</strong>
<p>{yoloPreflightError}</p>
</div>
) : null}
{!yoloPreflight && !loadingYoloPreflight && !yoloPreflightError ? (
<div className="result-state result-state-empty">
<strong>Nog geen runtimecontrole geladen.</strong>
<p>Vernieuw de controle voordat je een lokaal model gebruikt.</p>
</div>
) : null}
</div>
{yoloPreflight ? (
<div className={yoloPreflight.status === 'ready' ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}>
<div className="lab-readiness-grid">
<div className={yoloPreflight.checks.enabled ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>YOLO ingeschakeld</span>
<strong>{yoloPreflight.checks.enabled ? 'ja' : 'nee'}</strong>
</div>
<div className={yoloPreflight.checks.dependencies_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Benodigde software</span>
<strong>{yoloPreflight.checks.dependencies_available === true ? 'beschikbaar' : yoloPreflight.checks.dependencies_available === false ? 'ontbreekt' : 'niet gecontroleerd'}</strong>
</div>
<div className={yoloPreflight.checks.model_file_exists ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Lokaal modelbestand</span>
<strong>{yoloPreflight.checks.model_file_exists === true ? 'gevonden' : yoloPreflight.checks.model_path_set ? 'ontbreekt' : 'niet geconfigureerd'}</strong>
</div>
<div className={yoloPreflight.runtime.cuda_available ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>GPU-versnelling</span>
<strong>{yoloPreflight.runtime.cuda_available === true ? 'beschikbaar' : yoloPreflight.runtime.cuda_available === false ? 'niet beschikbaar' : 'niet gecontroleerd'}</strong>
</div>
<div className={yoloPreflight.checks.manifest_valid ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{yoloPreflight.checks.manifest_valid === true ? 'geldig' : yoloPreflight.checks.manifest_path_set ? 'ongeldig' : 'niet opgegeven'}</strong>
</div>
<div className={yoloPreflight.tile_count > 0 ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Aantal beeldtegels</span>
<strong>{yoloPreflight.tile_count} / {yoloPreflight.max_tiles}</strong>
</div>
</div>
<details className="technical-inline-details">
<summary>Versies en serverpaden</summary>
<div className="entity-meta">
<span>PyTorch: {yoloPreflight.runtime.torch_version ?? 'n.v.t.'}</span>
<span>Ultralytics: {yoloPreflight.runtime.ultralytics_version ?? 'n.v.t.'}</span>
<span>YOLO-configuratiemap: {yoloPreflight.runtime.yolo_config_dir ?? 'n.v.t.'}</span>
<span>Modelmap: {yoloPreflight.runtime.model_directory ?? 'n.v.t.'}</span>
<span>Model-ID: {yoloPreflight.model_asset_id ?? 'n.v.t.'}</span>
</div>
</details>
</div>
) : null}
</div>
</details>
</div>
)
}
@@ -40,7 +40,7 @@ interface WorkbenchInspectorProps {
function formatValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return 'n/a'
return 'n.v.t.'
}
return String(value)
}
@@ -93,23 +93,23 @@ export function WorkbenchInspector({
const tabs: Array<{ key: InspectorTab; label: string }> = [
{ key: 'context', label: 'Context' },
{ key: 'dataset', label: 'Dataset' },
{ key: 'quality', label: 'QA/Exports' },
{ key: 'ai', label: 'AI Runs' },
{ key: 'dataset', label: 'Databron' },
{ key: 'quality', label: 'Kwaliteit en downloads' },
{ key: 'ai', label: 'Beeldanalyse' },
]
return (
<section className="workbench-inspector-panel" data-testid="workbench-inspector-panel">
<div className="inspector-header">
<div>
<p className="eyebrow">Inspector</p>
<h2>Selection details</h2>
<p className="eyebrow">Context</p>
<h2>Details van de selectie</h2>
</div>
<button type="button" className="inspector-close" onClick={onClose} aria-label="Close selection details">
Close
<button type="button" className="inspector-close" onClick={onClose} aria-label="Sluit details van de selectie">
Sluiten
</button>
</div>
<div className="inspector-tabs" role="tablist" aria-label="Inspector detail tabs">
<div className="inspector-tabs" role="tablist" aria-label="Onderdelen van de selectie">
{tabs.map((tab) => (
<button
key={tab.key}
@@ -130,36 +130,36 @@ export function WorkbenchInspector({
{activeTab === 'context' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-card">
<h3>Project context</h3>
<InspectorField label="Project" value={selectedProject?.name} />
<InspectorField label="Region" value={selectedProject?.region} />
<h3>Werkruimte</h3>
<InspectorField label="Naam" value={selectedProject?.name} />
<InspectorField label="Regio" value={selectedProject?.region} />
<InspectorField label="Status" value={selectedProject?.status} />
<InspectorField label="AOIs" value={areas.length} />
<InspectorField label="Datasets" value={datasetsCount} />
<InspectorField label="Gebieden" value={areas.length} />
<InspectorField label="Databronnen" value={datasetsCount} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenDataWorkspace}>
Open data setup
Gegevens beheren
</button>
</div>
</div>
<div className="inspector-card">
<h3>Active AOI</h3>
<InspectorField label="Name" value={selectedArea?.name} />
<InspectorField label="Area m2" value={selectedArea?.area_m2} />
<InspectorField label="CRS" value={selectedArea?.original_crs} />
<InspectorField label="Geometry" value={selectedArea?.geometry?.type} />
<h3>Actief werkgebied</h3>
<InspectorField label="Naam" value={selectedArea?.name} />
<InspectorField label="Oppervlakte m²" value={selectedArea?.area_m2} />
<InspectorField label="Coördinatenstelsel" value={selectedArea?.original_crs} />
<InspectorField label="Geometrietype" value={selectedArea?.geometry?.type} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenMapWorkspace}>
Open map
Kaart openen
</button>
</div>
</div>
<div className="inspector-card">
<h3>Map feature</h3>
<h3>Geselecteerd kaartobject</h3>
{selectedMapFeature ? (
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
) : (
<p className="muted">No map feature selected.</p>
<p className="muted">Er is geen kaartobject geselecteerd.</p>
)}
</div>
</div>
@@ -169,13 +169,13 @@ export function WorkbenchInspector({
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-action-bar">
<button type="button" className="secondary-action" onClick={onOpenDataWorkspace}>
Data catalog
Gegevenscatalogus
</button>
<button type="button" className="secondary-action" onClick={onOpenMapWorkspace}>
Map layer
Kaartlaag
</button>
<button type="button" className="secondary-action" onClick={onOpenExportsWorkspace}>
Export
Download
</button>
</div>
<DatasetDetailPanel {...datasetDetailProps} />
@@ -185,28 +185,26 @@ export function WorkbenchInspector({
{activeTab === 'quality' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-card">
<h3>Latest QA/QC</h3>
<InspectorField label="Check type" value={latestQualityCheck?.check_type} />
<h3>Laatste kwaliteitscontrole</h3>
<InspectorField label="Type controle" value={latestQualityCheck?.check_type?.replaceAll('_', ' ')} />
<InspectorField label="Status" value={latestQualityCheck?.status} />
<InspectorField label="Score" value={latestQualityCheck?.score} />
<InspectorField label="Metrics" value={latestQualityCheck?.metrics.length} />
<InspectorField label="Reference" value={latestQualityCheck?.reference_dataset_id} />
<InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenQualityWorkspace}>
Open QA/QC
Kwaliteit openen
</button>
</div>
</div>
<div className="inspector-card">
<h3>Latest export</h3>
<InspectorField label="Created now" value={latestExport?.export_type} />
<InspectorField label="Persisted type" value={latestPersistedExport?.export_type} />
<h3>Laatste download</h3>
<InspectorField label="Zojuist aangemaakt" value={latestExport?.export_type} />
<InspectorField label="Bewaard type" value={latestPersistedExport?.export_type} />
<InspectorField label="Status" value={latestPersistedExport?.status ?? latestExport?.status} />
<InspectorField label="Path" value={latestPersistedExport?.storage_path ?? latestExport?.path} />
<InspectorField label="Export count" value={exports.length} />
<InspectorField label="Aantal downloads" value={exports.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenExportsWorkspace}>
Open exports
Downloads openen
</button>
</div>
</div>
@@ -216,23 +214,28 @@ export function WorkbenchInspector({
{activeTab === 'ai' ? (
<div className="inspector-tab-panel" role="tabpanel" id={activePanelId} aria-labelledby={activeTabId}>
<div className="inspector-card">
<h3>Detection run</h3>
<InspectorField label="Selected run" value={selectedDetectionRunId} />
<h3>Gebouwdetectie</h3>
<InspectorField label="Status" value={selectedDetectionRun?.status} />
<InspectorField label="Model" value={selectedDetectionRun?.model_name} />
<InspectorField label="Detections loaded" value={detectionItems.length} />
<InspectorField label="Gevonden objecten" value={detectionItems.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenAiWorkspace}>
Open AI Labs
Beeldanalyse openen
</button>
</div>
</div>
<div className="inspector-card">
<h3>Segmentation run</h3>
<InspectorField label="Selected run" value={selectedSegmentationRunId} />
<h3>Segmentatie</h3>
<InspectorField label="Status" value={selectedSegmentationRun?.status} />
<InspectorField label="Model" value={selectedSegmentationRun?.model_name} />
<InspectorField label="Segmentations loaded" value={segmentationItems.length} />
<InspectorField label="Herkende vlakken" value={segmentationItems.length} />
<details className="technical-inline-details">
<summary>Technische analyseruns</summary>
<div className="entity-meta">
<span>Detectierun-ID: {selectedDetectionRunId || 'n.v.t.'}</span>
<span>Segmentatierun-ID: {selectedSegmentationRunId || 'n.v.t.'}</span>
</div>
</details>
</div>
</div>
) : null}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import type {
ProjectRead,
VectorSelectionBBox,
VectorSelectionMetric,
VectorSelectionResponse,
} from '../../types'
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
export function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
}
if (project.name === KEMPEN_PROJECT_NAME) {
return 'Kempen (28 gemeenten)'
}
return project.name
}
export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
}
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
return Math.max(0, widthMetres * heightMetres)
}
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) {
return false
}
const tolerance = 1e-9
return (
Math.abs(left.min_x - right.min_x) < tolerance
&& Math.abs(left.min_y - right.min_y) < tolerance
&& Math.abs(left.max_x - right.max_x) < tolerance
&& Math.abs(left.max_y - right.max_y) < tolerance
)
}
export function formatArea(areaSquareMetres: number | null): string {
if (areaSquareMetres === null) {
return 'Nog niet geselecteerd'
}
if (areaSquareMetres >= 1_000_000) {
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
}
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
}
export function resultCountLabel(result: VectorSelectionResponse): string {
const total = result.total_feature_count ?? result.feature_count
return result.truncated && result.total_feature_count == null
? `${result.feature_count.toLocaleString('nl-BE')}+`
: total.toLocaleString('nl-BE')
}
export function resultMetricLabel(result: VectorSelectionResponse): string {
if (!result.summary) {
return resultCountLabel(result)
}
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
}
export function selectionMetricLabel(metric: VectorSelectionMetric): string {
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
}
export function formatTemporalMetric(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function readablePropertyName(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
const points: Array<[number, number]> = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
points.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
if ('coordinates' in (geometry ?? {})) {
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
}
return points
}
function formatCoordinate(value: number): string {
return Number.isFinite(value) ? value.toFixed(6) : 'n.v.t.'
}
export function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
const points = collectGeometryPoints(feature?.geometry)
if (!feature?.geometry || points.length === 0) {
return {
bboxLabel: 'n.v.t.',
coordinateCount: 0,
geometryType: feature?.geometry?.type ?? 'geen',
}
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
Math.max(...xs),
)}, ${formatCoordinate(Math.max(...ys))}`
return {
bboxLabel,
coordinateCount: points.length,
geometryType: feature.geometry.type,
}
}
export function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const bounds = featureCollectionBounds(collection)
if (!bounds) {
return null
}
return {
min_x: bounds.minX,
min_y: bounds.minY,
max_x: bounds.maxX,
max_y: bounds.maxY,
crs: 'EPSG:4326',
}
}
export function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
const points = collectGeometryPoints(feature?.geometry)
if (points.length === 0) {
return null
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
return {
min_x: Math.min(...xs),
min_y: Math.min(...ys),
max_x: Math.max(...xs),
max_y: Math.max(...ys),
crs: 'EPSG:4326',
}
}
export function normalizeBboxFromCorners(
first: [number, number],
second: [number, number],
): VectorSelectionBBox {
return {
min_x: Math.min(first[0], second[0]),
min_y: Math.min(first[1], second[1]),
max_x: Math.max(first[0], second[0]),
max_y: Math.max(first[1], second[1]),
crs: 'EPSG:4326',
}
}
export function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
if (!bbox) {
return 'n.v.t.'
}
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
export function formatPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
export function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
min_y: bbox ? String(bbox.min_y) : '',
max_x: bbox ? String(bbox.max_x) : '',
max_y: bbox ? String(bbox.max_y) : '',
}
}
export function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
const min_x = Number(input.min_x)
const min_y = Number(input.min_y)
const max_x = Number(input.max_x)
const max_y = Number(input.max_y)
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
return null
}
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
}
export function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [feature],
}
}
export function safeFileStem(value: unknown): string {
const stem = String(value ?? 'selected-feature')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return stem || 'selected-feature'
}
function fallbackCopyText(text: string): void {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
export function copyText(text: string): void {
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
return
}
fallbackCopyText(text)
}
export function downloadJsonFile(
filename: string,
payload: unknown,
contentType = 'application/json',
): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
@@ -0,0 +1,203 @@
import type {
AreaRead,
DatasetCreateResponse,
ExportRead,
ProjectRead,
QualityCheckRead,
} from '../../types'
import type { useSourceFreshness } from '../../hooks/useSourceFreshness'
import { SourceFreshnessPanel } from '../status/SourceFreshnessPanel'
import { WorkbenchStatusStrip } from '../WorkbenchStatusStrip'
export type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
interface OverviewWorkspaceProps {
selectedProject: ProjectRead | null
selectedProjectId: string | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
qualityChecks: QualityCheckRead[]
exports: ExportRead[]
activeLayerFeatureCount: number
selectedAreaHasGeometry: boolean
hasAnalysisOutput: boolean
sourceFreshness: ReturnType<typeof useSourceFreshness>
onOpenWorkspace: (target: WorkspaceKey) => void
}
interface WorkflowStep {
step: string
title: string
detail: string
status: string
ready: boolean
target: WorkspaceKey
}
export function OverviewWorkspace({
selectedProject,
selectedProjectId,
areas,
datasets,
qualityChecks,
exports,
activeLayerFeatureCount,
selectedAreaHasGeometry,
hasAnalysisOutput,
sourceFreshness,
onOpenWorkspace,
}: OverviewWorkspaceProps): JSX.Element {
const hasMapContext = activeLayerFeatureCount > 0 || selectedAreaHasGeometry
const workflowComplete =
Boolean(selectedProjectId)
&& datasets.length > 0
&& hasMapContext
&& hasAnalysisOutput
&& exports.length > 0
const recommendedTarget: WorkspaceKey = !selectedProjectId || datasets.length === 0
? 'data'
: !hasMapContext
? 'map'
: !hasAnalysisOutput
? 'analysis'
: 'exports'
const steps: WorkflowStep[] = [
{
step: '1',
title: 'Werkruimte en gebied',
detail: selectedProjectId
? `${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'} beschikbaar`
: 'Laad of maak een werkruimte',
status: selectedProjectId ? 'gereed' : 'volgende stap',
ready: Boolean(selectedProjectId),
target: 'data',
},
{
step: '2',
title: 'Databronnen',
detail: datasets.length > 0
? `${datasets.length} ${datasets.length === 1 ? 'databron' : 'databronnen'} ingeladen`
: 'Voeg bron- en referentiegegevens toe',
status: datasets.length > 0 ? 'gereed' : 'wachten',
ready: datasets.length > 0,
target: 'data',
},
{
step: '3',
title: 'Kaart',
detail: hasMapContext
? activeLayerFeatureCount > 0
? `${activeLayerFeatureCount.toLocaleString('nl-BE')} objecten op de kaart`
: 'Werkgebied ingeladen'
: 'Bekijk het werkgebied en kies een kaartlaag',
status: hasMapContext ? 'gereed' : 'wachten',
ready: hasMapContext,
target: 'map',
},
{
step: '4',
title: 'Controle en analyse',
detail: hasAnalysisOutput
? 'Er is een bewaard analyse- of kwaliteitsresultaat'
: 'Voer een controle uit zodra de gegevens klaarstaan',
status: hasAnalysisOutput ? 'gereed' : 'wachten',
ready: hasAnalysisOutput,
target: 'analysis',
},
{
step: '5',
title: 'Downloads',
detail: exports.length > 0
? `${exports.length} ${exports.length === 1 ? 'resultaat' : 'resultaten'} bewaard`
: 'Bewaar een gecontroleerd resultaat',
status: exports.length > 0 ? 'gereed' : 'wachten',
ready: exports.length > 0,
target: 'exports',
},
]
return (
<div className="workspace-stack">
<WorkbenchStatusStrip
selectedProject={selectedProject}
areas={areas}
datasets={datasets}
qualityChecks={qualityChecks}
exports={exports}
activeLayerFeatureCount={activeLayerFeatureCount}
selectedAreaHasGeometry={selectedAreaHasGeometry}
/>
<SourceFreshnessPanel
report={sourceFreshness.report}
loading={sourceFreshness.loading}
error={sourceFreshness.error}
onRefresh={() => { void sourceFreshness.refresh() }}
catalogReport={sourceFreshness.catalogReport}
catalogLoading={sourceFreshness.catalogLoading}
catalogError={sourceFreshness.catalogError}
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
grbRefreshPlan={sourceFreshness.grbRefreshPlan}
grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
<strong>{workflowComplete ? 'voltooid' : 'stappen open'}</strong>
</summary>
<section className="workflow-guidance-panel" aria-label="Voortgang van de werkstroom">
<div className="panel-title-row">
<div>
<p className="eyebrow">Van bron tot resultaat</p>
<h2>Voortgang van de werkstroom</h2>
</div>
<span className="status-badge">
{workflowComplete ? 'Klaar om te delen' : 'Ga verder met de gemarkeerde stap'}
</span>
</div>
<div className="workflow-guidance-steps">
{steps.map((step) => (
<button
key={`${step.step}-${step.title}`}
type="button"
className={
step.target === recommendedTarget && !step.ready
? 'workflow-guidance-step workflow-guidance-step-active'
: step.ready
? 'workflow-guidance-step workflow-guidance-step-ready'
: 'workflow-guidance-step'
}
onClick={() => onOpenWorkspace(step.target)}
aria-label={`Open stap ${step.title}`}
>
<span className="workflow-step-status">{step.status}</span>
<strong>{step.step}. {step.title}</strong>
<small>{step.detail}</small>
</button>
))}
</div>
</section>
<section className="overview-actions">
<div className="overview-action-copy">
<p className="eyebrow">Snel verder</p>
<h2>Kies je volgende actie</h2>
</div>
<div className="quick-action-grid overview-quick-actions" aria-label="Aanbevolen acties">
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('data')}>
Gegevens beheren
</button>
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('map')}>
Kaart openen
</button>
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('analysis')}>
Kwaliteit bekijken
</button>
<button type="button" className="quick-action-button" onClick={() => onOpenWorkspace('exports')}>
Downloads beheren
</button>
</div>
</section>
</details>
</div>
)
}
@@ -4,6 +4,7 @@ import type { ProjectCreate, ProjectRead } from '../../types'
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
const REGIONAL_PROJECT_NAME = 'Kempen Regional Workbench'
const LEGACY_MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const PROTECTED_PROJECT_NAMES = new Set([REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
function isTechnicalProject(project: ProjectRead): boolean {
return TECHNICAL_PROJECT_PATTERN.test(project.name)
@@ -27,12 +28,14 @@ interface ProjectPanelProps {
projects: ProjectRead[]
selectedProjectId: string | null
loadingProjects: boolean
archivingProjectId: string | null
projectForm: ProjectCreate
loadingDemoWorkflow: boolean
demoWorkflowMessage: string | null
onCreateProject: (event: FormEvent<HTMLFormElement>) => void
onUpdateProjectForm: (projectForm: ProjectCreate) => void
onSelectProject: (projectId: string) => void
onArchiveProject: (projectId: string) => Promise<void>
onLoadDemoWorkflow: () => void
}
@@ -40,12 +43,14 @@ export function ProjectPanel({
projects,
selectedProjectId,
loadingProjects,
archivingProjectId,
projectForm,
loadingDemoWorkflow,
demoWorkflowMessage,
onCreateProject,
onUpdateProjectForm,
onSelectProject,
onArchiveProject,
onLoadDemoWorkflow,
}: ProjectPanelProps): JSX.Element {
const selectedProject = projects.find((project) => project.id === selectedProjectId)
@@ -134,6 +139,28 @@ export function ProjectPanel({
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div>
{selectedProject && !PROTECTED_PROJECT_NAMES.has(selectedProject.name) ? (
<div className="project-lifecycle-actions">
<div>
<strong>Actieve werkruimte opruimen</strong>
<p className="muted">
Archiveren verbergt deze werkruimte uit de standaardlijst. Databronnen en analyseresultaten blijven bewaard.
</p>
</div>
<button
className="secondary-action"
type="button"
disabled={archivingProjectId === selectedProject.id}
onClick={() => {
if (window.confirm(`Werkruimte "${projectDisplayName(selectedProject)}" archiveren?`)) {
void onArchiveProject(selectedProject.id)
}
}}
>
{archivingProjectId === selectedProject.id ? 'Archiveren...' : 'Werkruimte archiveren'}
</button>
</div>
) : null}
{advancedProjects.length > 0 ? (
<details className="technical-run-list">
<summary>{advancedProjects.length} alternatieve en technische werkruimtes</summary>
@@ -65,6 +65,18 @@ function qualityStatusLabel(status: string | null | undefined): string {
return status
}
function qualityCheckTypeLabel(checkType: string | null | undefined): string {
const labels: Record<string, string> = {
vector_vs_reference: 'Kaartlaag tegenover referentie',
detections_vs_reference: 'Gebouwdetectie tegenover referentie',
segmentations_vs_reference: 'Segmentatie tegenover referentie',
detection_vs_reference: 'Gebouwdetectie tegenover referentie',
segmentation_vs_reference: 'Segmentatie tegenover referentie',
}
if (!checkType) return 'Kwaliteitscontrole'
return labels[checkType] ?? checkType.replaceAll('_', ' ')
}
function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined {
return check?.metrics.find((metric) => metric.metric_key === metricKey)
}
@@ -78,7 +90,7 @@ function evidenceLabel(item: Record<string, unknown>): string {
const candidate = item['candidate_feature_id']
const reference = item['reference_feature_id']
const iou = item['iou']
const pairLabel = [candidate ? `Candidate ${candidate}` : null, reference ? `Reference ${reference}` : null].filter(Boolean).join(' / ')
const pairLabel = [candidate ? `Te controleren ${candidate}` : null, reference ? `Referentie ${reference}` : null].filter(Boolean).join(' / ')
const iouValue = Number(iou)
return iou === null || iou === undefined || !Number.isFinite(iouValue) ? pairLabel || JSON.stringify(item) : `${pairLabel || 'Match'} / IoU ${iouValue.toFixed(3)}`
}
@@ -143,11 +155,11 @@ export function QualityResultsPanel({
[latestCheck, qualityChecks, selectedQualityCheckId],
)
const selectedCandidateName = selectedQualityCheck?.candidate_dataset_id
? datasetNameById.get(selectedQualityCheck.candidate_dataset_id) ?? selectedQualityCheck.candidate_dataset_id
: 'n/a'
? datasetNameById.get(selectedQualityCheck.candidate_dataset_id) ?? 'Laag niet meer in de gegevenslijst'
: 'Niet bewaard'
const selectedReferenceName = selectedQualityCheck
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? selectedQualityCheck.reference_dataset_id
: 'n/a'
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? 'Referentielaag niet meer in de gegevenslijst'
: 'Niet beschikbaar'
const selectedMatchEvidence = findingEvidenceList(selectedQualityCheck, 'match_evidence')
const selectedFalsePositiveEvidence = findingEvidenceList(selectedQualityCheck, 'false_positive_evidence')
const selectedFalseNegativeEvidence = findingEvidenceList(selectedQualityCheck, 'false_negative_evidence')
@@ -177,7 +189,7 @@ export function QualityResultsPanel({
<span className="count-pill">{qualityChecks.length} controles</span>
</div>
<div className="quality-summary-surface" aria-label="QA/QC result summary">
<div className="quality-summary-surface" aria-label="Samenvatting kwaliteitsresultaten">
<div className="quality-summary-grid">
<div>
<span>Afgerond</span>
@@ -208,8 +220,8 @@ export function QualityResultsPanel({
<span>Details, kaartbewijs en historiek</span>
<strong>{qualityChecks.length} bewaarde controles</strong>
</summary>
<div className="quality-evidence-surface" aria-label="QA/QC dataset evidence">
<div className="quality-handoff-grid" aria-label="QA/QC dataset handoff context">
<div className="quality-evidence-surface" aria-label="Databronnen van de kwaliteitscontrole">
<div className="quality-handoff-grid" aria-label="Context van de vergeleken databronnen">
<div>
<span>Te controleren lagen</span>
<strong>{candidateDatasets.length}</strong>
@@ -224,13 +236,13 @@ export function QualityResultsPanel({
<span>Laatste vergelijking</span>
<strong>{qualityStatusLabel(latestCheck?.status)}</strong>
<p className="quality-dataset-name">
Te controleren: {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n.v.t.'} / referentie: {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n.v.t.'}
Te controleren: {latestCandidateName ?? 'laag niet meer beschikbaar'} / referentie: {latestReferenceName ?? 'laag niet meer beschikbaar'}
</p>
</div>
</div>
</div>
<div className="quality-drilldown-surface" aria-label="QA/QC evidence drilldown">
<div className="quality-drilldown-surface" aria-label="Details van het kaartbewijs">
<div className="panel-title-row">
<div>
<h3>Bewijs van de kwaliteitscontrole</h3>
@@ -264,28 +276,28 @@ export function QualityResultsPanel({
<div className="quality-drilldown-grid">
<div>
<span>Geselecteerde controle</span>
<strong>{selectedQualityCheck.check_type}</strong>
<p>{selectedQualityCheck.id}</p>
<strong>{qualityCheckTypeLabel(selectedQualityCheck.check_type)}</strong>
<p>{qualityScoreInterpretation(selectedQualityCheck.score)}</p>
</div>
<div>
<span>Te controleren laag</span>
<strong>{selectedCandidateName}</strong>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</p>
<p>De laag waarvan de kwaliteit wordt beoordeeld.</p>
</div>
<div>
<span>Referentielaag</span>
<strong>{selectedReferenceName}</strong>
<p>{selectedQualityCheck.reference_dataset_id}</p>
<p>De bewaarde bron waarmee wordt vergeleken.</p>
</div>
<div>
<span>Analyserun</span>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</strong>
<p>Taak: {selectedQualityCheck.job_id ?? 'n.v.t.'}</p>
<span>Beoordeling</span>
<strong>{qualityScoreInterpretation(selectedQualityCheck.score)}</strong>
<p>Score: {qualityScoreValue(selectedQualityCheck.score)} op een schaal van 0 tot 1</p>
</div>
<div>
<span>Status</span>
<strong>{selectedQualityCheck.status}</strong>
<p>Score: {qualityScoreValue(selectedQualityCheck.score)}</p>
<strong>{qualityStatusLabel(selectedQualityCheck.status)}</strong>
<p>Resultaat is bewaard in de werkruimte.</p>
</div>
<div>
<span>Afgerond</span>
@@ -293,6 +305,17 @@ export function QualityResultsPanel({
<p>Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<details className="technical-inline-details">
<summary>Technische identificatie en verwerking</summary>
<div className="entity-meta">
<span>Controle-ID: {selectedQualityCheck.id}</span>
<span>Type: {selectedQualityCheck.check_type}</span>
<span>Te controleren dataset-ID: {selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</span>
<span>Referentie-dataset-ID: {selectedQualityCheck.reference_dataset_id}</span>
<span>Analyserun-ID: {selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</span>
<span>Taak-ID: {selectedQualityCheck.job_id ?? 'n.v.t.'}</span>
</div>
</details>
<div className="quality-evidence-token-grid">
<div>
<span>Onterecht gevonden</span>
@@ -325,7 +348,7 @@ export function QualityResultsPanel({
onOpenEvidenceMap={onOpenEvidenceMap}
/>
) : null}
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
<div className="quality-feature-evidence-grid" aria-label="Kaartbewijs per object">
<div>
<span>Overeenkomende object-ID's</span>
{selectedMatchEvidence.length > 0 ? (
@@ -386,7 +409,7 @@ export function QualityResultsPanel({
)}
</div>
<div className="quality-control-surface" aria-label="QA/QC refresh and filters">
<div className="quality-control-surface" aria-label="Kwaliteitsresultaten vernieuwen en filteren">
<div className="button-row">
<button
type="button"
@@ -415,12 +438,12 @@ export function QualityResultsPanel({
</div>
{qualityChecks.length > 0 ? (
<div className="quality-history-controls" aria-label="QA/QC result filters">
<div className="quality-history-controls" aria-label="Filters voor kwaliteitsresultaten">
<label>
Kwaliteitsresultaten zoeken
<input
type="search"
placeholder="Type, id or dataset"
placeholder="Type of naam van een kaartlaag"
value={qualitySearchQuery}
onChange={(event) => setQualitySearchQuery(event.target.value)}
/>
@@ -481,7 +504,7 @@ export function QualityResultsPanel({
) : null}
</div>
<div className="quality-history-surface" aria-label="QA/QC result history">
<div className="quality-history-surface" aria-label="Geschiedenis van kwaliteitsresultaten">
<div className="panel-title-row">
<h3>Historiek</h3>
<span className="count-pill">{visibleQualityChecks.length} getoond</span>
@@ -491,18 +514,18 @@ export function QualityResultsPanel({
<li className="quality-check-card" key={check.id}>
<div className="quality-check-header">
<div>
<strong>{check.check_type}</strong>
<strong>{qualityCheckTypeLabel(check.check_type)}</strong>
<div className="entity-meta">
<span className="quality-check-dataset-link">
candidate: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? check.candidate_dataset_id : 'n/a'}
Te controleren: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? 'laag niet meer beschikbaar' : 'n.v.t.'}
</span>
<span className="quality-check-dataset-link">
reference: {datasetNameById.get(check.reference_dataset_id) ?? check.reference_dataset_id}
Referentie: {datasetNameById.get(check.reference_dataset_id) ?? 'laag niet meer beschikbaar'}
</span>
</div>
</div>
<span className={check.status === 'ok' || check.status === 'completed' ? 'status-badge status-badge-ready' : 'status-badge'}>
{check.status}
{qualityStatusLabel(check.status)}
</span>
</div>
<div className="quality-check-actions">
@@ -528,10 +551,17 @@ export function QualityResultsPanel({
<strong>{check.metrics.length}</strong>
</div>
<div>
<span>Controle-ID</span>
<strong>{check.id}</strong>
<span>Beoordeling</span>
<strong>{qualityScoreInterpretation(check.score)}</strong>
</div>
</div>
<details className="technical-inline-details">
<summary>Technische referentie</summary>
<div className="entity-meta">
<span>Controle-ID: {check.id}</span>
<span>Type: {check.check_type}</span>
</div>
</details>
<div className="quality-metric-section">
<span>Gemeten kwaliteit</span>
<div className="quality-metric-grid">
@@ -48,6 +48,15 @@ interface SegmentationLabProps {
onRunQa: () => void
}
function segmentationRunLabel(run: SegmentationRunRead): string {
const timestamp = run.finished_at ?? run.created_at
const dateLabel = timestamp
? new Intl.DateTimeFormat('nl-BE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(timestamp))
: 'datum onbekend'
const status = run.status === 'completed' ? 'afgerond' : run.status
return `${run.model_name || 'Segmentatie'} · ${status} · ${dateLabel}`
}
export function SegmentationLab({
segmentationModels,
loadingSegmentationModels,
@@ -95,50 +104,50 @@ export function SegmentationLab({
const segmentationRunReady =
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
const segmentationRunBlockedReason = !selectedProjectId
? 'Select or create a project first'
? 'Kies eerst een werkruimte'
: !segmentationHasDataset
? 'Select a raster dataset'
? 'Kies een rasterbestand'
: selectedSegmentationModelId === 'fixture-segmenter'
? 'Fixture segmenter is explicit test/demo-only'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demos'
: !selectedSegmentationModelConfigured
? selectedSegmentationModelLimitation ?? 'Selected segmentation model is not configured'
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
: null
return (
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
<div className="panel-title-row">
<div>
<p className="eyebrow">Polygon segmentation</p>
<h2>Segmentation Lab</h2>
<p className="eyebrow">Vlakken herkennen in beeld</p>
<h2>Segmentatie</h2>
</div>
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
Refresh models
Status vernieuwen
</button>
</div>
<details className="ai-lab-model-surface" aria-label="Segmentation model capabilities">
<details className="ai-lab-model-surface" aria-label="Technische informatie over segmentatiemodellen">
<summary>
<span>Model registry</span>
<strong>{segmentationModels.length} models</strong>
<span>Technische modelinformatie</span>
<strong>{segmentationModels.length} modellen</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
{loadingSegmentationModels ? (
<div className="result-state result-state-loading">
<strong>Loading segmentation models.</strong>
<p>Checking backend model registry availability.</p>
<strong>Segmentatiemodellen worden gecontroleerd.</strong>
<p>GeoIntel leest de modelregistratie van de backend.</p>
</div>
) : null}
{segmentationModelError ? (
<div className="result-state result-state-error">
<strong>Segmentation model registry unavailable.</strong>
<strong>De modelregistratie voor segmentatie is niet bereikbaar.</strong>
<p>{segmentationModelError}</p>
</div>
) : null}
{segmentationModels.length === 0 && !loadingSegmentationModels ? (
<div className="result-state result-state-empty">
<strong>No segmentation models reported by backend.</strong>
<p>Refresh models after the backend is reachable.</p>
<strong>De backend meldt geen segmentatiemodellen.</strong>
<p>Vernieuw de status zodra de backend opnieuw bereikbaar is.</p>
</div>
) : null}
</div>
@@ -146,14 +155,17 @@ export function SegmentationLab({
{segmentationModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div className="entity-meta">
<span>{model.model_id}</span>
<span>{model.framework}</span>
<span>{model.task_type}</span>
</div>
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p>
<details className="technical-inline-details">
<summary>Technische identificatie</summary>
<div className="entity-meta">
<span>Model-ID: {model.model_id}</span>
<span>Framework: {model.framework}</span>
<span>Taaktype: {model.task_type}</span>
</div>
</details>
</li>
))}
</ul>
@@ -161,55 +173,55 @@ export function SegmentationLab({
</details>
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Segmentation run controls">
<h3>Run segmentation</h3>
<div className="ai-lab-run-surface" aria-label="Segmentatie starten">
<h3>Nieuwe segmentatie</h3>
<div
className={segmentationRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
aria-label="Segmentation run readiness"
aria-label="Startklaar voor segmentatie"
>
<div className="ai-lab-section-header">
<div>
<h3>Run readiness</h3>
<p>Checks the selected raster and segmenter state before submitting a segmentation job.</p>
<h3>Wat is nog nodig?</h3>
<p>GeoIntel controleert het raster en model voordat de verwerking start.</p>
</div>
<span className={segmentationRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{segmentationRunReady ? 'Ready to submit' : 'Blocked'}
{segmentationRunReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={segmentationHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Raster dataset</span>
<strong>{segmentationHasDataset ? 'Selected' : 'Select a raster dataset'}</strong>
<span>Rasterbestand</span>
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
</div>
<div className={selectedSegmentationModelConfigured ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Model availability</span>
<span>Analysemodel</span>
<strong>
{selectedSegmentationModelConfigured
? 'Selected model is configured'
: selectedSegmentationModelLimitation ?? 'Select a configured segmentation model'}
? 'Het gekozen model is beschikbaar'
: selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel'}
</strong>
</div>
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile manifest</span>
<strong>{segmentationHasTileManifest ? 'Provided for provenance' : 'Optional for the fixture segmenter'}</strong>
<span>Beeldtegels</span>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Niet vereist voor het fixturemodel'}</strong>
</div>
</div>
</div>
<div className={segmentationRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
<span>Run action</span>
<strong>{segmentationRunReady ? 'Ready to submit a segmentation job' : segmentationRunBlockedReason}</strong>
<span>Analyse</span>
<strong>{segmentationRunReady ? 'Klaar om segmentatie te starten' : segmentationRunBlockedReason}</strong>
</div>
{rasterDatasets.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No raster datasets available for segmentation.</strong>
<p>Upload or select a raster dataset in Data before running segmentation.</p>
<strong>Geen rasterbestand beschikbaar voor segmentatie.</strong>
<p>Voeg eerst een rasterbestand toe onder Bronnen.</p>
</div>
) : null}
<div className="lab-form-grid">
<label>
Raster dataset
Rasterbestand
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
<option value="">Kies een rasterbestand</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -218,7 +230,7 @@ export function SegmentationLab({
</select>
</label>
<label>
Model
Analysemodel
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
{segmentationModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
@@ -228,7 +240,7 @@ export function SegmentationLab({
</select>
</label>
<label>
Min confidence
Minimale zekerheid
<input
type="number"
min="0"
@@ -239,22 +251,25 @@ export function SegmentationLab({
/>
</label>
</div>
<details className="technical-inline-details">
<summary>Technische beeldtegelinstelling</summary>
<label>
Tile manifest
Beeldtegelmanifest
<input
type="text"
placeholder="Raster tile manifest path"
placeholder="Pad naar het beeldtegelmanifest"
value={segmentationTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
</label>
</details>
<button
className="primary-action"
type="button"
onClick={onRunSegmentation}
disabled={runningSegmentation || !segmentationRunReady}
>
Run segmentation
Segmentatie starten
</button>
</div>
</div>
@@ -262,61 +277,66 @@ export function SegmentationLab({
<div className="ai-lab-state-stack">
{!selectedSegmentationModelConfigured ? (
<div className="result-state result-state-empty">
<strong>Segmentation model is not ready.</strong>
<p>{selectedSegmentationModelLimitation ?? 'Select a configured segmentation model'}</p>
<strong>Het segmentatiemodel is nog niet gereed.</strong>
<p>{selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel.'}</p>
</div>
) : null}
{segmentationRunError ? (
<div className="result-state result-state-error">
<strong>Segmentation run failed.</strong>
<strong>De segmentatie is mislukt.</strong>
<p>{segmentationRunError}</p>
</div>
) : null}
{segmentationRunResult ? (
<div className="result-summary-card">
<p>Status: {segmentationRunResult.status}</p>
<p>Message: {segmentationRunResult.message}</p>
<p>Analysis run: {segmentationRunResult.analysis_run_id}</p>
<p>Job: {segmentationRunResult.job_id}</p>
<p>Segmentations: {segmentationRunResult.segmentation_count}</p>
<p>Status: {segmentationRunResult.status === 'completed' ? 'afgerond' : segmentationRunResult.status}</p>
<p>{segmentationRunResult.message}</p>
<p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p>
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
<details className="technical-inline-details">
<summary>Technische verwerking</summary>
<div className="entity-meta">
<span>Analyserun-ID: {segmentationRunResult.analysis_run_id}</span>
<span>Taak-ID: {segmentationRunResult.job_id}</span>
</div>
</details>
</div>
) : null}
</div>
<div className="ai-lab-results-surface" aria-label="Segmentation results">
<div className="ai-lab-results-surface" aria-label="Segmentatieresultaten">
<div className="panel-title-row">
<div>
<h3>Segmentation results</h3>
<p className="muted">Load persisted segmentation polygons and filter by class or confidence.</p>
<h3>Herkende vlakken</h3>
<p className="muted">Bekijk bewaarde resultaten en filter op klasse of zekerheid.</p>
</div>
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh runs
Analyses vernieuwen
</button>
</div>
<div className="lab-form-grid">
<label>
Run
Analyse
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select segmentation run</option>
<option value="">Kies een bewaarde analyse</option>
{segmentationRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'segmentation'} - {run.status} - {run.id}
{segmentationRunLabel(run)}
</option>
))}
</select>
</label>
<label>
Class
Klasse
<input
type="text"
placeholder="Class filter"
placeholder="Filter op klasse"
value={segmentationClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
</label>
<label>
Min confidence
Minimale zekerheid
<input
type="number"
min="0"
@@ -328,18 +348,18 @@ export function SegmentationLab({
</label>
</div>
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}>
Load segmentations
Resultaten laden
</button>
{loadingSegmentationResults ? (
<div className="result-state result-state-loading">
<strong>Loading segmentation results.</strong>
<p>Retrieving persisted segmentation polygons for the selected run.</p>
<strong>Segmentatieresultaten worden geladen.</strong>
<p>GeoIntel leest de bewaarde polygonen van deze analyse.</p>
</div>
) : null}
<div className="ai-lab-state-stack">
<div className="result-state result-state-ready">
<strong>Segmentations loaded: {segmentationItems.length}</strong>
<p>{selectedSegmentationRunId ? 'Loaded from persisted segmentation records.' : 'Select a segmentation run before loading results.'}</p>
<strong>{segmentationItems.length} vlakken geladen</strong>
<p>{selectedSegmentationRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
</div>
</div>
{segmentationItems.length > 0 ? (
@@ -347,23 +367,21 @@ export function SegmentationLab({
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Area m2</th>
<th>Klasse</th>
<th>Zekerheid</th>
<th>Oppervlakte m²</th>
<th>Model</th>
<th>Tile</th>
<th>Mask path</th>
<th>Brontegel</th>
</tr>
</thead>
<tbody>
{segmentationItems.map((segmentation) => (
<tr key={segmentation.id}>
<td>{segmentation.class_name}</td>
<td>{segmentation.confidence?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.area_m2?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.confidence?.toFixed(2) ?? 'n.v.t.'}</td>
<td>{segmentation.area_m2?.toFixed(2) ?? 'n.v.t.'}</td>
<td>{segmentation.model_name}</td>
<td>{segmentation.source_tile_path || (segmentation.tile_index ?? 'n/a')}</td>
<td>{segmentation.mask_path || 'n/a'}</td>
<td>{segmentation.tile_index ?? 'n.v.t.'}</td>
</tr>
))}
</tbody>
@@ -372,12 +390,12 @@ export function SegmentationLab({
) : null}
</div>
<div className="ai-lab-qa-surface" aria-label="Segmentation QA controls and results">
<h3>Segmentation QA</h3>
<div className="ai-lab-qa-surface" aria-label="Kwaliteitscontrole voor segmentatie">
<h3>Kwaliteitscontrole segmentatie</h3>
<label>
Reference dataset
Referentielaag
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -386,24 +404,27 @@ export function SegmentationLab({
</select>
</label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
Compare segmentations to reference
Vergelijk met referentielaag
</button>
{segmentationQaError ? (
<div className="result-state result-state-error">
<strong>Segmentation QA failed.</strong>
<strong>De kwaliteitscontrole is mislukt.</strong>
<p>{segmentationQaError}</p>
</div>
) : null}
{segmentationQaResult ? (
<div className="result-summary-card">
<p>Status: {segmentationQaResult.status}</p>
<p>Quality check: {segmentationQaResult.quality_check_id}</p>
<p>Precision: {segmentationQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
<p>Recall: {segmentationQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
<p>F1: {segmentationQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
<p>Mean IoU: {segmentationQaResult.mean_iou?.toFixed(3) ?? 'n/a'}</p>
<p>False positives: {segmentationQaResult.false_positives}</p>
<p>False negatives: {segmentationQaResult.false_negatives}</p>
<p>Status: {segmentationQaResult.status === 'completed' ? 'afgerond' : segmentationQaResult.status}</p>
<p>Precisie: {segmentationQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Herkenningsgraad: {segmentationQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
<p>F1: {segmentationQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Gemiddelde overlap: {segmentationQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Onterecht gevonden: {segmentationQaResult.false_positives}</p>
<p>Gemist: {segmentationQaResult.false_negatives}</p>
<details className="technical-inline-details">
<summary>Technische referentie</summary>
<span>Kwaliteitscontrole-ID: {segmentationQaResult.quality_check_id}</span>
</details>
</div>
) : null}
</div>
+27 -7
View File
@@ -46,6 +46,7 @@ export function useProjectWorkspace() {
const [loadingProjects, setLoadingProjects] = useState(false)
const [loadingAreas, setLoadingAreas] = useState(false)
const [loadingDatasets, setLoadingDatasets] = useState(false)
const [archivingProjectId, setArchivingProjectId] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const projectDataRequestSequence = useRef(0)
@@ -159,7 +160,7 @@ export function useProjectWorkspace() {
setSelectedProjectId(nextProjectId)
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to load projects')
setErrorMessage(error instanceof Error ? error.message : 'Werkruimtes konden niet worden geladen')
} finally {
setLoadingProjects(false)
}
@@ -180,7 +181,7 @@ export function useProjectWorkspace() {
return projectData
} catch (error) {
if (requestId === projectDataRequestSequence.current) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
setErrorMessage(error instanceof Error ? error.message : 'De gegevens van de werkruimte konden niet worden geladen')
}
return null
} finally {
@@ -194,7 +195,7 @@ export function useProjectWorkspace() {
const createProject = async (event: FormEvent) => {
event.preventDefault()
if (!projectForm.name.trim()) {
setErrorMessage('Project name is required')
setErrorMessage('Geef een naam voor de werkruimte op')
return
}
try {
@@ -207,21 +208,21 @@ export function useProjectWorkspace() {
setSelectedProjectId(createdProject.id)
await loadProjects(createdProject.id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to create project')
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden aangemaakt')
}
}
const createArea = async (event: FormEvent) => {
event.preventDefault()
if (!selectedProjectId) {
setErrorMessage('Select a project first')
setErrorMessage('Kies eerst een werkruimte')
return
}
let geometry: AreaCreate['geometry']
try {
geometry = JSON.parse(areaForm.geometry) as AreaCreate['geometry']
} catch {
setErrorMessage('Invalid GeoJSON geometry JSON')
setErrorMessage('De opgegeven GeoJSON-geometrie is ongeldig')
return
}
try {
@@ -233,7 +234,24 @@ export function useProjectWorkspace() {
await loadProjectData(selectedProjectId)
setAreaForm((previous) => ({ ...previous, name: '' }))
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to create area')
setErrorMessage(error instanceof Error ? error.message : 'Het gebied kon niet worden aangemaakt')
}
}
const archiveProject = async (projectId: string) => {
setArchivingProjectId(projectId)
setErrorMessage(null)
try {
await projectsApi.update(projectId, { status: 'archived' })
if (selectedProjectId === projectId) {
setSelectedProjectId(null)
resetProjectData()
}
await loadProjects()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De werkruimte kon niet worden gearchiveerd')
} finally {
setArchivingProjectId(null)
}
}
@@ -252,6 +270,7 @@ export function useProjectWorkspace() {
loadingProjects,
loadingAreas,
loadingDatasets,
archivingProjectId,
errorMessage,
projectForm,
areaForm,
@@ -259,6 +278,7 @@ export function useProjectWorkspace() {
loadProjectData,
createProject,
createArea,
archiveProject,
resetProjectData,
setSelectedProjectId,
setErrorMessage,
+4 -3
View File
@@ -1,17 +1,18 @@
import { apiDelete, apiGet, apiPatch, apiPost } from './client'
import type { ProjectCreate, ProjectListResponse, ProjectRead } from '../../types'
import type { ProjectCreate, ProjectListResponse, ProjectRead, ProjectUpdate } from '../../types'
export const projectsApi = {
list: (options?: { name?: string; limit?: number }): Promise<ProjectListResponse> => {
list: (options?: { name?: string; limit?: number; status?: 'active' | 'archived' | 'all' }): Promise<ProjectListResponse> => {
const search = new URLSearchParams()
if (options?.name) search.set('name', options.name)
if (options?.limit) search.set('limit', String(options.limit))
if (options?.status) search.set('status', options.status)
const query = search.toString()
return apiGet<ProjectListResponse>(`/api/v1/projects${query ? `?${query}` : ''}`)
},
create: (payload: ProjectCreate): Promise<ProjectRead> => apiPost<ProjectRead>('/api/v1/projects', payload),
get: (id: string): Promise<ProjectRead> => apiGet<ProjectRead>(`/api/v1/projects/${id}`),
update: (id: string, payload: Partial<ProjectCreate>): Promise<ProjectRead> =>
update: (id: string, payload: ProjectUpdate): Promise<ProjectRead> =>
apiPatch<ProjectRead>(`/api/v1/projects/${id}`, payload),
delete: (id: string): Promise<{ deleted: boolean }> =>
apiDelete<{ deleted: boolean }>(`/api/v1/projects/${id}`),
+74
View File
@@ -2281,6 +2281,80 @@ details.ai-lab-model-surface > summary strong {
}
}
/* Progressive disclosure keeps technical provenance available without
competing with the operational workflow. */
.technical-inline-details {
min-width: 0;
border-top: 1px solid var(--line);
padding-top: 0.45rem;
color: var(--muted);
font-size: 0.75rem;
}
.technical-inline-details > summary {
width: fit-content;
cursor: pointer;
color: var(--muted);
font-weight: 750;
}
.technical-inline-details[open] > summary {
margin-bottom: 0.45rem;
color: var(--text);
}
.technical-inline-details .entity-meta {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 0.3rem 0.7rem;
}
.technical-inline-details .entity-meta > * {
overflow-wrap: anywhere;
}
.detection-model-management {
display: grid;
min-width: 0;
gap: 0.75rem;
}
.project-lifecycle-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
border-top: 1px solid var(--line);
padding-top: 0.75rem;
}
.project-lifecycle-actions p {
margin: 0.2rem 0 0;
}
@media (min-width: 1800px) {
.workspace-grid-ai {
grid-template-columns: minmax(0, 1fr);
}
.detection-lab-shell .lab-form-grid,
.detection-model-management .lab-form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.project-lifecycle-actions {
grid-template-columns: minmax(0, 1fr);
}
.project-lifecycle-actions button {
width: 100%;
}
}
.geo-image-quality-metrics,
.detection-review-summary {
display: grid;
+4
View File
@@ -28,6 +28,10 @@ export interface ProjectCreate {
region?: string
}
export interface ProjectUpdate extends Partial<ProjectCreate> {
status?: 'active' | 'archived'
}
export interface ProjectListResponse {
items: ProjectRead[]
total: number
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy.orm import Session
from app.db.session import SessionLocal
from app.models import Project
CANONICAL_PROJECT_NAMES = frozenset(
{
"Kempen Regional Workbench",
"Mol Municipality Workbench",
}
)
TECHNICAL_PROJECT_PATTERNS = (
re.compile(r"^GeoIntel Detection Quality Matrix", re.IGNORECASE),
re.compile(r"^GeoIntel hard-negative", re.IGNORECASE),
re.compile(r"^GeoIntel Detection Calibration", re.IGNORECASE),
re.compile(r"^GeoIntel Real Data Validation", re.IGNORECASE),
re.compile(r"^GeoIntel Operational YOLO .* Smoke", re.IGNORECASE),
re.compile(r"^GeoIntel Demo - Building QA$", re.IGNORECASE),
re.compile(r"^GeoIntel training", re.IGNORECASE),
re.compile(r"^Mol Building QA", re.IGNORECASE),
)
@dataclass(frozen=True)
class ArchivePlan:
project_ids: tuple[UUID, ...]
names: tuple[str, ...]
@property
def count(self) -> int:
return len(self.project_ids)
def is_technical_project_name(name: str) -> bool:
normalized = name.strip()
if normalized in CANONICAL_PROJECT_NAMES:
return False
return any(pattern.search(normalized) for pattern in TECHNICAL_PROJECT_PATTERNS)
def build_archive_plan(db: Session) -> ArchivePlan:
rows = (
db.query(Project)
.filter(Project.status == "active")
.order_by(Project.created_at.asc())
.all()
)
selected = [row for row in rows if is_technical_project_name(row.name)]
return ArchivePlan(
project_ids=tuple(row.id for row in selected),
names=tuple(row.name for row in selected),
)
def apply_archive_plan(db: Session, plan: ArchivePlan) -> int:
if not plan.project_ids:
return 0
updated = (
db.query(Project)
.filter(Project.id.in_(plan.project_ids), Project.status == "active")
.update({Project.status: "archived"}, synchronize_session=False)
)
db.commit()
return int(updated)
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Archive allowlisted GeoIntel operator and benchmark projects. "
"Dry-run is the default; pass --apply to persist status changes."
)
)
parser.add_argument("--apply", action="store_true", help="Persist status='archived'.")
parser.add_argument(
"--show-names",
action="store_true",
help="Include every matched project name in the JSON output.",
)
args = parser.parse_args()
with SessionLocal() as db:
plan = build_archive_plan(db)
archived_count = apply_archive_plan(db, plan) if args.apply else 0
payload = {
"mode": "apply" if args.apply else "dry-run",
"matched_count": plan.count,
"archived_count": archived_count,
"canonical_projects_preserved": sorted(CANONICAL_PROJECT_NAMES),
}
if args.show_names:
payload["matched_names"] = list(plan.names)
print(json.dumps(payload, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -87,6 +87,7 @@ ${PYTHON_BIN} -m py_compile scripts/validate_detection_false_positive_review_dec
${PYTHON_BIN} -m py_compile scripts/validate_detection_false_negative_review_decisions.py
${PYTHON_BIN} -m py_compile scripts/activate_promoted_yolo_candidate.py
${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m py_compile scripts/archive_technical_projects.py
${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m compileall backend/app
(cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning)