feat: simplify regional workbench language
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 23:15:50 +02:00
parent 0381fdd636
commit b50b250f72
18 changed files with 292 additions and 97 deletions
+3 -1
View File
@@ -16,7 +16,9 @@
- Preserved separate Mol/regional series keys and honest partial-sector population and 10 m forest-area limitations.
- Replaced internal provider identifiers with readable source labels in the primary map.
- Added a provenance-gated full-Area query path for official pre-clipped datasets, avoiding redundant intersection of every feature against the same detailed municipal/regional boundary while preserving exact rectangle selection behavior.
- Added focused scope filtering, command construction, boundary resolution, multi-NIS provenance, packaging and frontend-label tests.
- Reduced a live complete-Kempen population/forest analysis from roughly 92 seconds to 2.8 seconds of API work while retaining the exact official totals.
- Grouped dated population and forest snapshots into one current source plus an optional historical disclosure, translated remaining user-facing status/download/model-evaluation text and made municipality selection explicitly optional.
- Added focused scope filtering, command construction, boundary resolution, multi-NIS provenance, full-Area correctness, temporal-language and frontend-label tests; the full readiness gate now passes with 571 tests.
## Sprint 193 End-user regional workbench simplification (2026-07-14)
@@ -200,7 +200,7 @@ class TemporalAnalysisService:
return (
TemporalObjectChanges(available=False),
{"type": "FeatureCollection", "features": []},
["Object-level changes are unavailable because the source does not guarantee stable feature identifiers."],
["Wijzigingen van individuele objecten kunnen voor deze bron niet betrouwbaar worden gevolgd."],
)
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
@@ -15,19 +15,19 @@ def test_detection_lab_exposes_persisted_threshold_calibration_comparison() -> N
assert "qualityChecks: QualityCheckRead[]" in source
assert "buildCalibrationRows(detectionRuns, qualityChecks)" in source
assert "Calibration comparison" in source
assert "Compare persisted detection runs by confidence threshold" in source
assert "Best F1 candidate" in source
assert "Best precision candidate" in source
assert "Lowest false-positive pressure" in source
assert "Threshold" in source
assert "Precision" in source
assert "Kalibraties vergelijken" in source
assert "Vergelijk bewaarde analyseruns per zekerheidsdrempel" in source
assert "Beste F1-score" in source
assert "Beste precisie" in source
assert "Minste foutieve meldingen" in source
assert "Drempel" in source
assert "Precisie" in source
assert "Recall" in source
assert "F1" in source
assert "False positives" in source
assert "False negatives" in source
assert "Promote only after checking evidence across AOIs" in source
assert "No calibration comparison available yet" in source
assert "Fout positief" in source
assert "Fout negatief" in source
assert "Keur pas goed nadat meerdere gebieden" in source
assert "Nog geen kalibratievergelijking beschikbaar" in source
assert "metricValue(check, 'f1')" in source
assert "metricValue(check, 'precision')" in source
assert "metricValue(check, 'recall')" in source
@@ -100,8 +100,8 @@ def test_detection_results_table_uses_bounded_local_pagination() -> None:
assert "visibleDetectionItems" in lab
assert "detectionItems.slice" in lab
assert "Detection result pagination" in lab
assert "Previous detection results page" in lab
assert "Next detection results page" in lab
assert "Vorige resultatenpagina" in lab
assert "Volgende resultatenpagina" in lab
assert "formatSourceTilePath(detection.source_tile_path)" in lab
assert 'title={detection.source_tile_path ?? undefined}' in lab
assert "pagination-toolbar" in styles
@@ -274,6 +274,26 @@ def test_temporal_compare_returns_delta_and_canonical_change_payload(monkeypatch
assert result.geojson["type"] == "FeatureCollection"
def test_unstable_temporal_identity_returns_clear_end_user_warning() -> None:
project_id = uuid4()
earlier = temporal_dataset(project_id=project_id, observed_year=2021)
later = temporal_dataset(project_id=project_id, observed_year=2025)
earlier.source_metadata["identity_stable"] = False
later.source_metadata["identity_stable"] = False
changes, geojson, warnings = TemporalAnalysisService._compare_identity_features(
SimpleNamespace(),
earlier=earlier,
later=later,
bbox={"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3},
preview_limit=100,
)
assert changes.available is False
assert geojson == {"type": "FeatureCollection", "features": []}
assert warnings == ["Wijzigingen van individuele objecten kunnen voor deze bron niet betrouwbaar worden gevolgd."]
def test_temporal_frontend_and_official_operator_contracts_exist() -> None:
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
temporal_api = (ROOT / "frontend/src/services/api/temporal.ts").read_text(encoding="utf-8")
+1 -1
View File
@@ -131,7 +131,7 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None:
assert "Kempen (28 gemeenten)" in workspace
assert 'aria-label="Regio"' not in workspace
assert 'aria-label="Ingeladen regiobereik"' in workspace
assert "Gemeente of volledige regio" in workspace
assert "Snel naar een gemeente (optioneel)" in workspace
assert "projects={projects}" in app
map_props = app.split("<MapWorkspace", maxsplit=1)[1].split("/>", maxsplit=1)[0]
assert "onSelectProject={selectProject}" not in map_props
@@ -17,7 +17,7 @@ def test_regional_workspace_is_automatic_and_map_has_one_scope_selector() -> Non
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 "Snel naar een gemeente (optioneel)" in map_workspace
assert 'aria-label="Werkgebied"' in map_workspace
@@ -184,11 +184,22 @@ def test_regional_timeseries_operator_is_packaged_and_release_checked() -> None:
def test_end_user_dataset_sources_are_human_readable() -> None:
display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
catalog = (ROOT / "frontend/src/components/datasets/DatasetPanel.tsx").read_text(encoding="utf-8")
status = (ROOT / "frontend/src/components/WorkbenchStatusStrip.tsx").read_text(encoding="utf-8")
detection = (ROOT / "frontend/src/components/detection/DetectionLab.tsx").read_text(encoding="utf-8")
exports = (ROOT / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8")
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
assert "statbel: 'Statbel'" in display
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
assert "dataset ? getDatasetSourceDisplayName(dataset)" in workspace
assert "Snel naar een gemeente (optioneel)" in workspace
assert "latestDatasetBySeries" in catalog
assert "Historische meetmomenten" in catalog
assert "getDatasetSourceDisplayName(dataset)" in catalog
assert "statusLabel(item.state)" in status
assert "Technische modelevaluatie" in detection
assert "Nog geen downloads gemaakt." in exports
def test_full_area_fast_path_requires_matching_area_and_clipped_operator_provenance() -> None:
@@ -17,7 +17,7 @@ def test_frontend_wires_v1_workbench_status_strip() -> None:
assert "activeLayerFeatureCount={mapFeatureCount}" in app
assert "selectedAreaHasGeometry={Boolean(areaFeatureCollection)}" in app
assert "Platformstatus" in component
assert "Workbench status:" in component
assert "Status werkruimte:" in component
assert "Kaartwerkruimte is gebruiksklaar" in component
assert "workbench-status-strip" in css
assert "status-tile-ready" in css
@@ -20,7 +20,7 @@ def test_detection_lab_exposes_structured_surfaces() -> None:
assert 'className="ai-lab-results-surface"' in lab
assert 'aria-label="Detection results"' in lab
assert 'className="ai-lab-qa-surface"' in lab
assert 'aria-label="Detection QA controls and results"' in lab
assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
def test_segmentation_lab_exposes_structured_surfaces() -> None:
@@ -53,7 +53,7 @@ def test_ai_lab_preserves_existing_detection_and_segmentation_controls() -> None
assert "onRunQa" in detection
assert "detectionTileManifestPath" in detection
assert "{detectionItems.length} objecten geladen" in detection
assert "Compare detections to reference" in detection
assert "Vergelijk met referentielaag" in detection
assert "onRunSegmentation" in segmentation
assert "onLoadResults" in segmentation
+26
View File
@@ -1,3 +1,29 @@
## Sprint 194 Regional official time series and full-Area performance (2026-07-14)
Changed:
- Generalized the explicit Statbel operator to the approved 28-municipality scope and added one coordinator for official 2021-2025 population plus 2013-2025 forest snapshots.
- Added municipality-partitioned official 10 m WCS retrieval after the upstream response-size limit rejected a single full-region request; source partitions are resumable and mosaicked locally before exact region clipping.
- Added a provenance-gated full-Area selection path for trusted pre-clipped operator datasets. Free rectangles, unrelated Areas and ordinary uploads continue to execute the normal PostGIS intersection path.
- Grouped older dated snapshots behind `Historische meetmomenten`, replaced raw provider/type labels in the primary catalog and translated the remaining visible temporal, download, platform-status and detection-evaluation language.
Live regional evidence:
- Population datasets: five exact dated snapshots, 3,317 persisted `vector_features`; 2021 `489,927` inhabitants and 2025 `506,473`, a measured increase of `16,546` (`3.377%`).
- Forest datasets: five exact dated snapshots, 273,669 persisted `vector_features`; 2013 `27,659.976 ha` and 2025 `27,410.687 ha`, a source-resolution change of `-249.289 ha` (`-0.9013%`).
- All ten snapshots have one immutable dataset version and zero invalid, empty, non-4326 or source-ID-missing geometries. A repeated synchronization reused the same ten Dataset IDs without duplicates.
- Complete-Kempen current analysis returns 506,473 inhabitants, 27,410.69 ha forest, 466,078 buildings, 88,332 water features, 84,504 roads and 415,288 parcels over 1,399.25 km2.
Performance and runtime evidence:
- Population full-Area API selection completed in `1.738 s`; forest completed in `1.083 s`. The browser rendered the full six-theme analysis and 2021-2025 comparison within the 3.5-second verification window, replacing the previous roughly 92-second forest-heavy workflow.
- Tower health, PostGIS 3.6, required schema objects, Alembic head `202607140001`, frontend, API proxy and icon checks passed after deployment.
- PyTorch `2.13.0+cpu`, Ultralytics `8.4.95` and the active local model file load successfully. The catalog exposes 24 local assets; the active seven-AOI profile remains review-required at F1 `0.5825` and was not blindly retrained.
Tested:
- `bash scripts/run_readiness_check.sh` passed with `571 passed`, backend compile, one Alembic head, frontend typecheck/build and live-smoke syntax validation.
- Focused regional, temporal and end-user contract checks passed. The browser confirmed the complete regional default, six current themes, five population moments, exact temporal totals and no required project/region selection step.
Next:
- Complete the 48 false-positive and 48 false-negative operator review decisions, then use those decisions to build a justified next training corpus and expose the existing raster-to-tiles-to-detection workflow as one guided end-user action.
## Sprint 193 End-user regional workbench simplification (2026-07-14)
Changed:
+1 -1
View File
@@ -14,7 +14,7 @@
- [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.
- [x] 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`.
@@ -1,4 +1,5 @@
import type { AreaRead, DatasetCreateResponse, ExportRead, ProjectRead, QualityCheckRead } from '../types'
import { REGIONAL_WORKSPACE_LABEL, REGIONAL_WORKSPACE_PROJECT_NAME } from '../config/primaryFocus'
interface StatusItem {
key: string
@@ -26,6 +27,17 @@ function countReferenceDatasets(datasets: DatasetCreateResponse[]): number {
return datasets.filter((dataset) => dataset.dataset_role === 'reference').length
}
function statusLabel(state: StatusItem['state']): string {
if (state === 'ready') return 'gereed'
if (state === 'warning') return 'aandacht'
return 'nog open'
}
function projectLabel(project: ProjectRead | null): string {
if (!project) return 'Geen werkruimte'
return project.name === REGIONAL_WORKSPACE_PROJECT_NAME ? REGIONAL_WORKSPACE_LABEL : project.name
}
function nextAction(items: StatusItem[]): string {
if (!items.some((item) => item.key === 'project' && item.state === 'ready')) {
return 'De regionale werkruimte kon niet worden geopend.'
@@ -65,7 +77,7 @@ export function WorkbenchStatusStrip({
{
key: 'project',
label: 'Werkruimte',
value: selectedProject ? selectedProject.name : 'Geen werkruimte',
value: projectLabel(selectedProject),
detail: selectedProject ? selectedProject.region : 'Niet geladen',
state: selectedProject ? 'ready' : 'waiting',
},
@@ -114,12 +126,12 @@ export function WorkbenchStatusStrip({
<div>
<p className="eyebrow">Platformstatus</p>
<h2>
<span className="sr-only">Workbench status: </span>
<span className="sr-only">Status werkruimte: </span>
{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`}>
<div className="status-readiness" aria-label={`${readyCount} van ${items.length} onderdelen gereed`}>
<strong>{readyCount}/{items.length}</strong>
<span>onderdelen gereed</span>
</div>
@@ -129,7 +141,7 @@ export function WorkbenchStatusStrip({
<div className={`status-tile status-tile-${item.state}`} key={item.key}>
<div className="status-tile-topline">
<span>{item.label}</span>
<span className="status-pill">{item.state}</span>
<span className="status-pill">{statusLabel(item.state)}</span>
</div>
<strong title={item.value}>{item.value}</strong>
<p>{item.detail}</p>
@@ -1,6 +1,6 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../../types'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
interface DatasetFormState {
datasetType: string
@@ -63,6 +63,9 @@ function normalizeDatasetRole(dataset: DatasetCreateResponse): 'reference' | 'ca
if (dataset.dataset_role === 'reference') {
return 'reference'
}
if (dataset.dataset_role === 'source') {
return 'source'
}
if (dataset.dataset_role === 'derived' || isVectorDatasetType(dataset.dataset_type)) {
return 'candidate'
}
@@ -76,7 +79,7 @@ function datasetRoleLabel(role: string): string {
if (role === 'candidate') {
return 'Analyse-resultaat'
}
return 'Eigen bron'
return 'Basisbron'
}
function datasetActionHint(dataset: DatasetCreateResponse, role: string): string {
@@ -92,6 +95,19 @@ function datasetActionHint(dataset: DatasetCreateResponse, role: string): string
return 'Ingeladen gegevensbron voor verdere analyse.'
}
function formatObservationDate(value: string | null | undefined): string {
if (!value) return 'huidige toestand'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('nl-BE', { day: 'numeric', month: 'short', year: 'numeric' }).format(date)
}
function datasetKindLabel(dataset: DatasetCreateResponse): string {
if (dataset.dataset_type === 'raster') return 'Luchtbeeld'
if (isVectorDatasetType(dataset.dataset_type)) return 'Kaartlaag'
return 'Gegevensbron'
}
export function DatasetPanel({
selectedProjectId,
selectedDatasetId,
@@ -107,8 +123,24 @@ export function DatasetPanel({
onOpenDatasetInMap,
onOpenDatasetExport,
}: DatasetPanelProps) {
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId)
const latestDatasetBySeries = new Map<string, DatasetCreateResponse>()
datasets.forEach((dataset) => {
if (!dataset.temporal_series_key) return
const current = latestDatasetBySeries.get(dataset.temporal_series_key)
const currentTimestamp = current?.observed_at ? Date.parse(current.observed_at) : Number.NEGATIVE_INFINITY
const candidateTimestamp = dataset.observed_at ? Date.parse(dataset.observed_at) : Number.NEGATIVE_INFINITY
if (!current || candidateTimestamp > currentTimestamp) {
latestDatasetBySeries.set(dataset.temporal_series_key, dataset)
}
})
const primaryDatasets = datasets.filter(
(dataset) => !dataset.temporal_series_key || latestDatasetBySeries.get(dataset.temporal_series_key)?.id === dataset.id,
)
const historicalDatasets = datasets.filter(
(dataset) => dataset.temporal_series_key && latestDatasetBySeries.get(dataset.temporal_series_key)?.id !== dataset.id,
)
const readyDatasets = primaryDatasets.filter((dataset) => dataset.status === 'ready').length
const roleSummaries = [
{
key: 'selected',
@@ -120,21 +152,21 @@ export function DatasetPanel({
{
key: 'reference',
label: 'Referentie',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
hint: 'Officieel',
className: 'dataset-role-reference',
},
{
key: 'candidate',
label: 'Resultaat',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
hint: 'Afgeleid',
className: 'dataset-role-candidate',
},
{
key: 'source',
label: 'Eigen bron',
count: datasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
label: 'Basisbron',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
hint: 'Ingeladen',
className: 'dataset-role-source',
},
@@ -147,7 +179,7 @@ export function DatasetPanel({
<p className="eyebrow">Ingeladen gegevens</p>
<h2>Bronnen</h2>
</div>
<span className="count-pill">{readyDatasets}/{datasets.length} beschikbaar</span>
<span className="count-pill">{readyDatasets}/{primaryDatasets.length} bronnen beschikbaar</span>
</div>
<div className="data-selection-summary data-selection-summary-dataset">
@@ -165,17 +197,17 @@ export function DatasetPanel({
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
>
<option value="vector">vector</option>
<option value="geojson">geojson</option>
<option value="raster">raster</option>
<option value="vector">Kaartlaag</option>
<option value="geojson">GeoJSON-kaartlaag</option>
<option value="raster">Luchtbeeld of raster</option>
</select>
</label>
<label>
Bronnaam
Naam van de bron
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
placeholder="user_upload"
placeholder="bijvoorbeeld eigen luchtbeeld"
/>
</label>
<label>
@@ -226,7 +258,7 @@ export function DatasetPanel({
<div className="data-panel-list-block dataset-catalog-block">
<p className="data-section-label">Beschikbare bronnen</p>
<ul className="dataset-list">
{datasets.map((dataset) => {
{primaryDatasets.map((dataset) => {
const datasetRole = normalizeDatasetRole(dataset)
const roleLabel = datasetRoleLabel(datasetRole)
return (
@@ -235,12 +267,12 @@ export function DatasetPanel({
<div>
<div className="dataset-card-kicker">
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
<span>{dataset.dataset_type}</span>
<span>{datasetKindLabel(dataset)}</span>
</div>
<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>
<span>{getDatasetSourceDisplayName(dataset)} · {formatObservationDate(dataset.observed_at)}</span>
</div>
</div>
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
@@ -305,6 +337,28 @@ export function DatasetPanel({
)
})}
</ul>
{historicalDatasets.length > 0 ? (
<details className="dataset-history-disclosure">
<summary>
<span>Historische meetmomenten</span>
<strong>{historicalDatasets.length} oudere lagen</strong>
</summary>
<ul className="dataset-history-list">
{historicalDatasets.map((dataset) => (
<li key={dataset.id}>
<div>
<strong>{getDatasetDisplayName(dataset)}</strong>
<span>{formatObservationDate(dataset.observed_at)} · {(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
</div>
<div className="panel-action-row">
<button className="secondary-action" type="button" onClick={() => onOpenDatasetInMap(dataset)}>Kaart</button>
<button className="secondary-action" type="button" onClick={() => onOpenDatasetExport(dataset)}>Downloaden</button>
</div>
</li>
))}
</ul>
</details>
) : null}
</div>
</section>
)
@@ -16,6 +16,13 @@ import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './de
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
function detectionModelLabel(model: DetectionModelCapability): string {
if (model.model_id === 'yolo-configured') return 'Lokaal gebouwmodel'
if (model.model_id === 'manual-fixture-detector') return 'Testmodel (alleen voor demo)'
if (model.model_id === 'yolo-placeholder') return 'Gebouwmodel nog niet geconfigureerd'
return model.display_name
}
interface CalibrationRow {
analysisRunId: string
qualityCheckId: string
@@ -272,7 +279,7 @@ export function DetectionLab({
<ul className="model-list">
{detectionModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong>
<strong>{detectionModelLabel(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div className="entity-meta">
<span>{model.model_id}</span>
@@ -560,7 +567,7 @@ export function DetectionLab({
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
{detectionModelLabel(model)}
</option>
))}
</select>
@@ -587,7 +594,7 @@ export function DetectionLab({
Beeldtegelbestand
<input
type="text"
placeholder="Raster tile manifest path"
placeholder="Pad naar de aangemaakte beeldtegels"
value={detectionTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
@@ -609,17 +616,17 @@ export function DetectionLab({
<div className="ai-lab-state-stack">
{detectionRunError ? (
<div className="result-state result-state-error">
<strong>Detection run failed.</strong>
<strong>De beeldanalyse is mislukt.</strong>
<p>{detectionRunError}</p>
</div>
) : null}
{detectionRunResult ? (
<div className="result-summary-card">
<p>Status: {detectionRunResult.status}</p>
<p>Message: {detectionRunResult.message}</p>
<p>Analysis run: {detectionRunResult.analysis_run_id}</p>
<p>Job: {detectionRunResult.job_id}</p>
<p>Detections: {detectionRunResult.detection_count}</p>
<p>Uitleg: {detectionRunResult.message}</p>
<p>Analyse: {detectionRunResult.analysis_run_id}</p>
<p>Verwerking: {detectionRunResult.job_id}</p>
<p>Gevonden objecten: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
</div>
) : null}
@@ -765,7 +772,7 @@ export function DetectionLab({
<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}
{run.model_name || 'Gebouwdetectie'} · {run.status} · {run.id}
</option>
))}
</select>
@@ -774,7 +781,7 @@ export function DetectionLab({
Type object
<input
type="text"
placeholder="bijvoorbeeld building"
placeholder="bijvoorbeeld gebouw"
value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
@@ -811,10 +818,10 @@ export function DetectionLab({
<div className="pagination-toolbar" aria-label="Detection result pagination">
<p className="pagination-summary" aria-live="polite">
<strong>{detectionPageStart + 1}-{detectionPageEnd}</strong>
<span>of {detectionItems.length}</span>
<span>van {detectionItems.length}</span>
</p>
<label className="pagination-page-size">
Rows
Rijen
<select
value={detectionPageSize}
onChange={(event) => {
@@ -831,19 +838,19 @@ export function DetectionLab({
<button
className="secondary-action pagination-button"
type="button"
aria-label="Previous detection results page"
title="Previous page"
aria-label="Vorige resultatenpagina"
title="Vorige pagina"
disabled={currentDetectionPage <= 1}
onClick={() => setDetectionResultPage(currentDetectionPage - 1)}
>
{'<'}
</button>
<span>Page {currentDetectionPage} of {detectionPageCount}</span>
<span>Pagina {currentDetectionPage} van {detectionPageCount}</span>
<button
className="secondary-action pagination-button"
type="button"
aria-label="Next detection results page"
title="Next page"
aria-label="Volgende resultatenpagina"
title="Volgende pagina"
disabled={currentDetectionPage >= detectionPageCount}
onClick={() => setDetectionResultPage(currentDetectionPage + 1)}
>
@@ -855,10 +862,10 @@ export function DetectionLab({
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Type</th>
<th>Zekerheid</th>
<th>Model</th>
<th>Source tile</th>
<th>Beeldtegel</th>
</tr>
</thead>
<tbody>
@@ -879,34 +886,40 @@ export function DetectionLab({
) : null}
</div>
<div className="ai-lab-results-surface calibration-comparison-surface" aria-label="Detection calibration comparison">
<details className="secondary-analysis-disclosure detection-technical-evaluation">
<summary>
<span>Technische modelevaluatie</span>
<strong>{calibrationRows.length > 0 || detectionQaResult ? 'resultaten beschikbaar' : 'optioneel'}</strong>
</summary>
<div className="ai-lab-disclosure-body">
<div className="ai-lab-results-surface calibration-comparison-surface" aria-label="Vergelijking modelkalibratie">
<div className="panel-title-row">
<div>
<h3>Calibration comparison</h3>
<p className="muted">Compare persisted detection runs by confidence threshold before promoting a model setting.</p>
<h3>Kalibraties vergelijken</h3>
<p className="muted">Vergelijk bewaarde analyseruns per zekerheidsdrempel voordat een modelinstelling wordt goedgekeurd.</p>
</div>
<span className="count-pill">{calibrationRows.length} rows</span>
<span className="count-pill">{calibrationRows.length} resultaten</span>
</div>
{calibrationRows.length > 0 ? (
<>
<div className="calibration-summary-grid" aria-label="Calibration comparison winners">
<CalibrationSummaryCard title="Best F1 candidate" row={bestF1Candidate} metric="f1" />
<CalibrationSummaryCard title="Best precision candidate" row={bestPrecisionCandidate} metric="precision" />
<CalibrationSummaryCard title="Lowest false-positive pressure" row={lowestFalsePositivePressureCandidate} metric="falsePositives" />
<CalibrationSummaryCard title="Beste F1-score" row={bestF1Candidate} metric="f1" />
<CalibrationSummaryCard title="Beste precisie" row={bestPrecisionCandidate} metric="precision" />
<CalibrationSummaryCard title="Minste foutieve meldingen" row={lowestFalsePositivePressureCandidate} metric="falsePositives" />
</div>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Threshold</th>
<th>Drempel</th>
<th>Model</th>
<th>Detections</th>
<th>Precision</th>
<th>Objecten</th>
<th>Precisie</th>
<th>Recall</th>
<th>F1</th>
<th>False positives</th>
<th>False negatives</th>
<th>Quality check</th>
<th>Fout positief</th>
<th>Fout negatief</th>
<th>Kwaliteitscontrole</th>
</tr>
</thead>
<tbody>
@@ -915,7 +928,7 @@ export function DetectionLab({
<td>{formatNullableNumber(row.threshold, 2)}</td>
<td>
<strong>{row.modelName}</strong>
<span className="table-subtle">{row.modelAssetId ?? 'runtime configured path'}</span>
<span className="table-subtle">{row.modelAssetId ?? 'geconfigureerd lokaal model'}</span>
</td>
<td>{row.detectionCount ?? 'n/a'}</td>
<td>{formatNullableNumber(row.precision, 3)}</td>
@@ -930,24 +943,24 @@ export function DetectionLab({
</table>
</div>
<div className="lab-action-guardrail">
<span>Promotion guardrail</span>
<strong>Promote only after checking evidence across AOIs, false positives and false negatives.</strong>
<span>Voorwaarde voor goedkeuring</span>
<strong>Keur pas goed nadat meerdere gebieden en de foutieve positieve en negatieve resultaten zijn gecontroleerd.</strong>
</div>
</>
) : (
<div className="result-state result-state-empty">
<strong>No calibration comparison available yet.</strong>
<p>Run configured YOLO at multiple confidence thresholds, then compare each persisted detection run against the same reference dataset.</p>
<strong>Nog geen kalibratievergelijking beschikbaar.</strong>
<p>Voer het lokale model met meerdere zekerheidsdrempels uit en vergelijk de resultaten met dezelfde referentielaag.</p>
</div>
)}
</div>
<div className="ai-lab-qa-surface" aria-label="Detection QA controls and results">
<h3>Detection QA</h3>
<div className="ai-lab-qa-surface" aria-label="Kwaliteitscontrole gebouwdetectie">
<h3>Kwaliteitscontrole gebouwdetectie</h3>
<label>
Reference dataset
Referentielaag
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
@@ -956,24 +969,24 @@ export function DetectionLab({
</select>
</label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
Compare detections to reference
Vergelijk met referentielaag
</button>
{detectionQaError ? (
<div className="result-state result-state-error">
<strong>Detection QA failed.</strong>
<strong>De kwaliteitscontrole is mislukt.</strong>
<p>{detectionQaError}</p>
</div>
) : null}
{detectionQaResult ? (
<div className="result-summary-card">
<p>Status: {detectionQaResult.status}</p>
<p>Quality check: {detectionQaResult.quality_check_id}</p>
<p>Precision: {detectionQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
<p>Kwaliteitscontrole: {detectionQaResult.quality_check_id}</p>
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
<p>Mean IoU: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n/a'}</p>
<p>False positives: {detectionQaResult.false_positives}</p>
<p>False negatives: {detectionQaResult.false_negatives}</p>
<p>Gemiddelde overlap: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Fout positief: {detectionQaResult.false_positives}</p>
<p>Fout negatief: {detectionQaResult.false_negatives}</p>
{detectionQaResult.coverage ? (
<div className="detection-qa-diagnostic">
<span>Inference coverage</span>
@@ -1004,6 +1017,8 @@ export function DetectionLab({
</div>
) : null}
</div>
</div>
</details>
</section>
)
}
@@ -326,27 +326,27 @@ export function ExportCenter({
<div className="export-state-stack">
{loadingExports ? (
<div className="result-state result-state-loading">
<strong>Loading export registry.</strong>
<p>Retrieving persisted artifacts for the selected project.</p>
<strong>Downloads laden.</strong>
<p>De bewaarde bestanden van deze werkruimte worden opgehaald.</p>
</div>
) : null}
{exportError ? (
<div className="result-state result-state-error">
<strong>Export action failed.</strong>
<strong>Download kon niet worden gemaakt.</strong>
<p>{exportError}</p>
</div>
) : null}
{latestExport ? (
<div className="latest-export-card">
<span>Latest export</span>
<span>Laatste download</span>
<strong>{formatExportType(latestExport.export_type)}</strong>
<p>{latestExport.path}</p>
</div>
) : null}
{exports.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No exports registered yet.</strong>
<p>Create metadata, GeoJSON or HTML report artifacts once the active project has data to hand off.</p>
<strong>Nog geen downloads gemaakt.</strong>
<p>Kies hierboven een leesbaar rapport, projectoverzicht of kaartlaag.</p>
</div>
) : null}
</div>
+1 -1
View File
@@ -1049,7 +1049,7 @@ export function MapWorkspace({
) : null}
<label className="geo-scope-select">
Gemeente of volledige regio
Snel naar een gemeente (optioneel)
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
+55
View File
@@ -1529,6 +1529,7 @@ details.ai-lab-model-surface > summary strong {
.technical-run-list,
.area-catalog-disclosure,
.dataset-history-disclosure,
.dataset-technical-details,
.quality-advanced-disclosure,
.provider-technical-details,
@@ -1543,6 +1544,7 @@ details.ai-lab-model-surface > summary strong {
.technical-run-list > summary,
.area-catalog-disclosure > summary,
.dataset-history-disclosure > summary,
.dataset-technical-details > summary,
.quality-advanced-disclosure > summary,
.provider-technical-details > summary,
@@ -1564,6 +1566,7 @@ details.ai-lab-model-surface > summary strong {
.technical-run-list > summary::-webkit-details-marker,
.area-catalog-disclosure > summary::-webkit-details-marker,
.dataset-history-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,
@@ -1575,6 +1578,7 @@ details.ai-lab-model-surface > summary strong {
.technical-run-list > summary::after,
.area-catalog-disclosure > summary::after,
.dataset-history-disclosure > summary::after,
.dataset-technical-details > summary::after,
.quality-advanced-disclosure > summary::after,
.provider-technical-details > summary::after,
@@ -1589,6 +1593,7 @@ details.ai-lab-model-surface > summary strong {
.technical-run-list[open] > summary::after,
.area-catalog-disclosure[open] > summary::after,
.dataset-history-disclosure[open] > summary::after,
.dataset-technical-details[open] > summary::after,
.quality-advanced-disclosure[open] > summary::after,
.provider-technical-details[open] > summary::after,
@@ -1645,6 +1650,56 @@ details.ai-lab-model-surface > summary strong {
margin-top: 0.25rem;
}
.dataset-history-disclosure {
margin-top: 0.75rem;
}
.dataset-history-disclosure > summary strong {
margin-right: 0.25rem;
color: var(--muted);
font-size: 0.75rem;
}
.dataset-history-list {
display: grid;
max-height: 24rem;
margin: 0;
overflow: auto;
padding: 0.5rem;
list-style: none;
background: #ffffff;
}
.dataset-history-list li {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--line);
padding: 0.65rem 0.25rem;
}
.dataset-history-list li:first-child {
border-top: 0;
}
.dataset-history-list li > div:first-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.dataset-history-list li span {
color: var(--muted);
font-size: 0.76rem;
}
.detection-technical-evaluation {
margin-top: 0.75rem;
}
.ai-user-summary {
order: 0;
display: grid;