diff --git a/CHANGELOG.md b/CHANGELOG.md index eb63e41a..e12f1326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,19 @@ - Passed the RC-8 release gate with 1,012 backend tests, 12 frontend unit tests, frontend typecheck/build, one Alembic head and a clean live E2E console/request audit. +- Replaced false initial `Ontbreekt` theme states with an explicit loading + state while projects, Areas and Datasets are still resolving. +- Added measured 4-second coverage and 15-second persisted map-analysis + budgets with visible completion time and over-budget warnings. +- Added keyboard-correct analysis tabs, reliable skip-link focus, live status + announcements and an accessible interactive-map region. +- Expanded the national map workbench across ultrawide screens while retaining + zero page-level overflow at 390, 1366 and 2560 pixels. +- Added an RC-9 Playwright UX audit covering delayed bootstrap, every + top-level workspace, accessible control names, keyboard behavior, viewport + layout and visible coverage timing. +- Passed the local RC-9 gate with 1,015 backend tests, 16 frontend unit tests, + frontend typecheck/build and a clean three-viewport browser audit. - Added a read-only release-evidence manifest command with Git, migration, dependency, configuration checksum and optional live endpoint evidence. - Replaced the obsolete pre-build status with the current implemented diff --git a/backend/tests/test_rc9_ux_release_contract.py b/backend/tests/test_rc9_ux_release_contract.py new file mode 100644 index 00000000..36817037 --- /dev/null +++ b/backend/tests/test_rc9_ux_release_contract.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_rc9_ux_audit_is_wired_into_frontend_and_readiness() -> None: + package = json.loads((ROOT / "frontend" / "package.json").read_text(encoding="utf-8")) + readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") + wrapper = ROOT / "scripts" / "run_rc9_ux_audit.sh" + + assert package["scripts"]["test:e2e:ux"] == "node e2e/uxAudit.mjs" + assert '"${NODE_BIN}" --check frontend/e2e/uxAudit.mjs' in readiness + assert "bash -n scripts/run_rc9_ux_audit.sh" in readiness + assert wrapper.is_file() + + +def test_rc9_loading_and_accessibility_states_are_explicit() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + map_workspace = ( + ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx" + ).read_text(encoding="utf-8") + geo_map = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text( + encoding="utf-8" + ) + + assert "workspaceDataLoading" in app + assert 'role="status" aria-live="polite"' in app + assert "Databronnen worden gecontroleerd" in map_workspace + assert "Beschikbaarheid controleren" in map_workspace + assert "aria-busy={workspaceLoading}" in map_workspace + assert "handleAnalysisModeKeyDown" in map_workspace + assert 'aria-label="Interactieve kaart.' in geo_map + + +def test_rc9_performance_budgets_are_documented_and_visible() -> None: + budget = ( + ROOT / "frontend" / "src" / "lib" / "performanceBudget.ts" + ).read_text(encoding="utf-8") + docs = (ROOT / "docs" / "UX_PERFORMANCE_BUDGETS.md").read_text(encoding="utf-8") + map_workspace = ( + ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx" + ).read_text(encoding="utf-8") + + assert "COVERAGE_RESPONSE_BUDGET_MS = 4_000" in budget + assert "MAP_ANALYSIS_BUDGET_MS = 15_000" in budget + assert "4 seconds" in docs + assert "15 seconds" in docs + assert "overschrijdt het releasebudget" in map_workspace diff --git a/backend/tests/test_sprint233_operational_completion.py b/backend/tests/test_sprint233_operational_completion.py index 8d3e2a01..0e4da5b6 100644 --- a/backend/tests/test_sprint233_operational_completion.py +++ b/backend/tests/test_sprint233_operational_completion.py @@ -351,7 +351,8 @@ def test_workspace_navigation_resets_the_actual_scroll_container() -> None: assert "const workbenchMainRef = useRef(null)" in app assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app - assert "
None: diff --git a/docs/UX_PERFORMANCE_BUDGETS.md b/docs/UX_PERFORMANCE_BUDGETS.md new file mode 100644 index 00000000..dca9074b --- /dev/null +++ b/docs/UX_PERFORMANCE_BUDGETS.md @@ -0,0 +1,49 @@ +# UX and Performance Budgets + +These budgets apply to the release-candidate map-first flow for Belgium and +the Belgian North Sea. They are regression limits, not claims about external +provider availability. + +## Interactive budgets + +| Interaction | Budget | Enforcement | +| --- | ---: | --- | +| persisted coverage resolution | 4 seconds | hook timing, visible warning and RC browser evidence | +| cached/persisted map selection analysis | 15 seconds | map timing and visible warning | +| initial map workspace readiness | 10 seconds on the release LAN | RC viewport evidence | +| local Ollama answer with persisted context | 90 seconds | RC-8 live journey timeout/evidence | +| bounded configured-YOLO fixture run | 120 seconds | RC-8 live journey timeout/evidence | + +An on-demand official-source acquisition is intentionally excluded from the +15-second cached-selection budget. Its source-specific timeout, transfer and +feature limits remain authoritative. The UI must keep showing a running state +and a source-specific failure instead of presenting an acquisition as missing +or complete. + +## Required states + +The frontend distinguishes: + +- `loading`: project, Area and Dataset state is still being resolved; +- `empty`: loading completed and no matching persisted data exists; +- `partial`: the persisted coverage matrix contains incomplete regional data; +- `error`: the API or provider contract failed; +- `ready`: persisted data and metrics are available. + +Definitive `Ontbreekt`/`Niet beschikbaar` wording is forbidden while the +workspace is loading. A completed coverage or selection request that exceeds +its budget must display the measured duration and budget warning. + +## Reproduction + +```bash +npm --prefix frontend run test:unit +bash scripts/run_rc9_ux_audit.sh \ + http://192.168.10.150:1202 \ + artifacts/rc9-ux-audit +``` + +The browser audit runs at 390x844, 1366x768 and 2560x1080. It checks body +overflow, map width, keyboard tab behavior, skip-link focus, visible control +names, delayed bootstrap truthfulness and coverage-budget feedback. Its +screenshots and `manifest.json` are runtime evidence and remain outside Git. diff --git a/frontend/README.md b/frontend/README.md index 3b8556a3..577a7527 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -751,3 +751,17 @@ configured local Ollama integration and the configured local YOLO model. It uses only the explicit technical demo-fixture flow for AI validation. Runtime screenshots and the machine-readable manifest are ignored build evidence and must not be committed. + +Run the loading, accessibility, responsive-layout and performance-feedback +audit at the release viewports: + +```bash +bash scripts/run_rc9_ux_audit.sh \ + http://192.168.10.150:1202 \ + artifacts/rc9-ux-audit +``` + +The audit covers 390x844, 1366x768 and 2560x1080, every top-level workspace, +keyboard operation of the analysis period, skip-link focus, delayed bootstrap +truthfulness and visible coverage timing. The enforced limits are documented +in `docs/UX_PERFORMANCE_BUDGETS.md`. diff --git a/frontend/e2e/uxAudit.mjs b/frontend/e2e/uxAudit.mjs new file mode 100644 index 00000000..8a3202ae --- /dev/null +++ b/frontend/e2e/uxAudit.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { chromium } from 'playwright' + +function parseArgs(argv) { + const parsed = { + baseUrl: process.env.GEOINTEL_BASE_URL || 'http://127.0.0.1:1202', + output: process.env.GEOINTEL_RC9_OUTPUT || '../artifacts/rc9-ux-audit', + } + for (let index = 2; index < argv.length; index += 1) { + if (argv[index] === '--base-url') parsed.baseUrl = argv[++index] + else if (argv[index] === '--output') parsed.output = argv[++index] + } + parsed.baseUrl = parsed.baseUrl.replace(/\/$/, '') + return parsed +} + +async function auditInteractiveNames(page, label) { + const unnamed = await page.locator('button, input, select, textarea, a[href]').evaluateAll((elements) => { + const visible = (element) => { + const style = window.getComputedStyle(element) + return style.visibility !== 'hidden' + && style.display !== 'none' + && element.getClientRects().length > 0 + && element.getAttribute('aria-hidden') !== 'true' + } + const textForIdList = (value) => (value || '') + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() || '') + .filter(Boolean) + .join(' ') + const accessibleName = (element) => { + const ariaLabel = element.getAttribute('aria-label')?.trim() + if (ariaLabel) return ariaLabel + const labelled = textForIdList(element.getAttribute('aria-labelledby')) + if (labelled) return labelled + if ('labels' in element && element.labels?.length) { + const labelText = [...element.labels] + .map((item) => item.textContent?.trim() || '') + .filter(Boolean) + .join(' ') + if (labelText) return labelText + } + if (element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement) { + const text = element.textContent?.trim() + if (text) return text + } + return element.getAttribute('title')?.trim() || '' + } + return elements + .filter(visible) + .filter((element) => !accessibleName(element)) + .map((element) => ({ + tag: element.tagName.toLowerCase(), + type: element.getAttribute('type'), + className: element.className, + testId: element.getAttribute('data-testid'), + })) + }) + assert.deepEqual(unnamed, [], `${label} has visible controls without an accessible name`) + return unnamed.length +} + +async function layoutEvidence(page) { + return page.evaluate(() => { + const root = document.documentElement + const main = document.querySelector('.workbench-main')?.getBoundingClientRect() + const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect() + const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect() + return { + viewport_width: window.innerWidth, + viewport_height: window.innerHeight, + document_width: root.scrollWidth, + body_width: document.body.scrollWidth, + horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth), + main: main ? { left: main.left, right: main.right, width: main.width } : null, + map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null, + theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null, + } + }) +} + +async function runViewport(browser, baseUrl, outputDir, viewport) { + const page = await browser.newPage({ viewport }) + const consoleErrors = [] + const failedRequests = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => consoleErrors.push(error.message)) + page.on('requestfailed', (request) => { + if (request.url().startsWith(baseUrl) && request.url().includes('/api/')) { + failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`) + } + }) + try { + const startedAt = Date.now() + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) + await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 }) + const readyMs = Date.now() - startedAt + await auditInteractiveNames(page, `${viewport.width}px map explorer`) + const layout = await layoutEvidence(page) + assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`) + assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`) + + const currentTab = page.getByRole('tab', { name: 'Laatste toestand' }) + const evolutionTab = page.getByRole('tab', { name: 'Evolutie' }) + await currentTab.focus() + await currentTab.press('ArrowRight') + await assert.doesNotReject(() => evolutionTab.waitFor({ state: 'visible' })) + assert.equal(await evolutionTab.getAttribute('aria-selected'), 'true') + await evolutionTab.press('ArrowLeft') + assert.equal(await currentTab.getAttribute('aria-selected'), 'true') + + await page.screenshot({ + path: path.join(outputDir, `viewport-${viewport.width}x${viewport.height}.png`), + fullPage: viewport.width <= 480, + }) + + await page.locator('.skip-link').focus() + await page.keyboard.press('Enter') + assert.equal(await page.locator('#workspace-main').evaluate((element) => document.activeElement === element), true) + await page.waitForTimeout(200) + return { + viewport, + ready_ms: readyMs, + layout, + console_errors: consoleErrors, + failed_requests: failedRequests, + } + } finally { + await page.close() + } +} + +async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) { + const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }) + let delayedDatasetRequests = 0 + await page.route('**/api/v1/projects/*/datasets*', async (route) => { + delayedDatasetRequests += 1 + await new Promise((resolve) => setTimeout(resolve, 1_500)) + await route.continue() + }) + try { + await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }) + const loadingStatus = page.getByRole('status', { name: '' }).filter({ + hasText: 'Databronnen worden gecontroleerd', + }) + await loadingStatus.waitFor({ state: 'visible', timeout: 15_000 }) + const themeStates = await page.locator('.geo-theme-option i').allTextContents() + assert(themeStates.length > 0, 'Theme controls did not render during bootstrap') + assert(themeStates.every((state) => state.trim() === 'Laden'), 'Bootstrap rendered a definitive missing state') + await page.screenshot({ path: path.join(outputDir, 'loading-state.png') }) + await loadingStatus.waitFor({ state: 'hidden', timeout: 60_000 }) + + await page.getByRole('button', { name: 'Geavanceerde werkbank' }).click() + await page.getByTestId('map-area-select').waitFor({ state: 'visible', timeout: 30_000 }) + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="map-area-select"] option').length > 1, + undefined, + { timeout: 30_000 }, + ) + await auditInteractiveNames(page, 'advanced map workbench') + + const areaSelect = page.getByTestId('map-area-select') + const options = await areaSelect.locator('option').count() + assert(options > 1, 'No persisted Area is available for the performance audit') + await areaSelect.selectOption({ index: 1 }) + const useArea = page.getByRole('button', { name: 'Begrenzing werkgebied gebruiken' }) + await useArea.waitFor({ state: 'visible' }) + await useArea.click() + await page.getByText(/Dekkingscontrole voltooid in/).waitFor({ state: 'visible', timeout: 30_000 }) + await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') }) + + const auditedWorkspaces = [] + for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) { + await page.getByTestId(`workspace-nav-${workspace}`).click() + await page.waitForTimeout(100) + await auditInteractiveNames(page, `${workspace} workspace`) + auditedWorkspaces.push(workspace) + } + + return { + delayed_dataset_requests: delayedDatasetRequests, + loading_state_verified: true, + advanced_accessible_names_verified: true, + coverage_budget_feedback_verified: true, + accessible_workspace_controls_verified: auditedWorkspaces, + } + } finally { + await page.close() + } +} + +async function main() { + const args = parseArgs(process.argv) + const outputDir = path.resolve(args.output) + await mkdir(outputDir, { recursive: true }) + const browser = await chromium.launch({ headless: true }) + const evidence = { + schema_version: 1, + base_url: args.baseUrl, + started_at: new Date().toISOString(), + viewports: [], + bootstrap: null, + status: 'running', + } + try { + for (const viewport of [ + { width: 390, height: 844 }, + { width: 1366, height: 768 }, + { width: 2560, height: 1080 }, + ]) { + evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport)) + } + evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir) + const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors) + const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests) + assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors') + assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests') + evidence.status = 'passed' + } catch (error) { + evidence.status = 'failed' + evidence.error = error instanceof Error ? error.stack || error.message : String(error) + throw error + } finally { + evidence.completed_at = new Date().toISOString() + await writeFile(path.join(outputDir, 'manifest.json'), `${JSON.stringify(evidence, null, 2)}\n`) + await browser.close() + } + process.stdout.write(`RC9 UX audit passed: ${path.join(outputDir, 'manifest.json')}\n`) +} + +await main() diff --git a/frontend/package.json b/frontend/package.json index 1ee3fe0f..beca3ea5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "typecheck": "tsc -p tsconfig.json --noEmit", "test:unit": "vitest run", - "test:e2e": "node e2e/releaseJourneys.mjs" + "test:e2e": "node e2e/releaseJourneys.mjs", + "test:e2e:ux": "node e2e/uxAudit.mjs" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c238b77d..98d10746 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,8 +76,13 @@ function App(): JSX.Element { const [mapContextSourceLabel, setMapContextSourceLabel] = useState(null) const [mapContextLayerLabel, setMapContextLayerLabel] = useState(null) const workbenchMainRef = useRef(null) + const previousWorkspaceRef = useRef(activeWorkspace) useEffect(() => { workbenchMainRef.current?.scrollTo({ top: 0, left: 0 }) + if (previousWorkspaceRef.current !== activeWorkspace) { + workbenchMainRef.current?.focus({ preventScroll: true }) + } + previousWorkspaceRef.current = activeWorkspace setInspectorOpen(false) }, [activeWorkspace]) const { @@ -482,6 +487,8 @@ function App(): JSX.Element { coverage: mapCoverage, loadingCoverage: mapCoverageLoading, coverageError: mapCoverageError, + coverageDurationMs: mapCoverageDurationMs, + coverageBudgetExceeded: mapCoverageBudgetExceeded, } = useCoverageResolver({ projectId: selectedProjectId, bbox: mapSelectionBbox, @@ -715,12 +722,22 @@ function App(): JSX.Element { const bathymetryProfileCount = regionalBathymetryContextActive ? regionalBathymetryProfileCount : selectedDataset?.feature_count ?? selectedDataset?.vector_summary?.feature_count ?? 0 + const workspaceDataLoading = loadingProjects || loadingAreas || loadingDatasets + const workspaceStatusMessage = errorMessage + ? `Werkruimtefout: ${errorMessage}` + : workspaceDataLoading + ? 'Werkruimte en databronnen worden geladen.' + : selectedProject + ? `${projectContextLabel} is geladen met ${datasets.length} databronnen en ${areas.length} gebieden.` + : 'Geen werkruimte geselecteerd.' const datasetContextLabel = activeWorkspace === 'map' && mapContextSourceLabel ? mapContextSourceLabel : analysisMapLayerActive && mapFeatureCollection ? `${mapLayerLabel} · controle vereist` : selectedDataset ? getDatasetDisplayName(selectedDataset) + : workspaceDataLoading + ? 'Bronnen worden geladen' : datasets.length > 0 ? 'Kies een bron' : 'Geen bron' @@ -732,6 +749,8 @@ function App(): JSX.Element { : `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen` : mapFeatureCollection ? `${mapFeatureCount.toLocaleString('nl-BE')} objecten` + : workspaceDataLoading + ? 'Kaart wordt voorbereid' : viewportVectorLayerActive ? 'Kaartlaag gekozen' : activeWorkspace === 'map' && selectedDataset?.dataset_type === 'raster' @@ -740,7 +759,13 @@ function App(): JSX.Element { return ( - {errorMessage ?

{errorMessage}

: null} +

{workspaceStatusMessage}

+ {errorMessage ?

{errorMessage}

: null}
-
+
{activeWorkspace !== 'map' ?

{selectedProject?.region ?? 'Mol, Kempen'}

@@ -861,7 +893,11 @@ function App(): JSX.Element { {activeWorkspace === 'data' ? (
- + + return ( +
+ ) } export default GeoMap diff --git a/frontend/src/components/datasets/DatasetPanel.tsx b/frontend/src/components/datasets/DatasetPanel.tsx index ee2f2109..2eff328f 100644 --- a/frontend/src/components/datasets/DatasetPanel.tsx +++ b/frontend/src/components/datasets/DatasetPanel.tsx @@ -284,8 +284,8 @@ export function DatasetPanel({ ))}
- {loadingDatasets ?

Bronnen laden...

: null} - {datasets.length === 0 ? ( + {loadingDatasets ?

Bronnen laden...

: null} + {!loadingDatasets && datasets.length === 0 ? (
Nog geen bronnen beschikbaar

Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.

diff --git a/frontend/src/components/datasets/SourceCatalogPanel.tsx b/frontend/src/components/datasets/SourceCatalogPanel.tsx index 0459249f..561f6d14 100644 --- a/frontend/src/components/datasets/SourceCatalogPanel.tsx +++ b/frontend/src/components/datasets/SourceCatalogPanel.tsx @@ -13,6 +13,7 @@ import { interface SourceCatalogPanelProps { datasets: DatasetCreateResponse[] projectId?: string + loading?: boolean } const THEME_LABELS: Record = { @@ -75,7 +76,11 @@ function mdkStatusLabel(status: BathymetrySourceProbeRead['status']): string { return 'Configuratie controleren' } -export function SourceCatalogPanel({ datasets, projectId }: SourceCatalogPanelProps): JSX.Element { +export function SourceCatalogPanel({ + datasets, + projectId, + loading = false, +}: SourceCatalogPanelProps): JSX.Element { const [mdkProbe, setMdkProbe] = useState(null) const [mdkProbeLoading, setMdkProbeLoading] = useState(false) const [mdkProbeError, setMdkProbeError] = useState(null) @@ -163,6 +168,31 @@ export function SourceCatalogPanel({ datasets, projectId }: SourceCatalogPanelPr } } + if (loading) { + return ( +
+
+
+ Broninventaris +

Welke vragen kan GeoIntel beantwoorden?

+
+ bronnen laden +
+
+
+
+ ) + } + return (
diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index a13607e1..d57f50a7 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import GeoMap from '../GeoMap' import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' @@ -10,6 +10,11 @@ import { terrainImageUrl } from '../../lib/terrainImage' import { floodHazardImageUrl } from '../../lib/floodHazardImage' import { thematicRasterImageUrl } from '../../lib/thematicRaster' import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus' +import { + MAP_ANALYSIS_BUDGET_MS, + exceedsPerformanceBudget, + formatPerformanceDuration, +} from '../../lib/performanceBudget' import { bboxToInputState, bboxesEqual, @@ -562,6 +567,10 @@ interface MapWorkspaceProps { coverage: CoverageResolveResponse | null coverageLoading: boolean coverageError: string | null + coverageDurationMs: number | null + coverageBudgetExceeded: boolean + workspaceLoading: boolean + workspaceError: string | null selectionExporting: boolean selectionExportError: string | null latestSelectionExportPath: string | null @@ -651,6 +660,10 @@ export function MapWorkspace({ coverage, coverageLoading, coverageError, + coverageDurationMs, + coverageBudgetExceeded, + workspaceLoading, + workspaceError, selectionExporting, selectionExportError, latestSelectionExportPath, @@ -744,6 +757,8 @@ export function MapWorkspace({ const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.') const [fullWorkflowError, setFullWorkflowError] = useState(null) const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new') + const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState(null) + const mapAnalysisRequestSequence = useRef(0) const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) const featureProperties = selectedMapFeature?.properties ?? null @@ -1331,6 +1346,8 @@ export function MapWorkspace({ } const clearAreaSelection = () => { + mapAnalysisRequestSequence.current += 1 + setMapAnalysisDurationMs(null) setBboxSelectionMode(false) setFirstSelectionCorner(null) setBboxInput(bboxToInputState(null)) @@ -1493,6 +1510,23 @@ export function MapWorkspace({ } } + const handleAnalysisModeKeyDown = ( + event: KeyboardEvent, + mode: 'current' | 'evolution', + ) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return + event.preventDefault() + const nextMode = event.key === 'ArrowLeft' || event.key === 'Home' + ? 'current' + : event.key === 'ArrowRight' || event.key === 'End' + ? 'evolution' + : mode + setExplorerMode(nextMode) + window.requestAnimationFrame(() => { + document.getElementById(`geo-analysis-tab-${nextMode}`)?.focus() + }) + } + const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const availableThemes: Array> = [] for (const theme of DATA_THEMES) { @@ -1524,6 +1558,10 @@ export function MapWorkspace({ } const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { + const requestId = mapAnalysisRequestSequence.current + 1 + mapAnalysisRequestSequence.current = requestId + const startedAt = Date.now() + setMapAnalysisDurationMs(null) setSelectionBbox(bbox) const tasks: Array> = [loadAllThemeResults(bbox, areaId)] if (activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive) { @@ -1532,7 +1570,13 @@ export function MapWorkspace({ if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId)) } - await Promise.all(tasks) + try { + await Promise.all(tasks) + } finally { + if (mapAnalysisRequestSequence.current === requestId) { + setMapAnalysisDurationMs(Date.now() - startedAt) + } + } } const runTemporalComparison = () => { @@ -1638,7 +1682,12 @@ export function MapWorkspace({ if (!advancedMode) { return ( -
+

{activeScopeLabel} · geografische verkenner

@@ -1647,29 +1696,60 @@ export function MapWorkspace({
-
+ {workspaceLoading ? ( +
+
+ ) : workspaceError ? ( +
+
+ De werkruimte kon niet volledig worden geladen + {workspaceError} +
+
+ ) : null} +