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