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

This commit is contained in:
Codex
2026-07-14 21:40:32 +02:00
parent 1693e4ca1f
commit b5bcd9b041
52 changed files with 1183 additions and 617 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 193 End-user regional workbench simplification (2026-07-14)
- Made the complete `Kempen Regional Workbench` the automatic fresh-session data context and removed the redundant region selector from the primary map.
- Kept all available regional datasets loaded while treating Mol, the other 27 municipalities and the complete region as spatial work-area filters.
- Replaced technical dataset names with theme/source labels and moved benchmark projects, raw source metadata, provider internals, QA evidence and model diagnostics behind advanced disclosures.
- Defaulted Detection Lab to the configured local YOLO asset, active local model profile and measured operating threshold; model quality remains explicitly review-required rather than presented as production-perfect.
- Simplified Quality, Downloads, Status and Beheer around end-user tasks without changing API contracts, migrations, persistence or inference behavior.
## Sprint 192 Regional map state correctness (2026-07-14)
- Fixed the map-first work-area selector so changing from the complete Kempen region to a municipality clears the old bbox, theme totals and result geometry before switching Areas.
@@ -16,11 +16,11 @@ def test_detection_lab_exposes_run_readiness_contract() -> None:
assert "detectionTileManifestPath.trim().length > 0" in lab
assert "detectionRunReady" in lab
assert 'aria-label="Detection 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 "Wat is nog nodig?" in lab
assert "Luchtbeeld" in lab
assert "Analysemodel" in lab
assert "Beeldtegels" in lab
assert "Klaar om te starten" in lab
def test_segmentation_lab_exposes_run_readiness_contract() -> None:
@@ -14,8 +14,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action()
assert "detectionModelUiRunnable" in lab
assert "selectedDetectionModelId !== 'manual-fixture-detector'" in lab
assert "detectionRunBlockedReason" in lab
assert "Fixture model is explicit test/demo-only" in lab
assert "Run action" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Klaar om gebouwen te zoeken" in lab
assert "disabled={runningDetection || !detectionRunReady}" in lab
@@ -19,9 +19,9 @@ def test_qa_feature_evidence_contract_is_documented_and_rendered() -> None:
assert field_name in api_contracts
assert "Feature-level QA/QC evidence" in quality_panel
assert "Matched feature ids" in quality_panel
assert "False positive feature ids" in quality_panel
assert "False negative feature ids" 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
assert "evidenceLabel" in quality_panel
@@ -47,5 +47,5 @@ def test_detection_lab_surfaces_local_model_asset_selection() -> None:
assert "Local model assets" in lab
assert "onSelectModelAsset" in lab
assert "modelAssets={modelAssets}" in app
assert "Official reference sources" in provider_panel
assert "not AI model choices" in provider_panel
assert "Officiële referentiebronnen" in provider_panel
assert "live GRB- en OSM-koppelingen staan uit" in provider_panel
@@ -4,13 +4,15 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_detection_workflow_does_not_auto_select_runtime_model_assets() -> None:
def test_detection_workflow_selects_only_the_active_runtime_model_asset_automatically() -> None:
hook = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8")
assert "const activeAsset = assetResponse.items.find" not in hook
assert "setSelectedModelAssetId(activeAsset?.model_asset_id ?? '')" not in hook
assert "const activeAsset = assetResponse.items.find((asset) => asset.active) ?? null" in hook
assert "const selectedAssetStillAvailable" in hook
assert "activeAsset?.model_asset_id ?? ''" in hook
assert "setSelectedModelAssetId(nextAssetId)" in hook
assert "setSelectedModelAssetId(assetResponse.items[0]" not in hook
assert "setSelectedModelAssetId('')" in hook
assert "getYoloPreflight({ model_asset_id: nextAssetId || null })" in hook
def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -> None:
@@ -18,11 +20,11 @@ def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -
encoding="utf-8"
)
assert "Explicit model asset" in lab
assert "No model file is selected automatically" in lab
assert "Operator profiles" in lab
assert "Lokaal modelbestand" in lab
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
assert "Gevalideerde profielen" in lab
assert "DETECTION_OPERATOR_PROFILES" in lab
assert "Candidate only - not default-approved" in lab
assert "kandidaat · extra controle vereist" in lab
assert "will_download_models" in lab
@@ -32,5 +34,5 @@ def test_detection_run_readiness_requires_explicit_asset_when_local_assets_exist
)
assert "detectionHasExplicitModelAsset" in lab
assert "Select a local model asset deliberately" in lab
assert "local model asset" in lab
assert "Kies een lokaal modelbestand onder beheer" in lab
assert "Lokale modelkeuze" in lab
@@ -40,7 +40,7 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl
assert "setDetectionConfidenceThreshold(0.25)" in app
assert "loadYoloPreflight(manifestPath).catch(() => null)" in app
assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))]
assert "Linked tile manifest" in lab
assert "Gekoppelde beeldtegels" in lab
assert "Tile manifest validation" in lab
assert "Tile count" in lab
assert "will_run_inference" in lab
@@ -34,17 +34,17 @@ def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> Non
)
assert "DETECTION_OPERATOR_PROFILES" in lab
assert "Operator profiles" in lab
assert "Gevalideerde profielen" in lab
assert "profile.displayName" in lab
assert "profile.confidenceThreshold" in lab
assert "Candidate only - not default-approved" in lab
assert "default-approved" in lab
assert "Apply profile" in lab
assert "kandidaat · extra controle vereist" in lab
assert "standaardprofiel" in lab
assert "Profiel gebruiken" in lab
assert "onApplyOperatorProfile(profile)" in lab
assert "Recommended starting threshold: 0.25" not in lab
def test_detection_workflow_applies_profiles_without_auto_selecting_assets() -> None:
def test_detection_workflow_applies_profiles_without_selecting_the_first_arbitrary_asset() -> None:
hook = (ROOT / "frontend" / "src" / "hooks" / "useDetectionWorkflow.ts").read_text(encoding="utf-8")
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
@@ -13,8 +13,9 @@ def test_long_context_names_remain_compact_and_inspectable() -> None:
encoding="utf-8"
)
assert "const projectContextLabel = selectedProject?.name ?? 'No project'" in app
assert "const datasetContextLabel = selectedDataset?.name ?? (datasets.length > 0 ? 'Select dataset' : 'No dataset')" in app
assert "selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME" in app
assert "? REGIONAL_WORKSPACE_LABEL" in app
assert "const datasetContextLabel = selectedDataset ? getDatasetDisplayName(selectedDataset)" in app
assert "<strong title={projectContextLabel}>{projectContextLabel}</strong>" in app
assert "<strong title={datasetContextLabel}>{datasetContextLabel}</strong>" in app
assert "<strong title={item.value}>{item.value}</strong>" in status
@@ -29,7 +29,7 @@ def test_frontend_declares_mol_as_primary_operating_focus() -> None:
assert "PRIMARY_FOCUS_AREA_GEOJSON" in project_hook
assert "4.35,51.28" not in project_hook
assert "center: PRIMARY_FOCUS_CENTER" in map_source
assert "GeoAI Workbench · Mol / Kempen" in app
assert "GeoAI-werkruimte · Mol en de Kempen" in app
def test_operator_workflows_put_mol_first_and_name_future_projects() -> None:
@@ -16,8 +16,9 @@ def test_workbench_uses_grouped_navigation_and_optional_inspector() -> None:
assert "import './styles/premium.css'" in app
assert "const workspaceNavGroups" in app
assert "['overview', 'data', 'map']" in app
assert "['map', 'data']" in app
assert "['analysis', 'ai']" in app
assert "['overview', 'system']" in app
assert "const [inspectorOpen, setInspectorOpen] = useState(false)" in app
assert "{inspectorOpen ? (" in app
assert 'id="workbench-inspector"' in app
@@ -31,12 +32,12 @@ def test_data_creation_forms_are_progressively_disclosed() -> None:
dataset = read("frontend/src/components/datasets/DatasetPanel.tsx")
css = read("frontend/src/styles/premium.css")
assert '<details className="data-panel-form-block">' in project
assert '<summary>Create project</summary>' in project
assert '<details className="data-panel-form-block technical-management-block">' in project
assert '<summary>Geavanceerd projectbeheer</summary>' in project
assert '<details className="data-panel-form-block">' in area
assert '<summary>Create spatial scope</summary>' in area
assert '<summary>Eigen gebied toevoegen (GeoJSON)</summary>' in area
assert '<details className="dataset-upload-block data-panel-form-block">' in dataset
assert '<summary>Upload source data</summary>' in dataset
assert '<summary>Eigen bronbestand toevoegen</summary>' in dataset
assert "details.data-panel-form-block > summary" in css
assert ".workspace-grid-data > section:nth-child(n)" in css
assert "max-height: calc(100dvh - 10.5rem);" in css
+6 -2
View File
@@ -129,9 +129,12 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None:
assert "py_compile scripts/provision_geographic_scope.py" in readiness
assert "Immutable scope dataset" in (ROOT / "scripts/provision_geographic_scope.py").read_text(encoding="utf-8")
assert "Kempen (28 gemeenten)" in workspace
assert 'aria-label="Regio"' in workspace
assert 'aria-label="Regio"' not in workspace
assert 'aria-label="Ingeladen regiobereik"' in workspace
assert "Gemeente of volledige regio" in workspace
assert "projects={projects}" in app
assert "onSelectProject={selectProject}" in app
map_props = app.split("<MapWorkspace", maxsplit=1)[1].split("/>", maxsplit=1)[0]
assert "onSelectProject={selectProject}" not in map_props
def test_project_switches_reset_scoped_state_and_prefer_the_regional_context() -> None:
@@ -144,5 +147,6 @@ def test_project_switches_reset_scoped_state_and_prefer_the_regional_context() -
assert "resetProjectData()" in selected_project_branch
assert "resetDatasetForProject()" in selected_project_branch
assert "projectDataRequestSequence" in project_workspace
assert "REGIONAL_WORKSPACE_PROJECT_NAME" in project_workspace
assert "vervoerregio|operationele grens" in map_state
assert "datasets.find(isOperationalScopeBoundaryDataset)" in dataset_workflow
@@ -0,0 +1,55 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_regional_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
map_workspace = read("frontend/src/components/map/MapWorkspace.tsx")
regional_check = project_hook.index("const regionalProject")
municipality_check = project_hook.index("const municipalityProject")
assert regional_check < municipality_check
assert 'aria-label="Regio"' not in map_workspace
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
assert "Gemeente of volledige regio" in map_workspace
assert 'aria-label="Werkgebied"' in map_workspace
def test_primary_navigation_uses_end_user_language_and_keeps_management_secondary() -> None:
app = read("frontend/src/App.tsx")
for label in ("Kaart", "Bronnen", "Kwaliteit", "Beeldanalyse", "Downloads"):
assert f"label: '{label}'" in app
assert "{ label: 'Beheer', keys: ['overview', 'system'] }" in app
assert 'className="secondary-analysis-disclosure segmentation-disclosure"' in app
def test_technical_projects_and_metadata_are_progressively_disclosed() -> None:
projects = read("frontend/src/components/project/ProjectPanel.tsx")
areas = read("frontend/src/components/project/AreaPanel.tsx")
datasets = read("frontend/src/components/datasets/DatasetPanel.tsx")
assert "TECHNICAL_PROJECT_PATTERN" in projects
assert "technische test- en benchmarkruns" in projects
assert "area-catalog-disclosure" in areas
assert "dataset-technical-details" in datasets
assert "getDatasetDisplayName" in datasets
def test_configured_yolo_and_active_asset_are_selected_without_hiding_limitations() -> None:
hook = read("frontend/src/hooks/useDetectionWorkflow.ts")
lab = read("frontend/src/components/detection/DetectionLab.tsx")
assert "useState('yolo-configured')" in hook
assert "useState(0.15)" in hook
assert "asset.active" in hook
assert "getYoloPreflight" in hook
assert 'aria-label="Status gebouwdetectie"' in lab
assert "resultaten blijven controleplichtig" in lab
assert "Modelkalibratie voor beheerders" in lab
@@ -58,4 +58,4 @@ def test_frontend_wires_selected_area_map_overlay_contract() -> None:
assert "area-fill" in geomap
assert "area-line" in geomap
assert "onSelectMapArea" in area_panel
assert "Show on map" in area_panel
assert "Toon op kaart" in area_panel
@@ -16,9 +16,9 @@ def test_frontend_wires_v1_workbench_status_strip() -> None:
assert "qualityChecks={qualityChecks}" in app
assert "activeLayerFeatureCount={mapFeatureCount}" in app
assert "selectedAreaHasGeometry={Boolean(areaFeatureCollection)}" in app
assert "V1 readiness" in component
assert "Workbench status" in component
assert "Workbench has the core V1 loop populated." in component
assert "Platformstatus" in component
assert "Workbench status:" in component
assert "Kaartwerkruimte is gebruiksklaar" in component
assert "workbench-status-strip" in css
assert "status-tile-ready" in css
@@ -47,7 +47,7 @@ def test_app_still_wires_quality_results_and_export_center() -> None:
assert "<QualityResultsPanel" in app
assert "onRefresh={() => loadQualityChecks()}" in app
assert "Refresh QA/QC results" in quality_panel
assert "Kwaliteitsresultaten vernieuwen" in quality_panel
assert "onClick={onRefresh}" in quality_panel
assert "<ExportCenter" in app
assert "onExportDataset={exportSelectedDatasetGeoJson}" in app
@@ -26,9 +26,9 @@ def test_app_uses_dataset_presentational_components() -> None:
def test_dataset_panel_owns_upload_and_list_markup() -> None:
panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx").read_text(encoding="utf-8")
assert "Upload dataset" in panel
assert "Refresh metadata" in panel
assert "Select / details" in panel
assert "Eigen bronbestand toevoegen" in panel
assert "Metadata vernieuwen" in panel
assert "Details en acties" in panel
assert "onLoadDatasetDetails" in panel
assert "onRefreshMetadata" in panel
@@ -38,8 +38,8 @@ def test_quality_results_panel_owns_persisted_quality_check_markup() -> None:
).read_text(encoding="utf-8")
assert "QualityCheckRead" in quality_panel
assert "Refresh QA/QC results" in quality_panel
assert "No persisted QA/QC results yet" in quality_panel
assert "Kwaliteitsresultaten vernieuwen" in quality_panel
assert "Nog geen bewaarde kwaliteitsresultaten" in quality_panel
assert "check.metrics.map" in quality_panel
assert "fetch(" not in quality_panel
assert "api" not in quality_panel.lower()
@@ -19,7 +19,7 @@ def test_data_workspace_panels_use_operator_friendly_cards_and_forms() -> None:
assert "compact-form" in project_panel
assert "entity-card-active" in project_panel
assert "AOI manager" in area_panel
assert "<h2>Gebieden</h2>" in area_panel
assert "compact-form" in area_panel
assert "entity-card-active" in area_panel
assert "dataset-upload-form" in dataset_panel
@@ -9,11 +9,11 @@ def test_quality_panel_uses_workbench_summary_and_cards() -> None:
ROOT / "frontend" / "src" / "components" / "quality" / "QualityResultsPanel.tsx"
).read_text(encoding="utf-8")
assert 'className="quality-results-panel"' in quality_panel
assert "'quality-results-panel quality-results-panel-empty' : 'quality-results-panel'" in quality_panel
assert 'className="quality-summary-grid"' in quality_panel
assert 'className="quality-check-list"' in quality_panel
assert 'className="quality-check-card"' in quality_panel
assert "No persisted QA/QC results yet" in quality_panel
assert "Nog geen bewaarde kwaliteitsresultaten" in quality_panel
assert "check.metrics.map" in quality_panel
assert "fetch(" not in quality_panel
@@ -13,8 +13,8 @@ def test_dataset_panel_exposes_map_and_export_quick_actions() -> None:
assert "dataset-card-active" in panel
assert "onOpenDatasetInMap" in panel
assert "onOpenDatasetExport" in panel
assert "Open in map" in panel
assert "Export / QA" in panel
assert "Open op kaart" in panel
assert "Downloaden" in panel
assert "disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}" in panel
@@ -13,12 +13,12 @@ def test_export_center_surfaces_handoff_readiness_context() -> None:
assert 'className="handoff-readiness-grid"' in export_center
assert 'className="handoff-action-grid"' in export_center
assert 'className="export-type-badge"' in export_center
assert "Latest handoff artifact" in export_center
assert "Project report" in export_center
assert "Detection run" in export_center
assert "Segmentation run" in export_center
assert "selectedDetectionRunId || 'none'" in export_center
assert "selectedSegmentationRunId || 'none'" in export_center
assert "Laatste bestand" in export_center
assert "Leesbaar rapport" in export_center
assert "Gebouwanalyse" in export_center
assert "Segmentatieanalyse" in export_center
assert "selectedDetectionRunId || 'geen'" in export_center
assert "selectedSegmentationRunId || 'geen'" in export_center
def test_export_handoff_polish_has_responsive_styles() -> None:
@@ -11,10 +11,10 @@ def test_dataset_panel_surfaces_role_summary_and_badges() -> None:
assert "roleSummaries" in dataset_panel
assert "dataset-role-summary-grid" in dataset_panel
assert "Selected" in dataset_panel
assert "Reference" in dataset_panel
assert "Candidate" in dataset_panel
assert "Source" in dataset_panel
assert "Geselecteerd" in dataset_panel
assert "Referentie" in dataset_panel
assert "Resultaat" in dataset_panel
assert "Eigen bron" in dataset_panel
assert "dataset-role-badge" in dataset_panel
assert "dataset-role-selected" in dataset_panel
assert "dataset-role-reference" in dataset_panel
@@ -28,9 +28,9 @@ def test_dataset_cards_expose_scan_friendly_source_and_crs_context() -> None:
)
assert "dataset-card-kicker" in dataset_panel
assert "Source: {dataset.source_name ?? dataset.source}" in dataset_panel
assert "Layer: {dataset.reference_layer_name}" in dataset_panel
assert "CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'n/a'}" in dataset_panel
assert "Bron: {dataset.source_name ?? dataset.source}" in dataset_panel
assert "Laag: {dataset.reference_layer_name}" in dataset_panel
assert "CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'onbekend'}" in dataset_panel
assert "dataset-card-title" in dataset_panel
@@ -11,9 +11,9 @@ def test_dataset_panel_explains_recommended_actions() -> None:
assert "datasetActionHint" in dataset_panel
assert "dataset-action-hint" in dataset_panel
assert "QA reference layer" in dataset_panel
assert "QA candidate layer" in dataset_panel
assert "Open on the map or send to Export / QA." in dataset_panel
assert "Officiële laag voor kaartanalyse en kwaliteitscontrole." in dataset_panel
assert "Bewaard resultaat dat opnieuw op de kaart" in dataset_panel
assert "Ingeladen gegevensbron voor verdere analyse." in dataset_panel
def test_dataset_buttons_have_scan_friendly_action_copy() -> None:
@@ -22,12 +22,12 @@ def test_dataset_buttons_have_scan_friendly_action_copy() -> None:
)
assert "dataset-action-grid" in dataset_panel
assert "Inspect" in dataset_panel
assert "Map" in dataset_panel
assert "Export / QA" in dataset_panel
assert "Bekijken" in dataset_panel
assert "Kaart" in dataset_panel
assert "Downloaden" in dataset_panel
assert "Metadata" in dataset_panel
assert "Vector/GeoJSON only" in dataset_panel
assert "Raster metadata is automatic" in dataset_panel
assert "Enkel vectorlagen" in dataset_panel
assert "disabled={dataset.dataset_type === 'raster'}" in dataset_panel
def test_dataset_action_styles_keep_buttons_dense_and_responsive() -> None:
@@ -23,11 +23,11 @@ def test_quality_panel_surfaces_candidate_reference_handoff_summary() -> None:
).read_text(encoding="utf-8")
assert "quality-handoff-grid" in quality_panel
assert "Candidate layers" in quality_panel
assert "Reference layers" in quality_panel
assert "Latest comparison" in quality_panel
assert "Candidate" in quality_panel
assert "Reference" in quality_panel
assert "Te controleren lagen" in quality_panel
assert "Referentielagen" in quality_panel
assert "Laatste vergelijking" in quality_panel
assert "Te controleren:" in quality_panel
assert "referentie:" in quality_panel
assert "quality-dataset-name" in quality_panel
@@ -28,8 +28,8 @@ def test_quality_panel_renders_metric_evidence_cards_and_raw_metrics() -> None:
assert "quality-metric-grid" in quality_panel
assert "quality-metric-card" in quality_panel
assert "quality-metric-card-critical" in quality_panel
assert "Metric evidence" in quality_panel
assert "Raw metrics" in quality_panel
assert "Gemeten kwaliteit" in quality_panel
assert "Alle meetwaarden" in quality_panel
assert "check.metrics.map" in quality_panel
@@ -17,12 +17,12 @@ def test_quality_panel_adds_result_filters_and_density_limit() -> None:
assert "filteredQualityChecks = useMemo" in quality_panel
assert "visibleQualityChecks = showAllQualityChecks ? filteredQualityChecks : filteredQualityChecks.slice(0, 8)" in quality_panel
assert "quality-history-controls" in quality_panel
assert "Search QA/QC results" in quality_panel
assert "All statuses" in quality_panel
assert "All check types" in quality_panel
assert "Reset view" in quality_panel
assert "Show all QA/QC results" in quality_panel
assert "No QA/QC results match the current filters." in quality_panel
assert "Kwaliteitsresultaten zoeken" in quality_panel
assert "Alle statussen" in quality_panel
assert "Alle types" in quality_panel
assert "Filters wissen" in quality_panel
assert "Alle kwaliteitsresultaten tonen" in quality_panel
assert "Geen kwaliteitsresultaten voldoen aan de filters." in quality_panel
def test_quality_filter_styles_reuse_dense_history_patterns() -> None:
@@ -16,12 +16,12 @@ def test_project_and_area_panels_expose_selected_summary_regions() -> None:
assert "const selectedProject = projects.find" in project_panel
assert 'className="data-selection-summary data-selection-summary-project"' in project_panel
assert 'className="data-panel-form-block"' in project_panel
assert 'className="data-panel-form-block technical-management-block"' in project_panel
assert 'className="data-panel-list-block"' in project_panel
assert "const selectedArea = areas.find" in area_panel
assert 'className="data-selection-summary data-selection-summary-area"' in area_panel
assert 'className="data-panel-form-block"' in area_panel
assert 'className="data-panel-list-block"' in area_panel
assert 'className="data-panel-list-block area-catalog-disclosure"' in area_panel
def test_dataset_panel_separates_selected_upload_and_catalog_regions() -> None:
@@ -11,7 +11,7 @@ def test_quality_results_panel_exposes_structured_surfaces() -> None:
encoding="utf-8"
)
assert 'className="quality-results-panel"' in panel
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
@@ -22,6 +22,7 @@ def test_quality_results_panel_exposes_structured_surfaces() -> None:
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 'className="quality-advanced-disclosure"' in panel
def test_quality_results_panel_preserves_existing_refresh_filter_and_history_state() -> None:
@@ -34,8 +35,8 @@ def test_quality_results_panel_preserves_existing_refresh_filter_and_history_sta
assert "qualityTypeFilter" in panel
assert "qualitySearchQuery" in panel
assert "visibleQualityChecks.map" in panel
assert "Show all QA/QC results" in panel
assert "No QA/QC results match the current filters." in panel
assert "Alle kwaliteitsresultaten tonen" in panel
assert "Geen kwaliteitsresultaten voldoen aan de filters." in panel
def test_quality_workspace_density_css_contracts() -> None:
@@ -52,7 +52,7 @@ def test_ai_lab_preserves_existing_detection_and_segmentation_controls() -> None
assert "onLoadResults" in detection
assert "onRunQa" in detection
assert "detectionTileManifestPath" in detection
assert "Detections loaded: {detectionItems.length}" in detection
assert "{detectionItems.length} objecten geladen" in detection
assert "Compare detections to reference" in detection
assert "onRunSegmentation" in segmentation
@@ -20,7 +20,7 @@ def test_export_center_exposes_final_handoff_surfaces() -> None:
assert 'className="export-actions-surface"' in export_center
assert 'aria-label="Export artifact actions"' in export_center
assert 'className="export-state-stack"' in export_center
assert 'className="export-history-surface"' in export_center
assert 'className="export-history-surface export-history-disclosure"' in export_center
assert 'aria-label="Export history"' in export_center
@@ -11,14 +11,14 @@ def test_export_center_groups_latest_handoff_artifacts_by_type() -> None:
assert "latestHandoffArtifacts" in export_center
assert "Latest handoff artifacts" in export_center
assert "Project report" in export_center
assert "Project metadata" in export_center
assert "Dataset GeoJSON" in export_center
assert "Detection GeoJSON" in export_center
assert "Segmentation GeoJSON" in export_center
assert "Leesbaar rapport" in export_center
assert "Werkruimtedata" in export_center
assert "Kaartlaag (GeoJSON)" in export_center
assert "Herkende gebouwen (GeoJSON)" in export_center
assert "Segmentaties (GeoJSON)" in export_center
assert "latest-artifact-grid" in export_center
assert "latest-artifact-card" in export_center
assert "No artifact yet" in export_center
assert "Nog niet gemaakt" in export_center
def test_export_center_latest_artifact_cards_keep_existing_actions() -> None:
@@ -12,14 +12,14 @@ 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 "Selected check" in panel
assert "Candidate layer" in panel
assert "Reference layer" in panel
assert "Analysis run" in panel
assert "Geselecteerde controle" in panel
assert "Te controleren laag" in panel
assert "Referentielaag" in panel
assert "Analyserun" in panel
assert "Job" in panel
assert "Completed" in panel
assert "Inspect latest check" in panel
assert "Inspect check" in panel
assert "Afgerond" in panel
assert "Laatste controle bekijken" in panel
assert "Controle bekijken" in panel
def test_quality_results_panel_surfaces_false_positive_negative_evidence() -> None:
@@ -27,9 +27,9 @@ def test_quality_results_panel_surfaces_false_positive_negative_evidence() -> No
encoding="utf-8"
)
assert "False positive evidence" in panel
assert "False negative evidence" in panel
assert "Map evidence handoff" in panel
assert "Onterecht gevonden" in panel
assert "Gemiste objecten" in panel
assert "Controle op de kaart" in panel
assert "metricByKey(selectedQualityCheck, 'false_positive_count')" in panel
assert "metricByKey(selectedQualityCheck, 'false_negative_count')" in panel
assert "selectedQualityCheck.parameters_json" in panel
@@ -22,8 +22,8 @@ def test_ai_labs_explain_missing_raster_input_before_disabled_runs() -> None:
encoding="utf-8"
)
assert "No raster datasets available for detection." in detection_lab
assert "Upload or select a raster dataset in Data before running object detection." in detection_lab
assert "Geen luchtbeeld beschikbaar in deze werkruimte." in detection_lab
assert "Voeg onder Bronnen een gegeorefereerde GeoTIFF toe." 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
+23
View File
@@ -1,3 +1,26 @@
## Sprint 193 End-user regional workbench simplification (2026-07-14)
Changed:
- Made `Kempen Regional Workbench` the automatic complete data context before the municipality fallback and removed the primary map's project-level region selector.
- Kept the 28 municipality Areas plus the complete region available as one spatial work-area selector over the already loaded regional datasets.
- Simplified the visible shell and moved technical project runs, raw source metadata, QA internals, provider capabilities, export history and AI diagnostics into explicit advanced disclosures.
- Defaulted Detection Lab to `yolo-configured`, threshold `0.15`, the active local asset and automatic non-mutating preflight checks.
- Added end-user workbench contract tests without changing backend API contracts, migrations, persistence or model execution.
AI evidence:
- The configured Tower runtime reports PyTorch `2.13.0+cpu`, Ultralytics `8.4.95`, a valid local active model file and a successful model-load preflight; CUDA is not available.
- The retained active profile records precision `0.5898`, recall `0.5770` and F1 `0.5825` over seven positive AOIs with zero detections in the empty-background control.
- No automatic download or blind retraining was introduced. The 48 false-positive and 48 false-negative review decisions remain a required gate before constructing another training corpus.
Tested before deployment:
- `bash scripts/run_readiness_check.sh` passed with `560 passed`, backend compile, one Alembic head (`202607140001`), frontend typecheck/build and live-smoke syntax validation.
- `python -m alembic upgrade head --sql` generated a 30,166-byte offline migration plan successfully; `bash -n scripts/live_migration_smoke.sh` passed.
- The false-positive review validator returned the expected `review_required` gate with all 48 selected decisions still `unreviewed`; no unreviewed QA mismatch was promoted into training data.
- Local Docker validation was unavailable because the Windows workstation has no Docker CLI; live container/PostGIS validation remains part of the Tower deployment pass.
Next:
- Extend official population and modern land-use time series from Mol to all 28 municipalities, then expose a guided raster-import and configured-detection run over a drawn map selection.
## Sprint 191 Regional Kempen GRB context foundation (2026-07-14)
Changed:
+3
View File
@@ -11,6 +11,9 @@
- [x] Define and provision the official 28-municipality Vervoerregio Kempen as the explicit operational regional scope.
- [x] Partition and provision the complete current GRB building theme for all 28 Kempen transport-region municipalities, with resumable source artifacts and one queryable regional PostGIS dataset.
- [x] Provision the remaining regional GRB context themes in bounded partitions: roads, water and parcels.
- [x] Make the complete regional workspace the automatic data context and use municipality/full-region Areas as one spatial filter instead of requiring a region dropdown.
- [x] Reduce end-user noise by moving technical projects, source metadata, QA evidence, provider internals and model diagnostics behind explicit advanced disclosures.
- [x] Default Detection Lab to the configured local YOLO asset and present measured model quality and control requirements honestly.
- [ ] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope.
This file now starts with the current implementation status. Older preparation/backlog sections are preserved below as historical planning context and should not be treated as the live sprint board without checking `docs/CODEX_EXECUTION_LOG.md`.
+7 -3
View File
@@ -1,8 +1,12 @@
# GeoIntel Frontend (Sprint 4)
React + TypeScript + MapLibre foundation for project/area/dataset workflow.
React + TypeScript + MapLibre workbench for regional geographic analysis.
Mol is the primary operating context. On a fresh session the application opens the map-first geographic explorer, prefers the persisted `Mol Municipality Workbench`, selects the official NIS `13025` municipality boundary and activates the largest available authoritative building layer. A compact `Regio` selector switches between this workspace and the explicitly provisioned `Kempen Regional Workbench`, whose scope is the official 28-municipality Vlaamse vervoerregio. Explicit project and dataset selections remain authoritative.
The persisted `Kempen Regional Workbench` is the automatic operational data context. Its current GRB layers are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Mol remains the primary validation and historical-data focus, while explicit project selection stays available under advanced management.
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its recorded coverage-aware benchmark is approximately precision 0.590, recall 0.577 and F1 0.582 over seven positive AOIs, with zero detections in the empty-background control. Another training pass is intentionally blocked until the generated false-positive and false-negative review decisions are completed.
The map-first explorer has two deliberate modes. `Latest state` selects the
latest explicitly dated source snapshot without claiming an old edition is
@@ -21,7 +25,7 @@ estimates clearly marked and land-cover sources show intersected hectares. The
advanced workbench remains available but is not required for the primary
choose-theme, draw-area, read-result flow.
The primary workflow is deliberately short: choose Mol or Kempen, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
The theme catalog currently recognizes buildings, population, forest/green, water, roads and parcels from dataset names and canonical `reference_layer_name` metadata. A theme is enabled only when a ready persisted vector dataset exists; otherwise it states `Bron nog niet ingeladen`. This prevents missing population or land-cover sources from appearing as zero-valued observations. The previous technical Map workspace remains available through `Geavanceerde werkbank` for derived datasets, QA/QC evidence and export operations.
+100 -79
View File
@@ -30,6 +30,8 @@ import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap'
import { useViewportVectorLayer } from './hooks/useViewportVectorLayer'
import { getDatasetDisplayName } from './lib/datasetDisplay'
import { REGIONAL_WORKSPACE_LABEL, REGIONAL_WORKSPACE_PROJECT_NAME } from './config/primaryFocus'
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
@@ -38,19 +40,20 @@ function isVectorDatasetType(datasetType: string): boolean {
type WorkspaceKey = 'overview' | 'data' | 'map' | 'analysis' | 'ai' | 'exports' | 'system'
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
{ key: 'overview', label: 'Overview', description: 'V1 readiness and next action' },
{ key: 'data', label: 'Data', description: 'Projects, AOI and datasets' },
{ key: 'map', label: 'Map', description: 'Layer review and inspection' },
{ key: 'analysis', label: 'QA/QC', description: 'Quality checks and change detection' },
{ key: 'ai', label: 'AI Labs', description: 'Detection and segmentation' },
{ key: 'exports', label: 'Exports', description: 'Artifacts and downloads' },
{ key: 'system', label: 'System', description: 'Provider capabilities' },
{ key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' },
{ key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' },
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
{ key: 'system', label: 'Beheer', description: 'Databronnen en systeemstatus' },
]
const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
{ label: 'Workspace', keys: ['overview', 'data', 'map'] },
{ label: 'Analyze', keys: ['analysis', 'ai'] },
{ label: 'Deliver', keys: ['exports', 'system'] },
{ label: 'Verkennen', keys: ['map', 'data'] },
{ label: 'Analyseren', keys: ['analysis', 'ai'] },
{ label: 'Afronden', keys: ['exports'] },
{ label: 'Beheer', keys: ['overview', 'system'] },
]
function App(): JSX.Element {
@@ -659,43 +662,45 @@ function App(): JSX.Element {
target: 'exports',
},
]
const projectContextLabel = selectedProject?.name ?? 'No project'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Select area' : 'No AOI')
const datasetContextLabel = selectedDataset?.name ?? (datasets.length > 0 ? 'Select dataset' : 'No dataset')
const projectContextLabel = selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME
? REGIONAL_WORKSPACE_LABEL
: selectedProject?.name ?? 'Geen werkruimte'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Kies een gebied' : 'Geen gebied')
const datasetContextLabel = selectedDataset ? getDatasetDisplayName(selectedDataset) : datasets.length > 0 ? 'Kies een bron' : 'Geen bron'
const layerContextLabel = mapFeatureCollection
? `${mapFeatureCount} features`
: viewportVectorLayerActive
? 'Viewport layer selected'
: 'No active layer'
? 'Kaartlaag gekozen'
: 'Geen actieve laag'
return (
<div className="app-shell workbench-shell">
<a className="skip-link" href="#workspace-main">
Skip to workspace
Ga naar de werkruimte
</a>
<header className="workbench-topbar">
<div className="brand-block">
<span className="brand-mark" aria-hidden="true">GI</span>
<div>
<h1>GeoIntel</h1>
<p>GeoAI Workbench · Mol / Kempen</p>
<p>GeoAI-werkruimte · Mol en de Kempen</p>
</div>
</div>
<div className="context-bar" aria-label="Active workbench context">
<div>
<span>Project</span>
<span>Werkruimte</span>
<strong title={projectContextLabel}>{projectContextLabel}</strong>
</div>
<div>
<span>AOI</span>
<span>Gebied</span>
<strong title={areaContextLabel}>{areaContextLabel}</strong>
</div>
<div>
<span>Dataset</span>
<span>Bron</span>
<strong title={datasetContextLabel}>{datasetContextLabel}</strong>
</div>
<div>
<span>Layer</span>
<span>Kaartlaag</span>
<strong title={layerContextLabel}>{layerContextLabel}</strong>
</div>
</div>
@@ -770,7 +775,7 @@ function App(): JSX.Element {
</button>
))}
</div>
<span>{selectedProject ? `Active project: ${selectedProject.name}` : 'Start with a project or load the demo workflow.'}</span>
<span>{selectedProject ? `Actief bereik: ${projectContextLabel}` : 'De regionale gegevens worden ingeladen.'}</span>
</div>
{activeWorkspace === 'overview' ? (
@@ -784,6 +789,11 @@ function App(): JSX.Element {
activeLayerFeatureCount={mapFeatureCount}
selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
/>
<details className="status-details-disclosure">
<summary>
<span>Volledige workflowstatus</span>
<strong>{workflowGuidanceComplete ? 'voltooid' : 'optionele stappen open'}</strong>
</summary>
<section className="workflow-guidance-panel" aria-label="V1 workflow guidance">
<div className="panel-title-row">
<div>
@@ -860,6 +870,7 @@ function App(): JSX.Element {
</button>
</div>
</section>
</details>
</div>
) : null}
@@ -958,7 +969,6 @@ function App(): JSX.Element {
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId}
onSelectProject={selectProject}
onOpenDatasetInMap={openDatasetInMap}
onSetAreaLayerVisible={setAreaLayerVisible}
onSetAreaLayerOpacity={setAreaLayerOpacity}
@@ -981,22 +991,6 @@ function App(): JSX.Element {
{activeWorkspace === 'analysis' ? (
<div className="workspace-grid workspace-grid-analysis">
<ChangeDetectionPanel
vectorDatasets={availableVectorDatasets}
sourceDatasetId={changeSourceDatasetId}
targetDatasetId={changeTargetDatasetId}
iouThreshold={changeIouThreshold}
includeUnchanged={changeIncludeUnchanged}
running={runningChangeDetection}
result={changeDetectionResult}
error={changeDetectionError}
onSourceDatasetChange={setChangeSourceDatasetId}
onTargetDatasetChange={setChangeTargetDatasetId}
onIouThresholdChange={setChangeIouThreshold}
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
<QualityResultsPanel
selectedProjectId={selectedProjectId}
qualityChecks={qualityChecks}
@@ -1008,6 +1002,27 @@ function App(): JSX.Element {
evidenceLoading={qualityEvidenceLoading}
evidenceError={qualityEvidenceError}
/>
<details className="secondary-analysis-disclosure">
<summary>
<span>Historische vectorlagen vergelijken</span>
<strong>Geavanceerd</strong>
</summary>
<ChangeDetectionPanel
vectorDatasets={availableVectorDatasets}
sourceDatasetId={changeSourceDatasetId}
targetDatasetId={changeTargetDatasetId}
iouThreshold={changeIouThreshold}
includeUnchanged={changeIncludeUnchanged}
running={runningChangeDetection}
result={changeDetectionResult}
error={changeDetectionError}
onSourceDatasetChange={setChangeSourceDatasetId}
onTargetDatasetChange={setChangeTargetDatasetId}
onIouThresholdChange={setChangeIouThreshold}
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
</details>
</div>
) : null}
@@ -1069,46 +1084,52 @@ function App(): JSX.Element {
onApplyOperatorProfile={applyDetectionOperatorProfile}
/>
<SegmentationLab
segmentationModels={segmentationModels}
loadingSegmentationModels={loadingSegmentationModels}
segmentationModelError={segmentationModelError}
selectedSegmentationDatasetId={selectedSegmentationDatasetId}
selectedSegmentationModelId={selectedSegmentationModelId}
segmentationTileManifestPath={segmentationTileManifestPath}
segmentationConfidenceThreshold={segmentationConfidenceThreshold}
runningSegmentation={runningSegmentation}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
segmentationReferenceDatasetId={segmentationReferenceDatasetId}
segmentationQaResult={segmentationQaResult}
segmentationQaError={segmentationQaError}
runningSegmentationQa={runningSegmentationQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
onLoadModels={loadSegmentationModels}
onSelectDataset={setSelectedSegmentationDatasetId}
onSelectModel={setSelectedSegmentationModelId}
onSetTileManifestPath={setSegmentationTileManifestPath}
onSetConfidenceThreshold={setSegmentationConfidenceThreshold}
onRunSegmentation={runSegmentation}
onLoadRuns={() => loadSegmentationRuns()}
onSelectRun={setSelectedSegmentationRunId}
onSetClassFilter={setSegmentationClassFilter}
onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter}
onLoadResults={() => loadSegmentationResults()}
onSelectReferenceDataset={setSegmentationReferenceDatasetId}
onRunQa={runSegmentationQa}
/>
<details className="secondary-analysis-disclosure segmentation-disclosure">
<summary>
<span>Segmentatie van beeldvlakken</span>
<strong>Nog niet geconfigureerd</strong>
</summary>
<SegmentationLab
segmentationModels={segmentationModels}
loadingSegmentationModels={loadingSegmentationModels}
segmentationModelError={segmentationModelError}
selectedSegmentationDatasetId={selectedSegmentationDatasetId}
selectedSegmentationModelId={selectedSegmentationModelId}
segmentationTileManifestPath={segmentationTileManifestPath}
segmentationConfidenceThreshold={segmentationConfidenceThreshold}
runningSegmentation={runningSegmentation}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
segmentationReferenceDatasetId={segmentationReferenceDatasetId}
segmentationQaResult={segmentationQaResult}
segmentationQaError={segmentationQaError}
runningSegmentationQa={runningSegmentationQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
onLoadModels={loadSegmentationModels}
onSelectDataset={setSelectedSegmentationDatasetId}
onSelectModel={setSelectedSegmentationModelId}
onSetTileManifestPath={setSegmentationTileManifestPath}
onSetConfidenceThreshold={setSegmentationConfidenceThreshold}
onRunSegmentation={runSegmentation}
onLoadRuns={() => loadSegmentationRuns()}
onSelectRun={setSelectedSegmentationRunId}
onSetClassFilter={setSegmentationClassFilter}
onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter}
onLoadResults={() => loadSegmentationResults()}
onSelectReferenceDataset={setSegmentationReferenceDatasetId}
onRunQa={runSegmentationQa}
/>
</details>
</div>
) : null}
@@ -28,21 +28,21 @@ function countReferenceDatasets(datasets: DatasetCreateResponse[]): number {
function nextAction(items: StatusItem[]): string {
if (!items.some((item) => item.key === 'project' && item.state === 'ready')) {
return 'Create or open a project.'
return 'De regionale werkruimte kon niet worden geopend.'
}
if (!items.some((item) => item.key === 'area' && item.state === 'ready')) {
return 'Create an AOI or select an area with geometry.'
return 'Kies een gemeente of de volledige regio.'
}
if (!items.some((item) => item.key === 'datasets' && item.state === 'ready')) {
return 'Upload a vector or raster dataset, or load the offline demo workflow.'
return 'Laad een kaartlaag of luchtbeeld in.'
}
if (!items.some((item) => item.key === 'qa' && item.state === 'ready')) {
return 'Run QA/QC against a reference dataset when candidate and reference data are available.'
return 'Kaart en bronnen zijn klaar. Kwaliteitscontrole is beschikbaar zodra er een analyseresultaat is.'
}
if (!items.some((item) => item.key === 'exports' && item.state === 'ready')) {
return 'Create a metadata, report or GeoJSON export for handoff.'
return 'De analyse is klaar; maak eventueel een rapport of download.'
}
return 'Workbench has the core V1 loop populated.'
return 'Alle onderdelen van de werkruimte bevatten bewaarde resultaten.'
}
export function WorkbenchStatusStrip({
@@ -64,63 +64,64 @@ export function WorkbenchStatusStrip({
const items: StatusItem[] = [
{
key: 'project',
label: 'Project',
value: selectedProject ? selectedProject.name : 'No project',
detail: selectedProject ? selectedProject.region : 'Open or create a project',
label: 'Werkruimte',
value: selectedProject ? selectedProject.name : 'Geen werkruimte',
detail: selectedProject ? selectedProject.region : 'Niet geladen',
state: selectedProject ? 'ready' : 'waiting',
},
{
key: 'area',
label: 'AOI',
value: `${areas.length} area${areas.length === 1 ? '' : 's'}`,
detail: selectedAreaHasGeometry ? 'selected area has map geometry' : 'no selected map geometry',
label: 'Gebieden',
value: `${areas.length} beschikbaar`,
detail: selectedAreaHasGeometry ? 'Kaartgrens beschikbaar' : 'Geen kaartgrens geselecteerd',
state: selectedAreaHasGeometry ? 'ready' : areas.length > 0 ? 'warning' : 'waiting',
},
{
key: 'datasets',
label: 'Datasets',
value: `${readyDatasets}/${datasets.length} ready`,
detail: `${vectorDatasets} vector, ${rasterDatasets} raster, ${referenceDatasets} reference`,
label: 'Bronnen',
value: `${readyDatasets}/${datasets.length} beschikbaar`,
detail: `${vectorDatasets} kaartlagen, ${rasterDatasets} luchtbeelden, ${referenceDatasets} officiële referenties`,
state: datasets.length === 0 ? 'waiting' : readyDatasets === datasets.length ? 'ready' : 'warning',
},
{
key: 'map',
label: 'Map',
value: `${activeLayerFeatureCount} feature${activeLayerFeatureCount === 1 ? '' : 's'}`,
detail: selectedAreaHasGeometry ? 'AOI overlay available' : 'AOI overlay missing',
label: 'Kaart',
value: `${activeLayerFeatureCount} zichtbare objecten`,
detail: selectedAreaHasGeometry ? 'Gebiedsgrens beschikbaar' : 'Gebiedsgrens ontbreekt',
state: selectedAreaHasGeometry || activeLayerFeatureCount > 0 ? 'ready' : 'waiting',
},
{
key: 'qa',
label: 'QA/QC',
value: `${qualityChecks.length} check${qualityChecks.length === 1 ? '' : 's'}`,
detail: latestQualityCheck ? `${latestQualityCheck.check_type}: ${latestQualityCheck.status}` : 'no persisted QA/QC result',
label: 'Kwaliteit',
value: `${qualityChecks.length} controles`,
detail: latestQualityCheck ? `${latestQualityCheck.check_type}: ${latestQualityCheck.status}` : 'Nog geen controle uitgevoerd',
state: qualityChecks.length > 0 ? 'ready' : 'waiting',
},
{
key: 'exports',
label: 'Exports',
value: `${exports.length} export${exports.length === 1 ? '' : 's'}`,
detail: latestExport ? `${latestExport.export_type}: ${latestExport.status}` : 'no handoff artifact yet',
label: 'Downloads',
value: `${exports.length} bestanden`,
detail: latestExport ? `${latestExport.export_type}: ${latestExport.status}` : 'Nog geen bestand gemaakt',
state: exports.length > 0 ? 'ready' : 'waiting',
},
]
const readyCount = items.filter((item) => item.state === 'ready').length
const coreReady = items.slice(0, 4).every((item) => item.state === 'ready')
return (
<section className="workbench-status-strip" aria-label="Workbench readiness">
<div className="status-strip-header">
<div>
<p className="eyebrow">V1 readiness</p>
<p className="eyebrow">Platformstatus</p>
<h2>
<span className="sr-only">Workbench status: </span>
{readyCount === items.length ? 'Mol workbench is ready' : 'Mol workbench needs attention'}
{coreReady ? 'Kaartwerkruimte is gebruiksklaar' : 'Kaartwerkruimte vraagt aandacht'}
</h2>
<p className="status-next-action">{nextAction(items)}</p>
</div>
<div className="status-readiness" aria-label={`${readyCount} of ${items.length} workbench stages ready`}>
<strong>{readyCount}/{items.length}</strong>
<span>stages ready</span>
<span>onderdelen gereed</span>
</div>
</div>
<div className="status-strip-grid">
@@ -1,5 +1,6 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../../types'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
interface DatasetFormState {
datasetType: string
@@ -70,25 +71,25 @@ function normalizeDatasetRole(dataset: DatasetCreateResponse): 'reference' | 'ca
function datasetRoleLabel(role: string): string {
if (role === 'reference') {
return 'Reference'
return 'Officiële referentie'
}
if (role === 'candidate') {
return 'Candidate'
return 'Analyse-resultaat'
}
return 'Source'
return 'Eigen bron'
}
function datasetActionHint(dataset: DatasetCreateResponse, role: string): string {
if (role === 'reference') {
return 'QA reference layer. Select it when comparing candidates against trusted geometry.'
return 'Officiële laag voor kaartanalyse en kwaliteitscontrole.'
}
if (role === 'candidate') {
return 'QA candidate layer. Open on the map or send to Export / QA.'
return 'Bewaard resultaat dat opnieuw op de kaart, in kwaliteit of als download kan worden gebruikt.'
}
if (dataset.dataset_type === 'raster') {
return 'Raster source. Use the raster tools in the inspector after selecting it.'
return 'Luchtbeeld of rasterbron voor kaart- en beeldanalyse.'
}
return 'Source dataset. Inspect details before using it in downstream workflows.'
return 'Ingeladen gegevensbron voor verdere analyse.'
}
export function DatasetPanel({
@@ -111,30 +112,30 @@ export function DatasetPanel({
const roleSummaries = [
{
key: 'selected',
label: 'Selected',
label: 'Geselecteerd',
count: selectedDatasetId ? 1 : 0,
hint: 'Active',
hint: 'Actief',
className: 'dataset-role-selected',
},
{
key: 'reference',
label: 'Reference',
label: 'Referentie',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
hint: 'QA truth',
hint: 'Officieel',
className: 'dataset-role-reference',
},
{
key: 'candidate',
label: 'Candidate',
label: 'Resultaat',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
hint: 'QA candidate',
hint: 'Afgeleid',
className: 'dataset-role-candidate',
},
{
key: 'source',
label: 'Source',
label: 'Eigen bron',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
hint: 'Uploads',
hint: 'Ingeladen',
className: 'dataset-role-source',
},
]
@@ -143,23 +144,23 @@ export function DatasetPanel({
<section className="workspace-panel" data-testid="dataset-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Data catalog</p>
<h2>Datasets</h2>
<p className="eyebrow">Ingeladen gegevens</p>
<h2>Bronnen</h2>
</div>
<span className="count-pill">{readyDatasets}/{datasets.length} ready</span>
<span className="count-pill">{readyDatasets}/{datasets.length} beschikbaar</span>
</div>
<div className="data-selection-summary data-selection-summary-dataset">
<span>Selected dataset</span>
<strong>{selectedDataset?.name ?? 'No dataset selected'}</strong>
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} / ${selectedDataset.status}` : 'Inspect a dataset to load details and tools.'}</small>
<span>Actieve bron</span>
<strong>{selectedDataset ? getDatasetDisplayName(selectedDataset) : 'Geen bron geselecteerd'}</strong>
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} · ${selectedDataset.status === 'ready' ? 'beschikbaar' : selectedDataset.status}` : 'Kies een bron om deze op de kaart te openen.'}</small>
</div>
<details className="dataset-upload-block data-panel-form-block">
<summary>Upload source data</summary>
<summary>Eigen bronbestand toevoegen</summary>
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
<label>
Type
Bestandstype
<select
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
@@ -170,7 +171,7 @@ export function DatasetPanel({
</select>
</label>
<label>
Source
Bronnaam
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
@@ -178,12 +179,12 @@ export function DatasetPanel({
/>
</label>
<label>
AOI
Gebied
<select
value={datasetForm.areaId}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
>
<option value="">No area</option>
<option value="">Volledige bron</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
@@ -192,7 +193,7 @@ export function DatasetPanel({
</select>
</label>
<label className="file-input-label">
File
Bestand
<input
type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff"
@@ -200,7 +201,7 @@ export function DatasetPanel({
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Upload dataset
Bron inladen
</button>
</form>
</details>
@@ -215,15 +216,15 @@ export function DatasetPanel({
))}
</div>
{loadingDatasets ? <p>Loading datasets...</p> : null}
{loadingDatasets ? <p>Bronnen laden...</p> : null}
{datasets.length === 0 ? (
<div className="empty-state">
<strong>No datasets yet</strong>
<p>Upload vector, GeoJSON or raster data after selecting a project.</p>
<strong>Nog geen bronnen beschikbaar</strong>
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
</div>
) : null}
<div className="data-panel-list-block dataset-catalog-block">
<p className="data-section-label">Available datasets</p>
<p className="data-section-label">Beschikbare bronnen</p>
<ul className="dataset-list">
{datasets.map((dataset) => {
const datasetRole = normalizeDatasetRole(dataset)
@@ -236,22 +237,16 @@ export function DatasetPanel({
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
<span>{dataset.dataset_type}</span>
</div>
<strong className="dataset-card-title">{dataset.name}</strong>
<div className="entity-meta">
<span>Source: {dataset.source_name ?? dataset.source}</span>
{dataset.reference_layer_name ? <span>Layer: {dataset.reference_layer_name}</span> : null}
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'n/a'}</span>
<strong className="dataset-card-title">{getDatasetDisplayName(dataset)}</strong>
<div className="entity-meta dataset-card-primary-meta">
<span>{(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
<span>{dataset.source_name ?? dataset.source}</span>
</div>
</div>
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
{dataset.status === 'ready' ? 'beschikbaar' : dataset.status === 'failed' ? 'mislukt' : 'bezig'}
</span>
</div>
<div className="dataset-metrics data-density-grid">
<span>size: {formatBytes(dataset.size_bytes)}</span>
<span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
<span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<p className="dataset-action-hint">{datasetActionHint(dataset, datasetRole)}</p>
<div className="dataset-action-grid">
<button
@@ -261,17 +256,17 @@ export function DatasetPanel({
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`}
>
<span>Inspect</span>
<small>Select / details</small>
<span>Bekijken</span>
<small>Details en acties</small>
</button>
<button
className="secondary-action dataset-action-button"
type="button"
onClick={() => onOpenDatasetInMap(dataset)}
aria-label="Open in map"
aria-label="Open op kaart"
>
<span>Map</span>
<small>Open layer</small>
<span>Kaart</span>
<small>Laag openen</small>
</button>
<button
className="secondary-action dataset-action-button"
@@ -279,20 +274,33 @@ export function DatasetPanel({
onClick={() => onOpenDatasetExport(dataset)}
disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}
>
<span>Export / QA</span>
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'Compare or export' : 'Vector/GeoJSON only'}</small>
<span>Downloaden</span>
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'GeoJSON of rapport' : 'Enkel vectorlagen'}</small>
</button>
</div>
<details className="dataset-technical-details">
<summary>Broninformatie</summary>
<div className="entity-meta">
<span>Bestand: {dataset.name}</span>
<span>Bron: {dataset.source_name ?? dataset.source}</span>
{dataset.reference_layer_name ? <span>Laag: {dataset.reference_layer_name}</span> : null}
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'onbekend'}</span>
</div>
<div className="dataset-metrics data-density-grid">
<span>Grootte: {formatBytes(dataset.size_bytes)}</span>
<span>Objecten: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'onbekend'}</span>
<span>Begrenzing: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<button
className="secondary-action dataset-action-button"
className="secondary-action"
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
aria-label="Refresh metadata"
aria-label="Bronmetadata vernieuwen"
>
<span>Metadata</span>
<small>{dataset.dataset_type === 'raster' ? 'Raster metadata is automatic' : 'Refresh vector stats'}</small>
Metadata vernieuwen
</button>
</div>
</details>
</li>
)
})}
@@ -145,6 +145,14 @@ export function DetectionLab({
}: DetectionLabProps): JSX.Element {
const selectedDetectionModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) ?? null
const selectedModelAsset = modelAssets.find((asset) => asset.model_asset_id === selectedModelAssetId) ?? null
const selectedOperatorProfile = DETECTION_OPERATOR_PROFILES.find(
(profile) => profile.modelAssetId === selectedModelAssetId,
) ?? null
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled &&
yoloPreflight.checks.dependencies_available &&
yoloPreflight.checks.model_file_exists,
)
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null
@@ -178,19 +186,19 @@ export function DetectionLab({
detectionHasExplicitModelAsset &&
detectionHasTileManifest
const detectionRunBlockedReason = !selectedProjectId
? 'Select or create a project first'
? 'De regionale werkruimte is nog niet geladen'
: !detectionHasDataset
? 'Select a raster dataset'
? 'Kies een luchtbeeld'
: !detectionHasModel
? 'Select a detection model'
? 'Kies een analysemodel'
: selectedDetectionModelId === 'manual-fixture-detector'
? 'Fixture model is explicit test/demo-only'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Selected model is not configured'
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd'
: !detectionHasExplicitModelAsset
? 'Select a local model asset deliberately'
? 'Kies een lokaal modelbestand onder beheer'
: !detectionHasTileManifest
? 'Provide a raster tile manifest for configured YOLO'
? 'Maak eerst beeldtegels voor het gekozen luchtbeeld'
: null
const calibrationRunReady = detectionRunReady && detectionReferenceDatasetId.length > 0 && calibrationThresholdText.trim().length > 0
@@ -198,18 +206,41 @@ export function DetectionLab({
<section className="workspace-panel ai-lab-shell detection-lab-shell">
<div className="panel-title-row">
<div>
<p className="eyebrow">Object detection</p>
<h2>Detection Lab</h2>
<p className="eyebrow">Analyse van luchtbeelden</p>
<h2>Gebouwen herkennen</h2>
</div>
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
Refresh models
Status vernieuwen
</button>
</div>
<div className="ai-user-summary" aria-label="Status gebouwdetectie">
<div className="ai-user-summary-card ai-user-summary-card-primary">
<span>Actieve analyse</span>
<strong>Gebouwdetectie</strong>
<p>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}</p>
</div>
<div className={yoloRuntimeReady ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>PyTorch-runtime</span>
<strong>{yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}</strong>
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
</div>
<div className="ai-user-summary-card">
<span>Gevalideerde kwaliteit</span>
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
<p>{selectedOperatorProfile ? `${selectedOperatorProfile.positiveSampleCount} testgebieden · resultaten blijven controleplichtig` : 'Kies het goedgekeurde lokale profiel.'}</p>
</div>
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>Beschikbare luchtbeelden</span>
<strong>{rasterDatasets.length}</strong>
<p>{rasterDatasets.length > 0 ? 'Klaar om een beeld te kiezen.' : 'Laad eerst een gegeorefereerd luchtbeeld in.'}</p>
</div>
</div>
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
<summary>
<span>Model registry</span>
<strong>{detectionModels.length} models</strong>
<span>Technische modelinformatie</span>
<strong>{detectionModels.length} registraties</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-state-stack">
@@ -257,21 +288,25 @@ export function DetectionLab({
</details>
{selectedDetectionModelId === 'yolo-configured' ? (
<div className="ai-lab-model-surface" aria-label="Local model asset selection">
<details className="ai-lab-model-surface" aria-label="Local model asset selection">
<summary>
<span>Modelkeuze voor beheerders</span>
<strong>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Geen lokaal model'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Explicit model asset</h3>
<p>Local model assets are read-only. No model file is selected automatically. Choose an existing local model asset before submitting configured YOLO.</p>
<h3>Lokaal modelbestand</h3>
<p>GeoIntel kiest automatisch het actieve lokale model. Een beheerder kan hier bewust een ander reeds aanwezig, alleen-lezen modelbestand kiezen.</p>
</div>
<span className={selectedModelAsset ? 'status-badge status-badge-ready' : 'status-badge'}>
{selectedModelAsset ? 'asset selected' : 'no explicit asset'}
{selectedModelAsset ? 'model gekozen' : 'geen model gekozen'}
</span>
</div>
<div className="model-asset-guidance">
<strong>Operator profiles</strong>
<strong>Gevalideerde profielen</strong>
<p>
Candidate profiles apply a local model asset and confidence threshold only after an explicit click.
Promoted profiles still require explicit operator action and do not mutate the runtime environment.
Een profiel koppelt een lokaal model aan een gemeten zekerheidsdrempel. Een andere keuze geldt alleen voor de huidige analyse en wijzigt de serverconfiguratie niet.
</p>
</div>
<div className="operator-profile-grid" aria-label="Configured YOLO operator profiles">
@@ -288,22 +323,22 @@ export function DetectionLab({
<div className="operator-profile-card-header">
<strong>{profile.displayName}</strong>
<span className={profile.defaultApproved ? 'status-badge status-badge-ready' : 'status-badge'}>
{profile.defaultApproved ? 'default-approved' : 'Candidate only - not default-approved'}
{profile.defaultApproved ? 'standaardprofiel' : 'kandidaat · extra controle vereist'}
</span>
</div>
<p>{profile.description}</p>
<div className="operator-profile-metrics">
<span>threshold {profile.confidenceThreshold.toFixed(2)}</span>
<span>drempel {profile.confidenceThreshold.toFixed(2)}</span>
<span>precision {profile.precision.toFixed(3)}</span>
<span>recall {profile.recall.toFixed(3)}</span>
<span>F1 {profile.f1.toFixed(3)}</span>
<span>positive AOIs {profile.positiveSampleCount}</span>
<span>max background FP {profile.maxBackgroundDetections}</span>
<span>testgebieden {profile.positiveSampleCount}</span>
<span>max. achtergrondfouten {profile.maxBackgroundDetections}</span>
</div>
<div className="entity-meta">
<span>asset: {profile.modelAssetId}</span>
<span>promotionRecommendation: {profile.promotionRecommendation}</span>
<span>available: {profileAsset ? 'yes' : 'not mounted'}</span>
<span>modelbestand: {profile.modelAssetId}</span>
<span>beoordeling: {profile.promotionRecommendation}</span>
<span>beschikbaar: {profileAsset ? 'ja' : 'niet gekoppeld'}</span>
</div>
<p className="field-guidance">{profile.limitationMessage}</p>
<button
@@ -312,7 +347,7 @@ export function DetectionLab({
onClick={() => onApplyOperatorProfile(profile)}
disabled={!profileAsset}
>
Apply profile
Profiel gebruiken
</button>
</div>
)
@@ -320,17 +355,16 @@ export function DetectionLab({
</div>
{selectedModelAsset ? (
<div className="model-asset-guidance">
<strong>Selected model asset status</strong>
<strong>Status gekozen model</strong>
<p>
{selectedModelAsset.display_name} is selected for this browser-run request. Runtime default activation
remains a separate guarded operator action backed by a promotion report.
{selectedModelAsset.display_name} wordt voor deze analyse gebruikt. De standaard serverconfiguratie blijft ongewijzigd.
</p>
</div>
) : null}
<label>
Local model file
Lokaal modelbestand
<select value={selectedModelAssetId} onChange={(event) => onSelectModelAsset(event.target.value)}>
<option value="">Select explicit local model asset</option>
<option value="">Kies een lokaal modelbestand</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(active)' : ''}
@@ -340,8 +374,8 @@ export function DetectionLab({
</label>
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>No local model assets found.</strong>
<p>Mount model files into the backend model directory or continue with the configured YOLO_MODEL_PATH.</p>
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
</div>
) : null}
{selectedModelAsset ? (
@@ -356,13 +390,14 @@ export function DetectionLab({
<p>{selectedModelAsset.limitation_message}</p>
</div>
) : null}
</div>
</div>
</details>
) : null}
<details className="ai-lab-model-surface" aria-label="YOLO runtime preflight">
<summary>
<span>YOLO runtime preflight</span>
<strong>{yoloPreflight?.status ?? 'not loaded'}</strong>
<span>Technische runtimecontrole</span>
<strong>{yoloRuntimeReady ? 'gereed' : yoloPreflight?.status ?? 'niet geladen'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
@@ -447,72 +482,72 @@ export function DetectionLab({
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Detection run controls">
<h3>Run detection</h3>
<h3>Nieuwe beeldanalyse</h3>
<div
className={detectionRunReady ? 'lab-readiness-panel lab-readiness-panel-ready' : 'lab-readiness-panel'}
aria-label="Detection run readiness"
>
<div className="ai-lab-section-header">
<div>
<h3>Run readiness</h3>
<p>Checks the selected dataset, model and tile manifest before submitting a detection job.</p>
<h3>Wat is nog nodig?</h3>
<p>De analyse start zodra een luchtbeeld en de bijbehorende beeldtegels beschikbaar zijn.</p>
</div>
<span className={detectionRunReady ? 'status-badge status-badge-ready' : 'status-badge'}>
{detectionRunReady ? 'Ready to submit' : 'Blocked'}
{detectionRunReady ? 'Klaar om te starten' : 'Nog niet startklaar'}
</span>
</div>
<div className="lab-readiness-grid">
<div className={detectionHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Raster dataset</span>
<strong>{detectionHasDataset ? 'Selected' : 'Select a raster dataset'}</strong>
<span>Luchtbeeld</span>
<strong>{detectionHasDataset ? 'Geselecteerd' : 'Nog geen luchtbeeld'}</strong>
</div>
<div className={detectionModelReady ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Model availability</span>
<span>Analysemodel</span>
<strong>
{detectionModelReady
? `${selectedDetectionModel?.display_name ?? selectedDetectionModelId} is configured`
: selectedDetectionModel?.limitation_message ?? 'Select a configured model'}
? 'Lokaal gebouwmodel beschikbaar'
: 'Model niet beschikbaar'}
</strong>
</div>
<div className={detectionHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Tile manifest</span>
<span>Beeldtegels</span>
<strong>
{detectionRequiresTileManifest
? detectionHasTileManifest
? 'Provided for configured YOLO'
: 'Required for configured YOLO'
: 'Not required for this model'}
? 'Beschikbaar'
: 'Maak eerst tegels vanuit het luchtbeeld'
: 'Niet vereist'}
</strong>
</div>
<div className={detectionHasExplicitModelAsset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Local model asset</span>
<span>Lokale modelkeuze</span>
<strong>
{selectedDetectionModelId === 'yolo-configured'
? selectedModelAsset
? selectedModelAsset.display_name
? selectedOperatorProfile?.displayName ?? selectedModelAsset.display_name
: modelAssets.length > 0
? 'Select a local model asset deliberately'
: 'No local assets reported; backend configured path only'
: 'Not required for this model'}
? 'Kies een lokaal model onder beheer'
: 'Geen lokaal model gevonden'
: 'Niet vereist'}
</strong>
</div>
</div>
</div>
<div className={detectionRunReady ? 'lab-action-guardrail lab-action-guardrail-ready' : 'lab-action-guardrail'}>
<span>Run action</span>
<strong>{detectionRunReady ? 'Ready to submit a detection job' : detectionRunBlockedReason}</strong>
<span>Analyse</span>
<strong>{detectionRunReady ? 'Klaar om gebouwen te zoeken' : detectionRunBlockedReason}</strong>
</div>
{rasterDatasets.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No raster datasets available for detection.</strong>
<p>Upload or select a raster dataset in Data before running object detection.</p>
<strong>Geen luchtbeeld beschikbaar in deze werkruimte.</strong>
<p>Voeg onder Bronnen een gegeorefereerde GeoTIFF toe. Daarna kan GeoIntel er beeldtegels en een detectierun van maken.</p>
</div>
) : null}
<div className="lab-form-grid">
<label>
Raster dataset
Luchtbeeld
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
<option value="">Kies een luchtbeeld</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -521,7 +556,7 @@ export function DetectionLab({
</select>
</label>
<label>
Model
Analysemodel
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
@@ -531,7 +566,7 @@ export function DetectionLab({
</select>
</label>
<label>
Min confidence
Minimale zekerheid
<input
type="number"
min="0"
@@ -542,14 +577,14 @@ export function DetectionLab({
/>
{selectedDetectionModelId === 'yolo-configured' ? (
<span className="field-guidance">
Use a validated operator profile, or enter a threshold manually for calibration.
Het actieve gevalideerde profiel gebruikt standaard 0,15 voor een evenwichtige gebouwcontrole.
</span>
) : null}
</label>
</div>
{selectedDetectionModelId === 'yolo-configured' ? (
<label>
Tile manifest
Beeldtegelbestand
<input
type="text"
placeholder="Raster tile manifest path"
@@ -560,13 +595,13 @@ export function DetectionLab({
) : null}
{selectedDetectionModelId === 'yolo-configured' && detectionTileManifestPath.trim() ? (
<div className="linked-manifest-card">
<strong>Linked tile manifest</strong>
<strong>Gekoppelde beeldtegels</strong>
<p>{detectionTileManifestPath}</p>
<span>Refresh preflight after changing model asset or manifest path.</span>
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
</div>
) : null}
<button className="primary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}>
Run detection
Zoek gebouwen
</button>
</div>
</div>
@@ -590,7 +625,12 @@ export function DetectionLab({
) : null}
</div>
<div className="ai-lab-run-surface guided-calibration-surface" aria-label="Guided calibration runner">
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Guided calibration runner">
<summary>
<span>Modelkalibratie voor beheerders</span>
<strong>{detectionCalibrationRows.length > 0 ? `${detectionCalibrationRows.length} drempels getest` : 'gesloten'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-section-header">
<div>
<h3>Guided calibration runner</h3>
@@ -705,23 +745,24 @@ export function DetectionLab({
<p>Choose a reference dataset and threshold set, then start the explicit sweep.</p>
</div>
)}
</div>
</div>
</details>
<div className="ai-lab-results-surface" aria-label="Detection results">
<div className="panel-title-row">
<div>
<h3>Detection results</h3>
<p className="muted">Load persisted detections and filter by class or confidence.</p>
<h3>Gevonden objecten</h3>
<p className="muted">Bekijk eerder bewaarde analyses en filter op type of zekerheid.</p>
</div>
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh runs
Analyses vernieuwen
</button>
</div>
<div className="lab-form-grid">
<label>
Run
Analyse
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select detection run</option>
<option value="">Kies een bewaarde analyse</option>
{detectionRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'detection'} - {run.status} - {run.id}
@@ -730,16 +771,16 @@ export function DetectionLab({
</select>
</label>
<label>
Class
Type object
<input
type="text"
placeholder="Class filter"
placeholder="bijvoorbeeld building"
value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
</label>
<label>
Min confidence
Minimale zekerheid
<input
type="number"
min="0"
@@ -751,18 +792,18 @@ export function DetectionLab({
</label>
</div>
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
Load detections
Resultaten laden
</button>
{loadingDetectionResults ? (
<div className="result-state result-state-loading">
<strong>Loading detection results.</strong>
<p>Retrieving persisted detections for the selected run.</p>
<strong>Resultaten laden.</strong>
<p>De bewaarde objecten van deze analyse worden opgehaald.</p>
</div>
) : null}
<div className="ai-lab-state-stack">
<div className="result-state result-state-ready">
<strong>Detections loaded: {detectionItems.length}</strong>
<p>{selectedDetectionRunId ? 'Loaded from persisted detection records.' : 'Select a detection run before loading results.'}</p>
<strong>{detectionItems.length} objecten geladen</strong>
<p>{selectedDetectionRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p>
</div>
</div>
{detectionItems.length > 0 ? (
@@ -98,44 +98,45 @@ export function ExportCenter({
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length
const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length
const handoffStatus = selectedProjectId ? 'project selected' : 'select project'
const handoffStatus = selectedProjectId ? 'werkruimte gekozen' : 'kies een werkruimte'
const exportTypes = useMemo(() => Array.from(new Set(exports.map((item) => item.export_type))).sort(), [exports])
const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
const latestHandoffArtifacts = useMemo(
() => [
{
key: 'project_report_html',
label: 'Project report',
detail: 'HTML report for human review',
label: 'Leesbaar rapport',
detail: 'HTML-rapport om te bekijken en te delen',
item: getLatestExportByType(exports, 'project_report_html'),
},
{
key: 'project_metadata_json',
label: 'Project metadata',
detail: 'Machine-readable project inventory',
label: 'Werkruimtedata',
detail: 'Technische inventaris in JSON-formaat',
item: getLatestExportByType(exports, 'project_metadata_json'),
},
{
key: 'dataset_geojson',
label: 'Dataset GeoJSON',
detail: 'Selected vector/reference layer',
label: 'Kaartlaag (GeoJSON)',
detail: 'De geselecteerde vector- of referentielaag',
item: getLatestExportByType(exports, 'dataset_geojson'),
},
{
key: 'detection_geojson',
label: 'Detection GeoJSON',
detail: 'Persisted detection geometries',
label: 'Herkende gebouwen (GeoJSON)',
detail: 'Bewaarde geometrieën uit beeldanalyse',
item: getLatestExportByType(exports, 'detection_geojson'),
},
{
key: 'segmentation_geojson',
label: 'Segmentation GeoJSON',
detail: 'Persisted segmentation polygons',
label: 'Segmentaties (GeoJSON)',
detail: 'Bewaarde segmentatiepolygonen',
item: getLatestExportByType(exports, 'segmentation_geojson'),
},
],
[exports],
)
const availableLatestArtifacts = latestHandoffArtifacts.filter((artifact) => artifact.item)
const filteredExports = useMemo(
() =>
exports.filter((item) => {
@@ -154,10 +155,10 @@ export function ExportCenter({
<div className="export-center-shell">
<div className="panel-title-row">
<div>
<h2>Export Center</h2>
<p className="muted">Create handoff artifacts from persisted datasets, AI runs and project metadata.</p>
<h2>Downloads en rapporten</h2>
<p className="muted">Bewaar de geselecteerde kaartlaag, analyseresultaten of een leesbaar projectrapport.</p>
</div>
<span className="count-pill">{exports.length} exports</span>
<span className="count-pill">{exports.length} bestanden</span>
</div>
<div className="export-summary-surface" aria-label="Export summary">
@@ -167,12 +168,12 @@ export function ExportCenter({
<strong>{geojsonExports}</strong>
</div>
<div>
<span>Reports</span>
<span>Rapporten</span>
<strong>{reportExports}</strong>
</div>
<div>
<span>Selected dataset</span>
<strong>{selectedDataset?.name ?? 'none'}</strong>
<span>Geselecteerde kaartlaag</span>
<strong>{selectedDataset?.name ?? 'geen'}</strong>
</div>
</div>
</div>
@@ -188,20 +189,20 @@ export function ExportCenter({
</div>
<div className="handoff-readiness-grid">
<div>
<span>Vector dataset</span>
<strong>{canExportDataset ? selectedDataset?.name : 'none ready'}</strong>
<span>Vectorkaartlaag</span>
<strong>{canExportDataset ? selectedDataset?.name : 'geen beschikbaar'}</strong>
</div>
<div>
<span>Detection run</span>
<strong>{selectedDetectionRunId || 'none'}</strong>
<span>Gebouwanalyse</span>
<strong>{selectedDetectionRunId || 'geen'}</strong>
</div>
<div>
<span>Segmentation run</span>
<strong>{selectedSegmentationRunId || 'none'}</strong>
<span>Segmentatieanalyse</span>
<strong>{selectedSegmentationRunId || 'geen'}</strong>
</div>
<div>
<span>Latest handoff artifact</span>
<strong>{latestExport ? formatExportType(latestExport.export_type) : 'none'}</strong>
<span>Laatste bestand</span>
<strong>{latestExport ? formatExportType(latestExport.export_type) : 'geen'}</strong>
</div>
</div>
</div>
@@ -209,16 +210,16 @@ export function ExportCenter({
<div className="latest-artifact-surface" aria-label="Latest handoff artifacts">
<div>
<h3>Latest handoff artifacts</h3>
<p className="muted">Open the newest artifact of each handoff type without searching the full export history.</p>
<h3>Laatste downloads</h3>
<p className="muted">Open de recentste bestanden zonder in de volledige geschiedenis te zoeken.</p>
</div>
<div className="latest-artifact-grid">
{latestHandoffArtifacts.map((artifact) => {
{availableLatestArtifacts.map((artifact) => {
const item = artifact.item
return (
<div className={item ? 'latest-artifact-card' : 'latest-artifact-card latest-artifact-card-empty'} key={artifact.key}>
<span>{artifact.label}</span>
<strong>{item ? item.status : 'No artifact yet'}</strong>
<strong>{item ? (item.status === 'ready' ? 'Beschikbaar' : item.status) : 'Nog niet gemaakt'}</strong>
<p>{item ? compactPath(item.storage_path) : artifact.detail}</p>
{item ? (
<>
@@ -226,13 +227,13 @@ export function ExportCenter({
<div className="latest-artifact-actions">
{canPreviewJson(item) ? (
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
Preview
Bekijken
</button>
) : (
<span className="status-badge">download-only</span>
<span className="status-badge">alleen downloaden</span>
)}
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
Download
Downloaden
</button>
</div>
</>
@@ -240,19 +241,25 @@ export function ExportCenter({
</div>
)
})}
{availableLatestArtifacts.length === 0 ? (
<div className="result-state result-state-empty latest-download-empty">
<strong>Nog geen downloads gemaakt</strong>
<p>Kies hieronder een rapport of de actieve kaartlaag.</p>
</div>
) : null}
</div>
</div>
<div className="export-actions-surface" aria-label="Export artifact actions">
<div className="panel-title-row">
<div>
<h3>Artifact actions</h3>
<p className="muted">Create review artifacts from the current project, dataset or selected AI analysis run.</p>
<h3>Wat wil je bewaren?</h3>
<p className="muted">Maak een leesbaar rapport of download gegevens voor verder gebruik.</p>
</div>
</div>
<div className="export-action-grid">
<div className="handoff-action-grid">
<div className="handoff-action-card">
<div className="handoff-action-card handoff-action-card-technical">
<span>Refresh</span>
<strong>Export registry</strong>
<p>Reload the persisted artifact list for the selected project.</p>
@@ -267,17 +274,17 @@ export function ExportCenter({
</button>
</div>
<div className="handoff-action-card handoff-action-card-primary">
<span>Project report</span>
<strong>HTML summary</strong>
<p>Generate the human-readable V1 handoff report from persisted project state.</p>
<span>Rapport</span>
<strong>Leesbaar projectoverzicht</strong>
<p>Maak een HTML-rapport met de beschikbare bronnen, controles en resultaten.</p>
<button type="button" className="primary-action" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
Export project report HTML
Projectrapport maken
</button>
</div>
<div className="handoff-action-card">
<span>Metadata</span>
<strong>Project JSON</strong>
<p>Export project, dataset, QA and artifact metadata for machine-readable review.</p>
<span>Technische gegevens</span>
<strong>Projectgegevens (JSON)</strong>
<p>Download de volledige projectinventaris voor geautomatiseerde verwerking.</p>
<button
type="button"
className="secondary-action"
@@ -285,33 +292,33 @@ export function ExportCenter({
disabled={!selectedProjectId || exporting}
data-testid="export-project-metadata"
>
Export project metadata JSON
Projectgegevens downloaden
</button>
</div>
<div className="handoff-action-card">
<span>Vector dataset</span>
<strong>Selected GeoJSON</strong>
<p>Export the active vector/reference dataset when it is selected and ready.</p>
<span>Kaartlaag</span>
<strong>Geselecteerde bron (GeoJSON)</strong>
<p>Download de actieve vector- of referentielaag.</p>
<button type="button" className="secondary-action" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
Export selected vector GeoJSON
Kaartlaag downloaden
</button>
</div>
<div className="handoff-action-card">
{selectedDetectionRunId ? <div className="handoff-action-card">
<span>Detection run</span>
<strong>Detections GeoJSON</strong>
<p>Export persisted detection geometries for the selected analysis run.</p>
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
Export selected detection run GeoJSON
</button>
</div>
<div className="handoff-action-card">
</div> : null}
{selectedSegmentationRunId ? <div className="handoff-action-card">
<span>Segmentation run</span>
<strong>Segmentations GeoJSON</strong>
<p>Export persisted segmentation polygons for the selected analysis run.</p>
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
Export selected segmentation run GeoJSON
</button>
</div>
</div> : null}
</div>
</div>
</div>
@@ -344,7 +351,12 @@ export function ExportCenter({
) : null}
</div>
<div className="export-history-surface" aria-label="Export history">
<details className="export-history-surface export-history-disclosure" aria-label="Export history">
<summary>
<span>Downloadgeschiedenis</span>
<strong>{exports.length} bestanden</strong>
</summary>
<div className="export-history-body">
<div>
<h3>Export history</h3>
<p className="muted">Review persisted artifacts and open JSON previews where supported.</p>
@@ -444,7 +456,8 @@ export function ExportCenter({
</li>
))}
</ul>
</div>
</div>
</details>
</div>
</section>
)
+10 -27
View File
@@ -4,6 +4,7 @@ import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, Qa
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -443,7 +444,6 @@ interface MapWorkspaceProps {
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onSelectProject: (projectId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onSetAreaLayerVisible: (visible: boolean) => void
onSetAreaLayerOpacity: (opacity: number) => void
@@ -512,7 +512,6 @@ export function MapWorkspace({
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
onSelectProject,
onOpenDatasetInMap,
onSetAreaLayerVisible,
onSetAreaLayerOpacity,
@@ -590,16 +589,9 @@ export function MapWorkspace({
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const operationalScopeProjects = useMemo(() => {
const scoped = projects.filter((project) => project.name === MOL_PROJECT_NAME || project.name === KEMPEN_PROJECT_NAME)
const selected = projects.find((project) => project.id === selectedProjectId)
if (selected && !scoped.some((project) => project.id === selected.id)) {
return [selected, ...scoped]
}
return scoped
}, [projects, selectedProjectId])
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
const activeTemporalSeriesGroups = useMemo(
() => listThemeTemporalSeries(availableMapDatasets, activeTheme),
[activeTheme, availableMapDatasets],
@@ -964,19 +956,11 @@ export function MapWorkspace({
<p>Dit zijn databronnen, geen AI-modellen.</p>
</div>
</div>
<label className="geo-project-scope-select">
Regio
<select
aria-label="Regio"
value={selectedProjectId ?? ''}
onChange={(event) => onSelectProject(event.target.value)}
disabled={operationalScopeProjects.length < 2}
>
{operationalScopeProjects.map((project) => (
<option key={project.id} value={project.id}>{operationalScopeProjectLabel(project)}</option>
))}
</select>
</label>
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
<span>Ingeladen bereik</span>
<strong>{activeScopeLabel}</strong>
<small>{municipalityAreaCount > 0 ? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar` : 'Regionale gegevens worden geladen'}</small>
</div>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
@@ -1006,7 +990,7 @@ export function MapWorkspace({
<strong>
{analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'Geen tijdreeks beschikbaar'
: activeThemeDataset?.name ?? 'Geen databron beschikbaar'}
: activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'}
</strong>
<small>
{analysisMode === 'evolution'
@@ -1065,9 +1049,8 @@ export function MapWorkspace({
) : null}
<label className="geo-scope-select">
Werkgebied
Gemeente of volledige regio
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
<option value="">Geen werkgebied</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
))}
@@ -1321,7 +1304,7 @@ export function MapWorkspace({
{analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks'
: activeThemeDataset
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.name}`
? getDatasetDisplayName(activeThemeDataset)
: 'niet beschikbaar'}
</span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
+19 -19
View File
@@ -36,28 +36,28 @@ export function AreaPanel({
<section data-testid="area-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Spatial scope</p>
<h2>AOI manager</h2>
<p className="eyebrow">Geografisch bereik</p>
<h2>Gebieden</h2>
</div>
<span className="count-pill">{areas.length}</span>
</div>
<p className="muted">{selectedProject ? `Project: ${selectedProject.name}` : 'Select a project first'}</p>
<p className="muted">{selectedProject ? selectedProject.name : 'Geen werkruimte actief'}</p>
<div className="data-selection-summary data-selection-summary-area">
<span>Shown on map</span>
<strong>{selectedArea?.name ?? 'No AOI selected'}</strong>
<small>{selectedArea?.area_m2 ? `${selectedArea.area_m2.toFixed(2)} m2` : 'Select an AOI with geometry for spatial review.'}</small>
<span>Getoond op de kaart</span>
<strong>{selectedArea?.name ?? 'Geen gebied geselecteerd'}</strong>
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente of de volledige regio op de kaart.'}</small>
</div>
<details className="data-panel-form-block">
<summary>Create spatial scope</summary>
<summary>Eigen gebied toevoegen (GeoJSON)</summary>
<form className="compact-form" onSubmit={onCreateArea}>
<label>
AOI name
Naam
<input
value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="AOI name"
placeholder="Naam van het gebied"
/>
</label>
<label>
@@ -69,7 +69,7 @@ export function AreaPanel({
/>
</label>
<label>
GeoJSON geometry
GeoJSON-geometrie
<textarea
value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
@@ -77,22 +77,22 @@ export function AreaPanel({
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Create area
Gebied toevoegen
</button>
</form>
</details>
{loadingAreas ? <p>Loading areas...</p> : null}
{areas.length === 0 ? <p>No areas yet</p> : null}
<div className="data-panel-list-block">
<p className="data-section-label">Saved AOIs</p>
{loadingAreas ? <p>Gebieden laden...</p> : null}
{areas.length === 0 ? <p>Nog geen gebieden beschikbaar.</p> : null}
<details className="data-panel-list-block area-catalog-disclosure">
<summary>{areas.length} beschikbare gemeenten en regiogrenzen</summary>
<ul className="entity-list">
{areas.map((area) => (
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
<strong>{area.name}</strong>
<div className="entity-meta">
<span>{area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}</span>
<span>{area.geometry?.type ?? area.geometry_type ?? 'n/a'}</span>
<span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span>
<span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span>
</div>
<button
className="secondary-action"
@@ -101,12 +101,12 @@ export function AreaPanel({
disabled={!area.geometry}
data-testid={`area-show-${area.id}`}
>
{selectedMapAreaId === area.id ? 'Shown on map' : 'Show on map'}
{selectedMapAreaId === area.id ? 'Op kaart' : 'Toon op kaart'}
</button>
</li>
))}
</ul>
</div>
</details>
</section>
)
}
@@ -1,6 +1,12 @@
import type { FormEvent } from 'react'
import type { ProjectCreate, ProjectRead } from '../../types'
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
function isTechnicalProject(project: ProjectRead): boolean {
return TECHNICAL_PROJECT_PATTERN.test(project.name)
}
interface ProjectPanelProps {
projects: ProjectRead[]
selectedProjectId: string | null
@@ -27,68 +33,33 @@ export function ProjectPanel({
onLoadDemoWorkflow,
}: ProjectPanelProps): JSX.Element {
const selectedProject = projects.find((project) => project.id === selectedProjectId)
const regularProjects = projects.filter((project) => !isTechnicalProject(project))
const technicalProjects = projects.filter(isTechnicalProject)
const visibleProjects = selectedProject && isTechnicalProject(selectedProject)
? [selectedProject, ...regularProjects]
: regularProjects
return (
<section className="workspace-panel" data-testid="project-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Context</p>
<h2>Projects</h2>
<p className="eyebrow">Gegevenscontext</p>
<h2>Werkruimtes</h2>
</div>
<span className="count-pill">{projects.length}</span>
<span className="count-pill">{regularProjects.length} beschikbaar</span>
</div>
{loadingProjects ? <p>Loading projects...</p> : null}
{loadingProjects ? <p>Werkruimtes laden...</p> : null}
<div className="data-selection-summary data-selection-summary-project">
<span>Selected project</span>
<strong>{selectedProject?.name ?? 'No active project'}</strong>
<small>{selectedProject?.region ?? 'Create or load a project to start the workbench.'}</small>
</div>
<details className="data-panel-form-block">
<summary>Create project</summary>
<form className="compact-form" onSubmit={onCreateProject}>
<label>
Name
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Project name"
/>
</label>
<label>
Description
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Description"
/>
</label>
<label>
Region
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Region"
/>
</label>
<button className="primary-action" type="submit">
Create project
</button>
</form>
</details>
<div className="demo-actions">
<button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
{loadingDemoWorkflow ? 'Loading demo...' : 'Load demo workflow'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
<span>Actieve werkruimte</span>
<strong>{selectedProject?.name ?? 'Geen werkruimte actief'}</strong>
<small>{selectedProject?.region ?? 'De regionale werkruimte wordt automatisch gekozen.'}</small>
</div>
<div className="data-panel-list-block">
<p className="data-section-label">Saved projects</p>
<p className="data-section-label">Beschikbare werkruimtes</p>
<ul className="entity-list">
{projects.map((project) => (
{visibleProjects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
@@ -98,18 +69,78 @@ export function ProjectPanel({
data-testid={`project-select-${project.id}`}
>
<strong>{project.name}</strong>
<span>{project.region || 'No region'}</span>
<span>{project.region || 'Regio niet opgegeven'}</span>
</button>
</li>
))}
{projects.length === 0 ? (
{regularProjects.length === 0 ? (
<li className="empty-state">
<strong>No projects yet</strong>
<p>Create a project or load the offline demo workflow to populate the workbench.</p>
<strong>Nog geen werkruimte beschikbaar</strong>
<p>Maak een werkruimte aan of laad de demonstratiegegevens.</p>
</li>
) : null}
</ul>
</div>
<details className="data-panel-form-block technical-management-block">
<summary>Geavanceerd projectbeheer</summary>
<div className="technical-management-body">
<form className="compact-form" onSubmit={onCreateProject}>
<label>
Naam
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Naam van de werkruimte"
/>
</label>
<label>
Beschrijving
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Korte beschrijving"
/>
</label>
<label>
Regio
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Regio"
/>
</label>
<button className="primary-action" type="submit">Nieuwe werkruimte</button>
</form>
<div className="demo-actions">
<button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
{loadingDemoWorkflow ? 'Demo laden...' : 'Demonstratiegegevens laden'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div>
{technicalProjects.length > 0 ? (
<details className="technical-run-list">
<summary>{technicalProjects.length} technische test- en benchmarkruns</summary>
<ul className="entity-list">
{technicalProjects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
type="button"
onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId}
data-testid={`project-select-${project.id}`}
>
<strong>{project.name}</strong>
<span>{project.region || 'Technische run'}</span>
</button>
</li>
))}
</ul>
</details>
) : null}
</div>
</details>
</section>
)
}
@@ -7,6 +7,23 @@ interface ProviderPanelProps {
onRefresh: () => void
}
function providerStatusLabel(provider: ProviderCapability): string {
if (provider.configured) {
return 'Beschikbaar'
}
return 'Live koppeling uit'
}
function authorityLabel(value: string): string {
const labels: Record<string, string> = {
authoritative: 'Officieel',
contextual: 'Aanvullend',
manual: 'Eigen upload',
fixture: 'Testgegevens',
}
return labels[value] ?? value
}
export function ProviderPanel({
providers,
loadingCapabilities,
@@ -18,40 +35,40 @@ export function ProviderPanel({
<div className="system-provider-shell">
<div className="panel-title-row">
<div>
<h2>Provider Capabilities</h2>
<p className="muted">Review configured local providers and honest not-configured external sources.</p>
<h2>Databronnen</h2>
<p className="muted">Beschikbaarheid en herkomst van officiële, aanvullende en eigen bronnen.</p>
</div>
<button type="button" className="secondary-action" onClick={onRefresh} disabled={loadingCapabilities}>
Refresh providers
Status vernieuwen
</button>
</div>
<div className="system-provider-state-stack">
{loadingCapabilities ? (
<div className="result-state result-state-loading">
<strong>Loading provider capabilities.</strong>
<p>Checking local, fixture and external provider registry states.</p>
<strong>Databronnen laden.</strong>
<p>De lokale en externe bronstatus wordt gecontroleerd.</p>
</div>
) : null}
{capabilitiesError ? (
<div className="result-state result-state-error">
<strong>Provider registry unavailable.</strong>
<strong>Bronregister niet bereikbaar.</strong>
<p>{capabilitiesError}</p>
</div>
) : null}
{providers.length === 0 && !loadingCapabilities ? (
<div className="result-state result-state-empty">
<strong>No providers reported by backend.</strong>
<p>Refresh provider capabilities after the backend is reachable.</p>
<strong>Geen databronnen gemeld.</strong>
<p>Vernieuw de status zodra de backend bereikbaar is.</p>
</div>
) : null}
</div>
<div className="system-provider-capability-surface" aria-label="Provider capability registry">
<div className="provider-detail-stack">
<strong>Official reference sources</strong>
<strong>Officiële referentiebronnen</strong>
<div>
GRB and OSM are reference-data provider capabilities, not AI model choices. Manual upload is the configured path for real reference datasets today; fixtures remain test/demo only.
Reeds ingeladen GRB-lagen blijven lokaal beschikbaar. De live GRB- en OSM-koppelingen staan uit en halen dus niet ongemerkt externe gegevens op.
</div>
</div>
<ul className="system-provider-list">
@@ -62,24 +79,16 @@ export function ProviderPanel({
<strong>{provider.display_name}</strong>
<span>{provider.provider_name}</span>
</div>
<span className={provider.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{provider.status}</span>
<span className={provider.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{providerStatusLabel(provider)}</span>
</div>
<div className="system-provider-grid">
<div>
<span>Authority</span>
<strong>{provider.authority_level}</strong>
<span>Herkomst</span>
<strong>{authorityLabel(provider.authority_level)}</strong>
</div>
<div>
<span>Configured</span>
<strong>{provider.configured ? 'yes' : 'no'}</strong>
</div>
<div>
<span>Geometry</span>
<strong>{provider.supported_geometry_types.join(', ')}</strong>
</div>
<div>
<span>Query modes</span>
<strong>{provider.supported_query_modes.join(', ')}</strong>
<span>Status</span>
<strong>{providerStatusLabel(provider)}</strong>
</div>
</div>
<div className="provider-layer-list" aria-label={`${provider.provider_name} supported layers`}>
@@ -89,22 +98,29 @@ export function ProviderPanel({
</span>
))}
</div>
<div className="provider-detail-stack">
<div>limitation: {provider.limitation_message}</div>
{!provider.configured && provider.not_configured_reason ? (
<div>reason: {provider.not_configured_reason}</div>
) : null}
</div>
<div className="provider-provenance-grid">
<div className="provider-provenance-card">
<span>Attribution</span>
<strong>{provider.attribution}</strong>
<details className="provider-technical-details">
<summary>Herkomst en technische mogelijkheden</summary>
<div className="provider-detail-stack">
<div>{provider.limitation_message}</div>
{!provider.configured && provider.not_configured_reason ? (
<div>{provider.not_configured_reason}</div>
) : null}
</div>
<div className="provider-provenance-card">
<span>License</span>
<strong>{provider.license_note}</strong>
<div className="provider-provenance-grid">
<div className="provider-provenance-card">
<span>Naamsvermelding</span>
<strong>{provider.attribution}</strong>
</div>
<div className="provider-provenance-card">
<span>Licentie</span>
<strong>{provider.license_note}</strong>
</div>
</div>
</div>
<div className="entity-meta">
<span>Geometrie: {provider.supported_geometry_types.join(', ')}</span>
<span>Zoekwijzen: {provider.supported_query_modes.join(', ')}</span>
</div>
</details>
</li>
))}
</ul>
@@ -147,50 +147,63 @@ export function QualityResultsPanel({
const hasActiveQualityFilters = qualityStatusFilter !== 'all' || qualityTypeFilter !== 'all' || qualitySearchQuery.trim().length > 0
return (
<section className="quality-results-panel" data-testid="quality-results-panel">
<section className={qualityChecks.length === 0 ? 'quality-results-panel quality-results-panel-empty' : 'quality-results-panel'} data-testid="quality-results-panel">
<div className="quality-results-shell">
<div className="panel-title-row">
<div>
<h2>QA/QC Results</h2>
<p className="muted">Persisted quality checks from dataset, detection and segmentation comparisons.</p>
<h2>Kwaliteitsresultaten</h2>
<p className="muted">Bewaarde controles van kaartlagen en beeldanalyses.</p>
</div>
<span className="count-pill">{qualityChecks.length} checks</span>
<span className="count-pill">{qualityChecks.length} controles</span>
</div>
<div className="quality-summary-surface" aria-label="QA/QC result summary">
<div className="quality-summary-grid">
<div>
<span>Completed</span>
<span>Afgerond</span>
<strong>{completedChecks}</strong>
</div>
<div>
<span>Latest score</span>
<span>Laatste score</span>
<strong>{latestCheck?.score ?? 'n/a'}</strong>
</div>
<div>
<span>Latest status</span>
<span>Laatste status</span>
<strong>{latestCheck?.status ?? 'waiting'}</strong>
</div>
</div>
</div>
{qualityChecks.length === 0 ? (
<div className="result-state result-state-empty quality-user-empty-state">
<strong>Nog geen kwaliteitscontrole uitgevoerd</strong>
<p>Selecteer een gebied op de kaart en vergelijk een bewaard resultaat met een officiële referentielaag.</p>
</div>
) : null}
{qualityChecks.length > 0 ? (
<details className="quality-advanced-disclosure">
<summary>
<span>Details, kaartbewijs en historiek</span>
<strong>{qualityChecks.length} bewaarde controles</strong>
</summary>
<div className="quality-evidence-surface" aria-label="QA/QC dataset evidence">
<div className="quality-handoff-grid" aria-label="QA/QC dataset handoff context">
<div>
<span>Candidate layers</span>
<span>Te controleren lagen</span>
<strong>{candidateDatasets.length}</strong>
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'No candidate layer selected yet'}</p>
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'Nog geen te controleren laag gekozen'}</p>
</div>
<div>
<span>Reference layers</span>
<span>Referentielagen</span>
<strong>{referenceDatasets.length}</strong>
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'No reference layer available yet'}</p>
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'Nog geen referentielaag beschikbaar'}</p>
</div>
<div>
<span>Latest comparison</span>
<span>Laatste vergelijking</span>
<strong>{latestCheck?.status ?? 'waiting'}</strong>
<p className="quality-dataset-name">
Candidate {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n/a'} / Reference {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n/a'}
Te controleren: {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n.v.t.'} / referentie: {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n.v.t.'}
</p>
</div>
</div>
@@ -199,8 +212,8 @@ export function QualityResultsPanel({
<div className="quality-drilldown-surface" aria-label="QA/QC evidence drilldown">
<div className="panel-title-row">
<div>
<h3>QA/QC evidence drilldown</h3>
<p className="muted">Inspect the persisted check, metric evidence and dataset handoff context before exporting or sharing results.</p>
<h3>Bewijs van de kwaliteitscontrole</h3>
<p className="muted">Bekijk de bewaarde vergelijking, scores en kaartlagen voordat je resultaten deelt.</p>
</div>
<button
type="button"
@@ -208,7 +221,7 @@ export function QualityResultsPanel({
onClick={() => setSelectedQualityCheckId(latestCheck?.id ?? null)}
disabled={!latestCheck}
>
Inspect latest check
Laatste controle bekijken
</button>
<button
type="button"
@@ -216,12 +229,12 @@ export function QualityResultsPanel({
onClick={() => latestCheck?.id && onOpenEvidenceMap?.(latestCheck.id)}
disabled={!latestCheck || evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Loading map evidence...' : 'Show latest on map'}
{evidenceLoading ? 'Kaartbewijs laden...' : 'Laatste op kaart tonen'}
</button>
</div>
{evidenceError ? (
<div className="result-state result-state-error">
<strong>QA/QC evidence overlay could not be loaded.</strong>
<strong>Het bewijs kon niet op de kaart worden geladen.</strong>
<p>{evidenceError}</p>
</div>
) : null}
@@ -229,22 +242,22 @@ export function QualityResultsPanel({
<>
<div className="quality-drilldown-grid">
<div>
<span>Selected check</span>
<span>Geselecteerde controle</span>
<strong>{selectedQualityCheck.check_type}</strong>
<p>{selectedQualityCheck.id}</p>
</div>
<div>
<span>Candidate layer</span>
<span>Te controleren laag</span>
<strong>{selectedCandidateName}</strong>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'candidate not stored'}</p>
</div>
<div>
<span>Reference layer</span>
<span>Referentielaag</span>
<strong>{selectedReferenceName}</strong>
<p>{selectedQualityCheck.reference_dataset_id}</p>
</div>
<div>
<span>Analysis run</span>
<span>Analyserun</span>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n/a'}</strong>
<p>Job: {selectedQualityCheck.job_id ?? 'n/a'}</p>
</div>
@@ -254,39 +267,39 @@ export function QualityResultsPanel({
<p>Score: {selectedQualityCheck.score ?? 'n/a'}</p>
</div>
<div>
<span>Completed</span>
<span>Afgerond</span>
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
<p>Created: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<div className="quality-evidence-token-grid">
<div>
<span>False positive evidence</span>
<span>Onterecht gevonden</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_positive_count'))}</strong>
<p>Candidate geometries not matched to the reference layer.</p>
<p>Gevonden vormen zonder overeenkomst in de referentielaag.</p>
</div>
<div>
<span>False negative evidence</span>
<span>Gemiste objecten</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_negative_count'))}</strong>
<p>Reference geometries not matched by the candidate layer.</p>
<p>Objecten uit de referentielaag die niet werden gevonden.</p>
</div>
<div className="quality-evidence-map-handoff">
<span>Map evidence handoff</span>
<span>Controle op de kaart</span>
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
<p>Use the candidate and reference datasets as map layers for spatial review.</p>
<p>Toon beide lagen samen op de kaart voor een ruimtelijke controle.</p>
<button
type="button"
className="secondary-action"
onClick={() => onOpenEvidenceMap?.(selectedQualityCheck.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Loading overlay...' : 'Show evidence overlay'}
{evidenceLoading ? 'Lagen laden...' : 'Bewijs op kaart tonen'}
</button>
</div>
</div>
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
<div>
<span>Matched feature ids</span>
<span>Overeenkomende object-ID's</span>
{selectedMatchEvidence.length > 0 ? (
<ul>
{selectedMatchEvidence.slice(0, 6).map((item, index) => (
@@ -294,11 +307,11 @@ export function QualityResultsPanel({
))}
</ul>
) : (
<p>No matched feature ids persisted for this check.</p>
<p>Voor deze controle zijn geen overeenkomende object-ID's bewaard.</p>
)}
</div>
<div>
<span>False positive feature ids</span>
<span>ID's van onterecht gevonden objecten</span>
{selectedFalsePositiveEvidence.length > 0 ? (
<ul>
{selectedFalsePositiveEvidence.slice(0, 6).map((item, index) => (
@@ -306,11 +319,11 @@ export function QualityResultsPanel({
))}
</ul>
) : (
<p>No false-positive feature ids persisted for this check.</p>
<p>Er zijn geen ID's van onterecht gevonden objecten bewaard.</p>
)}
</div>
<div>
<span>False negative feature ids</span>
<span>ID's van gemiste objecten</span>
{selectedFalseNegativeEvidence.length > 0 ? (
<ul>
{selectedFalseNegativeEvidence.slice(0, 6).map((item, index) => (
@@ -318,19 +331,19 @@ export function QualityResultsPanel({
))}
</ul>
) : (
<p>No false-negative feature ids persisted for this check.</p>
<p>Er zijn geen ID's van gemiste objecten bewaard.</p>
)}
</div>
</div>
<div className="quality-provenance-grid">
<div>
<span>Parameters</span>
<span>Instellingen</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.parameters_json ?? {}, null, 2)}
</pre>
</div>
<div>
<span>Findings</span>
<span>Bevindingen</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.findings_json ?? {}, null, 2)}
</pre>
@@ -339,8 +352,8 @@ export function QualityResultsPanel({
</>
) : (
<div className="result-state result-state-empty">
<strong>No QA/QC check selected.</strong>
<p>Run or load a persisted QA/QC check to inspect metric and map evidence.</p>
<strong>Geen kwaliteitscontrole geselecteerd.</strong>
<p>Kies een bewaarde controle om scores en kaartbewijs te bekijken.</p>
</div>
)}
</div>
@@ -354,21 +367,21 @@ export function QualityResultsPanel({
disabled={!selectedProjectId}
data-testid="refresh-quality-results"
>
Refresh QA/QC results
Kwaliteitsresultaten vernieuwen
</button>
</div>
<div className="quality-result-state-stack">
{qualityChecksError ? (
<div className="result-state result-state-error">
<strong>QA/QC results could not be loaded.</strong>
<strong>De kwaliteitsresultaten konden niet worden geladen.</strong>
<p>{qualityChecksError}</p>
</div>
) : null}
{qualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No persisted QA/QC results yet</strong>
<p>Run dataset, detection or segmentation QA against a reference layer to populate this workspace.</p>
<strong>Nog geen bewaarde kwaliteitsresultaten</strong>
<p>Vergelijk een kaartlaag of beeldanalyse met een referentielaag om deze werkruimte te vullen.</p>
</div>
) : null}
</div>
@@ -376,7 +389,7 @@ export function QualityResultsPanel({
{qualityChecks.length > 0 ? (
<div className="quality-history-controls" aria-label="QA/QC result filters">
<label>
Search QA/QC results
Kwaliteitsresultaten zoeken
<input
type="search"
placeholder="Type, id or dataset"
@@ -387,7 +400,7 @@ export function QualityResultsPanel({
<label>
Status
<select value={qualityStatusFilter} onChange={(event) => setQualityStatusFilter(event.target.value)}>
<option value="all">All statuses</option>
<option value="all">Alle statussen</option>
{qualityStatuses.map((status) => (
<option key={status} value={status}>
{status}
@@ -396,9 +409,9 @@ export function QualityResultsPanel({
</select>
</label>
<label>
Check type
Type controle
<select value={qualityTypeFilter} onChange={(event) => setQualityTypeFilter(event.target.value)}>
<option value="all">All check types</option>
<option value="all">Alle types</option>
{qualityTypes.map((checkType) => (
<option key={checkType} value={checkType}>
{checkType}
@@ -417,33 +430,33 @@ export function QualityResultsPanel({
}}
disabled={!hasActiveQualityFilters && !showAllQualityChecks}
>
Reset view
Filters wissen
</button>
</div>
) : null}
{filteredQualityChecks.length > 8 ? (
<div className="list-limit-banner">
<span className="quality-list-count">
Showing {visibleQualityChecks.length} of {filteredQualityChecks.length} matching QA/QC results.
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} older checks hidden.` : ' All matching checks shown.'}
{visibleQualityChecks.length} van {filteredQualityChecks.length} kwaliteitsresultaten getoond.
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} oudere controles verborgen.` : ' Alle resultaten zijn zichtbaar.'}
</span>
<button type="button" className="secondary-action" onClick={() => setShowAllQualityChecks((value) => !value)}>
{showAllQualityChecks ? 'Show latest 8' : 'Show all QA/QC results'}
{showAllQualityChecks ? 'Alleen laatste 8 tonen' : 'Alle kwaliteitsresultaten tonen'}
</button>
</div>
) : null}
{qualityChecks.length > 0 && filteredQualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No QA/QC results match the current filters.</strong>
<p>Clear the search, status or check type filter to return to the latest quality results.</p>
<strong>Geen kwaliteitsresultaten voldoen aan de filters.</strong>
<p>Wis de zoek-, status- of typefilter om de laatste resultaten opnieuw te tonen.</p>
</div>
) : null}
</div>
<div className="quality-history-surface" aria-label="QA/QC result history">
<div className="panel-title-row">
<h3>Result history</h3>
<span className="count-pill">{visibleQualityChecks.length} shown</span>
<h3>Historiek</h3>
<span className="count-pill">{visibleQualityChecks.length} getoond</span>
</div>
<ul className="quality-check-list">
{visibleQualityChecks.map((check) => (
@@ -466,7 +479,7 @@ export function QualityResultsPanel({
</div>
<div className="quality-check-actions">
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
Inspect check
Controle bekijken
</button>
<button
type="button"
@@ -474,7 +487,7 @@ export function QualityResultsPanel({
onClick={() => onOpenEvidenceMap?.(check.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
Show evidence on map
Bewijs op kaart tonen
</button>
</div>
<div className="quality-score-row">
@@ -483,16 +496,16 @@ export function QualityResultsPanel({
<strong>{check.score ?? 'n/a'}</strong>
</div>
<div>
<span>Metrics</span>
<span>Meetwaarden</span>
<strong>{check.metrics.length}</strong>
</div>
<div>
<span>Quality check</span>
<span>Controle-ID</span>
<strong>{check.id}</strong>
</div>
</div>
<div className="quality-metric-section">
<span>Metric evidence</span>
<span>Gemeten kwaliteit</span>
<div className="quality-metric-grid">
{CORE_METRIC_ORDER.map((metricKey) => {
const metric = check.metrics.find((item) => item.metric_key === metricKey)
@@ -506,7 +519,7 @@ export function QualityResultsPanel({
})}
</div>
</div>
<span className="metric-list-label">Raw metrics</span>
<span className="metric-list-label">Alle meetwaarden</span>
<ul className="metric-list">
{check.metrics.map((metric) => (
<li key={metric.id}>
@@ -519,6 +532,8 @@ export function QualityResultsPanel({
))}
</ul>
</div>
</details>
) : null}
</div>
</section>
)
+2
View File
@@ -2,6 +2,8 @@ import type { DatasetCreateResponse, ProjectRead } from '../types'
export const PRIMARY_FOCUS_LABEL = 'Mol'
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
export const PRIMARY_FOCUS_CENTER: [number, number] = [5.1167, 51.1919]
export const PRIMARY_FOCUS_AREA_NAME = 'Mol primary AOI'
export const PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'
+25 -7
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { detectionApi } from '../services/api'
import type {
DatasetCreateResponse,
@@ -72,10 +72,10 @@ export function useDetectionWorkflow({
const [detectionModelError, setDetectionModelError] = useState<string | null>(null)
const [modelAssetError, setModelAssetError] = useState<string | null>(null)
const [selectedDetectionDatasetId, setSelectedDetectionDatasetId] = useState('')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-placeholder')
const [selectedDetectionModelId, setSelectedDetectionModelId] = useState('yolo-configured')
const [selectedModelAssetId, setSelectedModelAssetId] = useState('')
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.5)
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
@@ -98,6 +98,12 @@ export function useDetectionWorkflow({
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
useEffect(() => {
if (!selectedDetectionDatasetId && rasterDatasets.length > 0) {
setSelectedDetectionDatasetId(rasterDatasets[0].id)
}
}, [rasterDatasets, selectedDetectionDatasetId])
const loadDetectionModels = async () => {
setLoadingDetectionModels(true)
setDetectionModelError(null)
@@ -105,8 +111,12 @@ export function useDetectionWorkflow({
try {
const response = await detectionApi.listModels()
setDetectionModels(response.models)
if (!response.models.some((model) => model.model_id === selectedDetectionModelId) && response.models.length > 0) {
setSelectedDetectionModelId(response.models[0].model_id)
const selectedModelStillAvailable = response.models.some((model) => model.model_id === selectedDetectionModelId)
if (!selectedModelStillAvailable && response.models.length > 0) {
const configuredModel = response.models.find(
(model) => model.configured && model.model_id !== 'manual-fixture-detector',
)
setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id)
}
} catch (error) {
setDetectionModelError(formatError(error, 'Failed to load detection models'))
@@ -114,8 +124,16 @@ export function useDetectionWorkflow({
try {
const assetResponse = await detectionApi.listModelAssets()
setModelAssets(assetResponse.items)
if (!assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)) {
setSelectedModelAssetId('')
const activeAsset = assetResponse.items.find((asset) => asset.active) ?? null
const selectedAssetStillAvailable = assetResponse.items.some((asset) => asset.model_asset_id === selectedModelAssetId)
const nextAssetId = selectedAssetStillAvailable ? selectedModelAssetId : activeAsset?.model_asset_id ?? ''
setSelectedModelAssetId(nextAssetId)
try {
const preflight = await detectionApi.getYoloPreflight({ model_asset_id: nextAssetId || null })
setYoloPreflight(preflight)
setYoloPreflightError(null)
} catch (error) {
setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status'))
}
} catch (error) {
setModelAssets([])
+12
View File
@@ -3,6 +3,7 @@ import {
PRIMARY_FOCUS_AREA_GEOJSON,
PRIMARY_FOCUS_AREA_NAME,
PRIMARY_FOCUS_REGION,
REGIONAL_WORKSPACE_PROJECT_NAME,
isPrimaryFocusMunicipalityBoundaryDataset,
isPrimaryFocusMunicipalityProject,
isPrimaryFocusProject,
@@ -79,6 +80,17 @@ export function useProjectWorkspace() {
if (selectedProjectId && items.some((project) => project.id === selectedProjectId)) {
return selectedProjectId
}
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
if (regionalProject) {
try {
const data = await fetchProjectData(regionalProject.id)
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
return regionalProject.id
}
} catch {
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
}
}
const municipalityProject = items.find(isPrimaryFocusMunicipalityProject)
if (municipalityProject) {
try {
+24
View File
@@ -0,0 +1,24 @@
import type { DatasetCreateResponse } from '../types'
const DATASET_LABEL_BY_LAYER: Record<string, string> = {
buildings: 'Bebouwing',
roads: 'Wegen',
water: 'Water',
parcels: 'Percelen',
population: 'Bevolking',
forest: 'Bos en groen',
regional_boundary: 'Grens vervoerregio Kempen',
municipality_boundaries: 'Gemeentegrenzen Kempen',
}
export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? '')
.toString()
.toLowerCase()
const label = DATASET_LABEL_BY_LAYER[layer]
if (!label) {
return dataset.name
}
const source = (dataset.source_name ?? dataset.source).toUpperCase()
return `${label} · ${source}`
}
+279
View File
@@ -1493,6 +1493,285 @@ details.ai-lab-model-surface > summary strong {
}
}
/* End-user workbench simplification */
.geo-loaded-scope {
display: grid;
gap: 0.2rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.65rem 0.7rem;
background: #f7faf9;
}
.geo-loaded-scope span,
.geo-loaded-scope small {
color: var(--muted);
font-size: 0.72rem;
}
.geo-loaded-scope strong {
color: var(--text);
font-size: 0.88rem;
}
.technical-management-body {
display: grid;
gap: 0.8rem;
border-top: 1px solid var(--line);
padding: 0.8rem;
background: #ffffff;
}
.technical-management-body > .compact-form {
padding: 0;
}
.technical-run-list,
.area-catalog-disclosure,
.dataset-technical-details,
.quality-advanced-disclosure,
.provider-technical-details,
.secondary-analysis-disclosure,
.status-details-disclosure,
.export-history-disclosure {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 6px;
background: #f8fafb;
}
.technical-run-list > summary,
.area-catalog-disclosure > summary,
.dataset-technical-details > summary,
.quality-advanced-disclosure > summary,
.provider-technical-details > summary,
.secondary-analysis-disclosure > summary,
.status-details-disclosure > summary,
.export-history-disclosure > summary {
display: flex;
min-height: 2.7rem;
cursor: pointer;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
padding: 0.62rem 0.75rem;
color: #33424d;
font-size: 0.8rem;
font-weight: 750;
list-style: none;
}
.technical-run-list > summary::-webkit-details-marker,
.area-catalog-disclosure > summary::-webkit-details-marker,
.dataset-technical-details > summary::-webkit-details-marker,
.quality-advanced-disclosure > summary::-webkit-details-marker,
.provider-technical-details > summary::-webkit-details-marker,
.secondary-analysis-disclosure > summary::-webkit-details-marker,
.status-details-disclosure > summary::-webkit-details-marker,
.export-history-disclosure > summary::-webkit-details-marker {
display: none;
}
.technical-run-list > summary::after,
.area-catalog-disclosure > summary::after,
.dataset-technical-details > summary::after,
.quality-advanced-disclosure > summary::after,
.provider-technical-details > summary::after,
.secondary-analysis-disclosure > summary::after,
.status-details-disclosure > summary::after,
.export-history-disclosure > summary::after {
content: '+';
margin-left: auto;
color: var(--accent-strong);
font-size: 1rem;
}
.technical-run-list[open] > summary::after,
.area-catalog-disclosure[open] > summary::after,
.dataset-technical-details[open] > summary::after,
.quality-advanced-disclosure[open] > summary::after,
.provider-technical-details[open] > summary::after,
.secondary-analysis-disclosure[open] > summary::after,
.status-details-disclosure[open] > summary::after,
.export-history-disclosure[open] > summary::after {
content: '-';
}
.technical-run-list .entity-list,
.area-catalog-disclosure .entity-list {
max-height: 28rem;
overflow: auto;
border-top: 1px solid var(--line);
padding: 0.65rem;
background: #ffffff;
}
.quality-advanced-disclosure {
margin-top: 0.75rem;
}
.quality-advanced-disclosure > summary strong {
margin-right: 0.25rem;
color: var(--ink);
font-size: 0.76rem;
}
.quality-advanced-disclosure > div {
margin: 0;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
}
.dataset-technical-details {
margin-top: 0.65rem;
}
.dataset-technical-details > div,
.dataset-technical-details > button,
.provider-technical-details > div {
margin: 0 0.7rem 0.7rem;
}
.dataset-technical-details > summary,
.provider-technical-details > summary {
min-height: 2.3rem;
padding-block: 0.45rem;
}
.dataset-card-primary-meta {
margin-top: 0.25rem;
}
.ai-user-summary {
order: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.ai-user-summary-card {
display: grid;
min-width: 0;
gap: 0.25rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.75rem;
background: #ffffff;
}
.ai-user-summary-card-primary {
border-color: #bfd8d2;
background: var(--accent-soft);
}
.ai-user-summary-card-ready {
border-left: 3px solid var(--success);
}
.ai-user-summary-card span,
.ai-user-summary-card p {
margin: 0;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
}
.ai-user-summary-card strong {
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.95rem;
}
.secondary-analysis-disclosure {
align-self: start;
}
.secondary-analysis-disclosure > summary strong,
.status-details-disclosure > summary strong,
.export-history-disclosure > summary strong {
margin-left: auto;
color: var(--muted);
font-size: 0.72rem;
}
.secondary-analysis-disclosure > section,
.secondary-analysis-disclosure > .workspace-panel {
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
}
.status-details-disclosure > section {
margin: 0;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
}
.quality-results-panel-empty .quality-evidence-surface,
.quality-results-panel-empty .quality-drilldown-surface,
.quality-results-panel-empty .quality-control-surface,
.quality-results-panel-empty .quality-history-surface {
display: none;
}
.quality-user-empty-state {
margin-top: 0.75rem;
}
.export-summary-surface,
.export-handoff-surface,
.handoff-action-card-technical {
display: none;
}
.latest-download-empty {
grid-column: 1 / -1;
}
.export-history-body {
display: grid;
gap: 0.75rem;
border-top: 1px solid var(--line);
padding: 0.75rem;
background: #ffffff;
}
.export-history-body > div:first-child {
display: none;
}
@media (min-width: 1100px) {
.workspace-grid-analysis {
grid-template-columns: minmax(0, 1fr);
}
.workspace-grid-analysis > section:nth-child(n),
.workspace-grid-analysis > details:nth-child(n) {
max-height: none;
overflow: visible;
}
}
@media (max-width: 1180px) {
.ai-user-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.ai-user-summary {
grid-template-columns: minmax(0, 1fr);
}
.context-bar > div:nth-child(n + 3) {
display: none;
}
}
@media (min-width: 621px) {
.status-strip-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));