Harden RC9 UX and performance states
This commit is contained in:
@@ -49,6 +49,19 @@
|
|||||||
- Passed the RC-8 release gate with 1,012 backend tests, 12 frontend unit
|
- 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
|
tests, frontend typecheck/build, one Alembic head and a clean live E2E
|
||||||
console/request audit.
|
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,
|
- Added a read-only release-evidence manifest command with Git, migration,
|
||||||
dependency, configuration checksum and optional live endpoint evidence.
|
dependency, configuration checksum and optional live endpoint evidence.
|
||||||
- Replaced the obsolete pre-build status with the current implemented
|
- Replaced the obsolete pre-build status with the current implemented
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -351,7 +351,8 @@ def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
|
|||||||
|
|
||||||
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
|
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
|
||||||
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
|
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
|
||||||
assert "<main ref={workbenchMainRef}" in app
|
assert "<main" in app
|
||||||
|
assert "ref={workbenchMainRef}" in app
|
||||||
|
|
||||||
|
|
||||||
def test_quality_scores_have_plain_language_interpretation() -> None:
|
def test_quality_scores_have_plain_language_interpretation() -> None:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
uses only the explicit technical demo-fixture flow for AI validation. Runtime
|
||||||
screenshots and the machine-readable manifest are ignored build evidence and
|
screenshots and the machine-readable manifest are ignored build evidence and
|
||||||
must not be committed.
|
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`.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:e2e": "node e2e/releaseJourneys.mjs"
|
"test:e2e": "node e2e/releaseJourneys.mjs",
|
||||||
|
"test:e2e:ux": "node e2e/uxAudit.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
|||||||
+44
-4
@@ -76,8 +76,13 @@ function App(): JSX.Element {
|
|||||||
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
||||||
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
|
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
|
||||||
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
||||||
|
const previousWorkspaceRef = useRef<WorkspaceKey>(activeWorkspace)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
|
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
|
||||||
|
if (previousWorkspaceRef.current !== activeWorkspace) {
|
||||||
|
workbenchMainRef.current?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
previousWorkspaceRef.current = activeWorkspace
|
||||||
setInspectorOpen(false)
|
setInspectorOpen(false)
|
||||||
}, [activeWorkspace])
|
}, [activeWorkspace])
|
||||||
const {
|
const {
|
||||||
@@ -482,6 +487,8 @@ function App(): JSX.Element {
|
|||||||
coverage: mapCoverage,
|
coverage: mapCoverage,
|
||||||
loadingCoverage: mapCoverageLoading,
|
loadingCoverage: mapCoverageLoading,
|
||||||
coverageError: mapCoverageError,
|
coverageError: mapCoverageError,
|
||||||
|
coverageDurationMs: mapCoverageDurationMs,
|
||||||
|
coverageBudgetExceeded: mapCoverageBudgetExceeded,
|
||||||
} = useCoverageResolver({
|
} = useCoverageResolver({
|
||||||
projectId: selectedProjectId,
|
projectId: selectedProjectId,
|
||||||
bbox: mapSelectionBbox,
|
bbox: mapSelectionBbox,
|
||||||
@@ -715,12 +722,22 @@ function App(): JSX.Element {
|
|||||||
const bathymetryProfileCount = regionalBathymetryContextActive
|
const bathymetryProfileCount = regionalBathymetryContextActive
|
||||||
? regionalBathymetryProfileCount
|
? regionalBathymetryProfileCount
|
||||||
: selectedDataset?.feature_count ?? selectedDataset?.vector_summary?.feature_count ?? 0
|
: 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
|
const datasetContextLabel = activeWorkspace === 'map' && mapContextSourceLabel
|
||||||
? mapContextSourceLabel
|
? mapContextSourceLabel
|
||||||
: analysisMapLayerActive && mapFeatureCollection
|
: analysisMapLayerActive && mapFeatureCollection
|
||||||
? `${mapLayerLabel} · controle vereist`
|
? `${mapLayerLabel} · controle vereist`
|
||||||
: selectedDataset
|
: selectedDataset
|
||||||
? getDatasetDisplayName(selectedDataset)
|
? getDatasetDisplayName(selectedDataset)
|
||||||
|
: workspaceDataLoading
|
||||||
|
? 'Bronnen worden geladen'
|
||||||
: datasets.length > 0
|
: datasets.length > 0
|
||||||
? 'Kies een bron'
|
? 'Kies een bron'
|
||||||
: 'Geen bron'
|
: 'Geen bron'
|
||||||
@@ -732,6 +749,8 @@ function App(): JSX.Element {
|
|||||||
: `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen`
|
: `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen`
|
||||||
: mapFeatureCollection
|
: mapFeatureCollection
|
||||||
? `${mapFeatureCount.toLocaleString('nl-BE')} objecten`
|
? `${mapFeatureCount.toLocaleString('nl-BE')} objecten`
|
||||||
|
: workspaceDataLoading
|
||||||
|
? 'Kaart wordt voorbereid'
|
||||||
: viewportVectorLayerActive
|
: viewportVectorLayerActive
|
||||||
? 'Kaartlaag gekozen'
|
? 'Kaartlaag gekozen'
|
||||||
: activeWorkspace === 'map' && selectedDataset?.dataset_type === 'raster'
|
: activeWorkspace === 'map' && selectedDataset?.dataset_type === 'raster'
|
||||||
@@ -740,7 +759,13 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell workbench-shell">
|
<div className="app-shell workbench-shell">
|
||||||
<a className="skip-link" href="#workspace-main">
|
<a
|
||||||
|
className="skip-link"
|
||||||
|
href="#workspace-main"
|
||||||
|
onClick={() => {
|
||||||
|
window.requestAnimationFrame(() => workbenchMainRef.current?.focus({ preventScroll: true }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
Ga naar de werkruimte
|
Ga naar de werkruimte
|
||||||
</a>
|
</a>
|
||||||
<header className="workbench-topbar">
|
<header className="workbench-topbar">
|
||||||
@@ -771,7 +796,8 @@ function App(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{errorMessage ? <p className="error">{errorMessage}</p> : null}
|
<p className="sr-only" role="status" aria-live="polite">{workspaceStatusMessage}</p>
|
||||||
|
{errorMessage ? <p className="error" role="alert">{errorMessage}</p> : null}
|
||||||
|
|
||||||
<div className="workbench-layout">
|
<div className="workbench-layout">
|
||||||
<aside className="workbench-sidebar" aria-label="Navigatie van de werkruimte">
|
<aside className="workbench-sidebar" aria-label="Navigatie van de werkruimte">
|
||||||
@@ -804,7 +830,13 @@ function App(): JSX.Element {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main ref={workbenchMainRef} className="workbench-main" id="workspace-main" tabIndex={-1}>
|
<main
|
||||||
|
ref={workbenchMainRef}
|
||||||
|
className="workbench-main"
|
||||||
|
id="workspace-main"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-busy={workspaceDataLoading}
|
||||||
|
>
|
||||||
{activeWorkspace !== 'map' ? <div className="workspace-heading">
|
{activeWorkspace !== 'map' ? <div className="workspace-heading">
|
||||||
<div className="workspace-heading-copy">
|
<div className="workspace-heading-copy">
|
||||||
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
|
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
|
||||||
@@ -861,7 +893,11 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
{activeWorkspace === 'data' ? (
|
{activeWorkspace === 'data' ? (
|
||||||
<div className="workspace-grid workspace-grid-data">
|
<div className="workspace-grid workspace-grid-data">
|
||||||
<SourceCatalogPanel datasets={datasets} projectId={selectedProjectId ?? undefined} />
|
<SourceCatalogPanel
|
||||||
|
datasets={datasets}
|
||||||
|
projectId={selectedProjectId ?? undefined}
|
||||||
|
loading={loadingDatasets}
|
||||||
|
/>
|
||||||
<ProjectPanel
|
<ProjectPanel
|
||||||
projects={projects}
|
projects={projects}
|
||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
@@ -943,6 +979,10 @@ function App(): JSX.Element {
|
|||||||
coverage={mapCoverage}
|
coverage={mapCoverage}
|
||||||
coverageLoading={mapCoverageLoading}
|
coverageLoading={mapCoverageLoading}
|
||||||
coverageError={mapCoverageError}
|
coverageError={mapCoverageError}
|
||||||
|
coverageDurationMs={mapCoverageDurationMs}
|
||||||
|
coverageBudgetExceeded={mapCoverageBudgetExceeded}
|
||||||
|
workspaceLoading={workspaceDataLoading}
|
||||||
|
workspaceError={errorMessage}
|
||||||
selectionExporting={selectionExporting}
|
selectionExporting={selectionExporting}
|
||||||
selectionExportError={selectionExportError}
|
selectionExportError={selectionExportError}
|
||||||
latestSelectionExportPath={latestSelectionExport?.path ?? null}
|
latestSelectionExportPath={latestSelectionExport?.path ?? null}
|
||||||
|
|||||||
@@ -741,7 +741,14 @@ function GeoMap({
|
|||||||
})
|
})
|
||||||
}, [qaEvidenceData, mapStyleReady])
|
}, [qaEvidenceData, mapStyleReady])
|
||||||
|
|
||||||
return <div className="map-container" ref={containerRef} />
|
return (
|
||||||
|
<div
|
||||||
|
className="map-container"
|
||||||
|
ref={containerRef}
|
||||||
|
role="region"
|
||||||
|
aria-label="Interactieve kaart. Gebruik de kaartbediening om te zoomen en de tekenknop om een gebied te selecteren."
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GeoMap
|
export default GeoMap
|
||||||
|
|||||||
@@ -284,8 +284,8 @@ export function DatasetPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingDatasets ? <p>Bronnen laden...</p> : null}
|
{loadingDatasets ? <p role="status" aria-live="polite">Bronnen laden...</p> : null}
|
||||||
{datasets.length === 0 ? (
|
{!loadingDatasets && datasets.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<strong>Nog geen bronnen beschikbaar</strong>
|
<strong>Nog geen bronnen beschikbaar</strong>
|
||||||
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
|
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
interface SourceCatalogPanelProps {
|
interface SourceCatalogPanelProps {
|
||||||
datasets: DatasetCreateResponse[]
|
datasets: DatasetCreateResponse[]
|
||||||
projectId?: string
|
projectId?: string
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_LABELS: Record<string, string> = {
|
const THEME_LABELS: Record<string, string> = {
|
||||||
@@ -75,7 +76,11 @@ function mdkStatusLabel(status: BathymetrySourceProbeRead['status']): string {
|
|||||||
return 'Configuratie controleren'
|
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<BathymetrySourceProbeRead | null>(null)
|
const [mdkProbe, setMdkProbe] = useState<BathymetrySourceProbeRead | null>(null)
|
||||||
const [mdkProbeLoading, setMdkProbeLoading] = useState(false)
|
const [mdkProbeLoading, setMdkProbeLoading] = useState(false)
|
||||||
const [mdkProbeError, setMdkProbeError] = useState<string | null>(null)
|
const [mdkProbeError, setMdkProbeError] = useState<string | null>(null)
|
||||||
@@ -163,6 +168,31 @@ export function SourceCatalogPanel({ datasets, projectId }: SourceCatalogPanelPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="workspace-panel source-catalog-panel"
|
||||||
|
aria-label="Beschikbare databronnen"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className="panel-title-row">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Broninventaris</span>
|
||||||
|
<h2>Welke vragen kan GeoIntel beantwoorden?</h2>
|
||||||
|
</div>
|
||||||
|
<span className="status-badge">bronnen laden</span>
|
||||||
|
</div>
|
||||||
|
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
||||||
|
<span className="geo-loading-indicator" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Broninventaris wordt opgebouwd</strong>
|
||||||
|
<small>Operationele en geplande bronnen worden pas getoond wanneer de projectdata compleet is.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
|
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
|
||||||
<div className="panel-title-row">
|
<div className="panel-title-row">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||||
import GeoMap from '../GeoMap'
|
import GeoMap from '../GeoMap'
|
||||||
import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
|
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'
|
import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
|
||||||
@@ -10,6 +10,11 @@ import { terrainImageUrl } from '../../lib/terrainImage'
|
|||||||
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
||||||
import { thematicRasterImageUrl } from '../../lib/thematicRaster'
|
import { thematicRasterImageUrl } from '../../lib/thematicRaster'
|
||||||
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
|
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
|
||||||
|
import {
|
||||||
|
MAP_ANALYSIS_BUDGET_MS,
|
||||||
|
exceedsPerformanceBudget,
|
||||||
|
formatPerformanceDuration,
|
||||||
|
} from '../../lib/performanceBudget'
|
||||||
import {
|
import {
|
||||||
bboxToInputState,
|
bboxToInputState,
|
||||||
bboxesEqual,
|
bboxesEqual,
|
||||||
@@ -562,6 +567,10 @@ interface MapWorkspaceProps {
|
|||||||
coverage: CoverageResolveResponse | null
|
coverage: CoverageResolveResponse | null
|
||||||
coverageLoading: boolean
|
coverageLoading: boolean
|
||||||
coverageError: string | null
|
coverageError: string | null
|
||||||
|
coverageDurationMs: number | null
|
||||||
|
coverageBudgetExceeded: boolean
|
||||||
|
workspaceLoading: boolean
|
||||||
|
workspaceError: string | null
|
||||||
selectionExporting: boolean
|
selectionExporting: boolean
|
||||||
selectionExportError: string | null
|
selectionExportError: string | null
|
||||||
latestSelectionExportPath: string | null
|
latestSelectionExportPath: string | null
|
||||||
@@ -651,6 +660,10 @@ export function MapWorkspace({
|
|||||||
coverage,
|
coverage,
|
||||||
coverageLoading,
|
coverageLoading,
|
||||||
coverageError,
|
coverageError,
|
||||||
|
coverageDurationMs,
|
||||||
|
coverageBudgetExceeded,
|
||||||
|
workspaceLoading,
|
||||||
|
workspaceError,
|
||||||
selectionExporting,
|
selectionExporting,
|
||||||
selectionExportError,
|
selectionExportError,
|
||||||
latestSelectionExportPath,
|
latestSelectionExportPath,
|
||||||
@@ -744,6 +757,8 @@ export function MapWorkspace({
|
|||||||
const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.')
|
const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.')
|
||||||
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
||||||
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
|
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
|
||||||
|
const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState<number | null>(null)
|
||||||
|
const mapAnalysisRequestSequence = useRef(0)
|
||||||
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
|
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
|
||||||
const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name))
|
const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name))
|
||||||
const featureProperties = selectedMapFeature?.properties ?? null
|
const featureProperties = selectedMapFeature?.properties ?? null
|
||||||
@@ -1331,6 +1346,8 @@ export function MapWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearAreaSelection = () => {
|
const clearAreaSelection = () => {
|
||||||
|
mapAnalysisRequestSequence.current += 1
|
||||||
|
setMapAnalysisDurationMs(null)
|
||||||
setBboxSelectionMode(false)
|
setBboxSelectionMode(false)
|
||||||
setFirstSelectionCorner(null)
|
setFirstSelectionCorner(null)
|
||||||
setBboxInput(bboxToInputState(null))
|
setBboxInput(bboxToInputState(null))
|
||||||
@@ -1493,6 +1510,23 @@ export function MapWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAnalysisModeKeyDown = (
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
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 loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
||||||
const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
|
const availableThemes: Array<MapThemeQuery<DataThemeId>> = []
|
||||||
for (const theme of DATA_THEMES) {
|
for (const theme of DATA_THEMES) {
|
||||||
@@ -1524,6 +1558,10 @@ export function MapWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
||||||
|
const requestId = mapAnalysisRequestSequence.current + 1
|
||||||
|
mapAnalysisRequestSequence.current = requestId
|
||||||
|
const startedAt = Date.now()
|
||||||
|
setMapAnalysisDurationMs(null)
|
||||||
setSelectionBbox(bbox)
|
setSelectionBbox(bbox)
|
||||||
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
|
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
|
||||||
if (activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive) {
|
if (activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive) {
|
||||||
@@ -1532,7 +1570,13 @@ export function MapWorkspace({
|
|||||||
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
|
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
|
||||||
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
|
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await Promise.all(tasks)
|
await Promise.all(tasks)
|
||||||
|
} finally {
|
||||||
|
if (mapAnalysisRequestSequence.current === requestId) {
|
||||||
|
setMapAnalysisDurationMs(Date.now() - startedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runTemporalComparison = () => {
|
const runTemporalComparison = () => {
|
||||||
@@ -1638,7 +1682,12 @@ export function MapWorkspace({
|
|||||||
|
|
||||||
if (!advancedMode) {
|
if (!advancedMode) {
|
||||||
return (
|
return (
|
||||||
<section className="geo-explorer" data-testid="map-workspace" aria-label={`Gebiedsverkenner ${activeScopeLabel}`}>
|
<section
|
||||||
|
className="geo-explorer"
|
||||||
|
data-testid="map-workspace"
|
||||||
|
aria-label={`Gebiedsverkenner ${activeScopeLabel}`}
|
||||||
|
aria-busy={workspaceLoading}
|
||||||
|
>
|
||||||
<header className="geo-explorer-header">
|
<header className="geo-explorer-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
||||||
@@ -1647,29 +1696,60 @@ export function MapWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
||||||
<button
|
<button
|
||||||
|
id="geo-analysis-tab-current"
|
||||||
className={analysisMode === 'current' ? 'active' : ''}
|
className={analysisMode === 'current' ? 'active' : ''}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={analysisMode === 'current'}
|
aria-selected={analysisMode === 'current'}
|
||||||
|
aria-controls="geo-explorer-results"
|
||||||
|
tabIndex={analysisMode === 'current' ? 0 : -1}
|
||||||
onClick={() => setExplorerMode('current')}
|
onClick={() => setExplorerMode('current')}
|
||||||
|
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'current')}
|
||||||
>
|
>
|
||||||
Laatste toestand
|
Laatste toestand
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
id="geo-analysis-tab-evolution"
|
||||||
className={analysisMode === 'evolution' ? 'active' : ''}
|
className={analysisMode === 'evolution' ? 'active' : ''}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={analysisMode === 'evolution'}
|
aria-selected={analysisMode === 'evolution'}
|
||||||
|
aria-controls="geo-explorer-results"
|
||||||
|
tabIndex={analysisMode === 'evolution' ? 0 : -1}
|
||||||
onClick={() => setExplorerMode('evolution')}
|
onClick={() => setExplorerMode('evolution')}
|
||||||
|
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'evolution')}
|
||||||
>
|
>
|
||||||
Evolutie
|
Evolutie
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary-action geo-explorer-advanced" type="button" onClick={() => setAdvancedMode(true)}>
|
<button
|
||||||
|
className="secondary-action geo-explorer-advanced"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdvancedMode(true)}
|
||||||
|
aria-expanded={advancedMode}
|
||||||
|
aria-controls="geo-advanced-workbench"
|
||||||
|
>
|
||||||
Geavanceerde werkbank
|
Geavanceerde werkbank
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{workspaceLoading ? (
|
||||||
|
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
||||||
|
<span className="geo-loading-indicator" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Databronnen worden gecontroleerd</strong>
|
||||||
|
<small>Beschikbaarheid verschijnt zodra de nationale werkruimte volledig is geladen.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : workspaceError ? (
|
||||||
|
<div className="geo-bootstrap-status geo-bootstrap-status-error" role="alert">
|
||||||
|
<div>
|
||||||
|
<strong>De werkruimte kon niet volledig worden geladen</strong>
|
||||||
|
<small>{workspaceError}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="geo-explorer-layout">
|
<div className="geo-explorer-layout">
|
||||||
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
||||||
<div className="geo-panel-heading">
|
<div className="geo-panel-heading">
|
||||||
@@ -1682,7 +1762,13 @@ export function MapWorkspace({
|
|||||||
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
||||||
<span>Ingeladen bereik</span>
|
<span>Ingeladen bereik</span>
|
||||||
<strong>{activeScopeLabel}</strong>
|
<strong>{activeScopeLabel}</strong>
|
||||||
<small>{municipalityAreaCount > 0 ? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar` : 'Regionale gegevens worden geladen'}</small>
|
<small>
|
||||||
|
{workspaceLoading
|
||||||
|
? 'Gebieden en bronnen worden geladen'
|
||||||
|
: municipalityAreaCount > 0
|
||||||
|
? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar`
|
||||||
|
: 'Geen gemeentelijke onderverdeling in deze werkruimte'}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="geo-theme-list">
|
<div className="geo-theme-list">
|
||||||
{DATA_THEMES.map((theme) => {
|
{DATA_THEMES.map((theme) => {
|
||||||
@@ -1692,9 +1778,9 @@ export function MapWorkspace({
|
|||||||
const temporalGroups = themeTemporalSeriesMap[theme.id]
|
const temporalGroups = themeTemporalSeriesMap[theme.id]
|
||||||
const temporalGroup = temporalGroups[0]
|
const temporalGroup = temporalGroups[0]
|
||||||
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
||||||
const available = analysisMode === 'current'
|
const available = !workspaceLoading && (analysisMode === 'current'
|
||||||
? Boolean(dataset || onDemandProduct)
|
? Boolean(dataset || onDemandProduct)
|
||||||
: Boolean(dataset) && evolutionAvailable
|
: Boolean(dataset) && evolutionAvailable)
|
||||||
const active = activeThemeId === theme.id
|
const active = activeThemeId === theme.id
|
||||||
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
|
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
|
||||||
return (
|
return (
|
||||||
@@ -1710,7 +1796,9 @@ export function MapWorkspace({
|
|||||||
<span>
|
<span>
|
||||||
<strong>{theme.label}</strong>
|
<strong>{theme.label}</strong>
|
||||||
<small>
|
<small>
|
||||||
{analysisMode === 'evolution'
|
{workspaceLoading
|
||||||
|
? 'Beschikbaarheid controleren'
|
||||||
|
: analysisMode === 'evolution'
|
||||||
? evolutionAvailable
|
? evolutionAvailable
|
||||||
? `${temporalGroup.items.length} meetmomenten${temporalRange ? ` · ${temporalRange}` : ''}`
|
? `${temporalGroup.items.length} meetmomenten${temporalRange ? ` · ${temporalRange}` : ''}`
|
||||||
: dataset
|
: dataset
|
||||||
@@ -1724,7 +1812,9 @@ export function MapWorkspace({
|
|||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<i>
|
<i>
|
||||||
{analysisMode === 'evolution'
|
{workspaceLoading
|
||||||
|
? 'Laden'
|
||||||
|
: analysisMode === 'evolution'
|
||||||
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
|
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
|
||||||
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Op aanvraag' : 'Ontbreekt'}
|
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Op aanvraag' : 'Ontbreekt'}
|
||||||
</i>
|
</i>
|
||||||
@@ -1736,7 +1826,9 @@ export function MapWorkspace({
|
|||||||
<div className="geo-source-summary">
|
<div className="geo-source-summary">
|
||||||
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
|
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
|
||||||
<strong>
|
<strong>
|
||||||
{analysisOverlayActive
|
{workspaceLoading
|
||||||
|
? 'Databronnen worden geladen'
|
||||||
|
: analysisOverlayActive
|
||||||
? mapLayerLabel
|
? mapLayerLabel
|
||||||
: analysisMode === 'evolution'
|
: analysisMode === 'evolution'
|
||||||
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
|
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
|
||||||
@@ -1747,7 +1839,9 @@ export function MapWorkspace({
|
|||||||
: activeOnDemandMapProduct?.displayName ?? 'Geen databron beschikbaar'}
|
: activeOnDemandMapProduct?.displayName ?? 'Geen databron beschikbaar'}
|
||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{analysisOverlayActive
|
{workspaceLoading
|
||||||
|
? 'Even geduld; ontbrekende bronnen worden pas na de laadcontrole gemeld.'
|
||||||
|
: analysisOverlayActive
|
||||||
? `${mapLayerSourceLabel} · AI-resultaat, controle vereist`
|
? `${mapLayerSourceLabel} · AI-resultaat, controle vereist`
|
||||||
: analysisMode === 'evolution'
|
: analysisMode === 'evolution'
|
||||||
? activeTemporalSeries.length >= 2
|
? activeTemporalSeries.length >= 2
|
||||||
@@ -2022,7 +2116,12 @@ export function MapWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="geo-results-panel" aria-label="Gebiedsanalyse">
|
<aside
|
||||||
|
id="geo-explorer-results"
|
||||||
|
className="geo-results-panel"
|
||||||
|
aria-label="Gebiedsanalyse"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="geo-panel-heading">
|
<div className="geo-panel-heading">
|
||||||
<span>3</span>
|
<span>3</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -2301,6 +2400,19 @@ export function MapWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{mapAnalysisDurationMs !== null ? (
|
||||||
|
<p
|
||||||
|
className={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
|
||||||
|
? 'geo-performance-status geo-performance-status-warning'
|
||||||
|
: 'geo-performance-status'}
|
||||||
|
role={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS) ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
Selectie geanalyseerd in {formatPerformanceDuration(mapAnalysisDurationMs)}.
|
||||||
|
{exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
|
||||||
|
? ' Dit overschrijdt het releasebudget van 15 seconden.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2326,7 +2438,7 @@ export function MapWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
<section id="geo-advanced-workbench" className="map-workspace-shell" data-testid="map-workspace">
|
||||||
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
|
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
|
||||||
Terug naar gebiedsverkenner
|
Terug naar gebiedsverkenner
|
||||||
</button>
|
</button>
|
||||||
@@ -2589,7 +2701,11 @@ export function MapWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{mapSelectionBbox ? (
|
{mapSelectionBbox ? (
|
||||||
<section className="coverage-resolution-surface" aria-label="Datadekking van de kaartselectie">
|
<section
|
||||||
|
className="coverage-resolution-surface"
|
||||||
|
aria-label="Datadekking van de kaartselectie"
|
||||||
|
aria-busy={coverageLoading}
|
||||||
|
>
|
||||||
<div className="panel-title-row">
|
<div className="panel-title-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Dekking van deze selectie</p>
|
<p className="eyebrow">Dekking van deze selectie</p>
|
||||||
@@ -2597,7 +2713,18 @@ export function MapWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
{coverageLoading ? <span className="count-pill">controleren</span> : null}
|
{coverageLoading ? <span className="count-pill">controleren</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{coverageError ? <p className="error">{coverageError}</p> : null}
|
{coverageError ? <p className="error" role="alert">{coverageError}</p> : null}
|
||||||
|
{coverageDurationMs !== null ? (
|
||||||
|
<p
|
||||||
|
className={coverageBudgetExceeded
|
||||||
|
? 'geo-performance-status geo-performance-status-warning'
|
||||||
|
: 'geo-performance-status'}
|
||||||
|
role={coverageBudgetExceeded ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}.
|
||||||
|
{coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{coverage ? (
|
{coverage ? (
|
||||||
<>
|
<>
|
||||||
<div className="coverage-zone-row">
|
<div className="coverage-zone-row">
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe('useCoverageResolver', () => {
|
|||||||
expect(result.current.coverage).toBeNull()
|
expect(result.current.coverage).toBeNull()
|
||||||
expect(result.current.coverageError).toBeNull()
|
expect(result.current.coverageError).toBeNull()
|
||||||
expect(result.current.loadingCoverage).toBe(false)
|
expect(result.current.loadingCoverage).toBe(false)
|
||||||
|
expect(result.current.coverageDurationMs).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes provider failures without retaining stale results', async () => {
|
it('exposes provider failures without retaining stale results', async () => {
|
||||||
@@ -95,4 +96,28 @@ describe('useCoverageResolver', () => {
|
|||||||
expect(result.current.coverage).toBeNull()
|
expect(result.current.coverage).toBeNull()
|
||||||
expect(result.current.loadingCoverage).toBe(false)
|
expect(result.current.loadingCoverage).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('records duration and exposes a slow coverage response', async () => {
|
||||||
|
let resolveRequest: ((value: CoverageResolveResponse) => void) | null = null
|
||||||
|
mocks.resolveCoverage.mockImplementationOnce(
|
||||||
|
() => new Promise<CoverageResolveResponse>((resolve) => {
|
||||||
|
resolveRequest = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
})
|
||||||
|
expect(result.current.loadingCoverage).toBe(true)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(4_001)
|
||||||
|
resolveRequest?.(coverageResult)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.coverageDurationMs).toBe(4_001)
|
||||||
|
expect(result.current.coverageBudgetExceeded).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { externalApi } from '../services/api'
|
import { externalApi } from '../services/api'
|
||||||
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
|
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
|
||||||
|
import {
|
||||||
|
COVERAGE_RESPONSE_BUDGET_MS,
|
||||||
|
exceedsPerformanceBudget,
|
||||||
|
} from '../lib/performanceBudget'
|
||||||
|
|
||||||
interface CoverageResolverOptions {
|
interface CoverageResolverOptions {
|
||||||
projectId: string | null
|
projectId: string | null
|
||||||
@@ -11,18 +15,22 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
|||||||
const [coverage, setCoverage] = useState<CoverageResolveResponse | null>(null)
|
const [coverage, setCoverage] = useState<CoverageResolveResponse | null>(null)
|
||||||
const [loadingCoverage, setLoadingCoverage] = useState(false)
|
const [loadingCoverage, setLoadingCoverage] = useState(false)
|
||||||
const [coverageError, setCoverageError] = useState<string | null>(null)
|
const [coverageError, setCoverageError] = useState<string | null>(null)
|
||||||
|
const [coverageDurationMs, setCoverageDurationMs] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || !bbox) {
|
if (!projectId || !bbox) {
|
||||||
setCoverage(null)
|
setCoverage(null)
|
||||||
setCoverageError(null)
|
setCoverageError(null)
|
||||||
setLoadingCoverage(false)
|
setLoadingCoverage(false)
|
||||||
|
setCoverageDurationMs(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
const startedAt = Date.now()
|
||||||
setLoadingCoverage(true)
|
setLoadingCoverage(true)
|
||||||
setCoverageError(null)
|
setCoverageError(null)
|
||||||
|
setCoverageDurationMs(null)
|
||||||
externalApi.resolveCoverage({
|
externalApi.resolveCoverage({
|
||||||
projectId,
|
projectId,
|
||||||
bbox: {
|
bbox: {
|
||||||
@@ -45,6 +53,7 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
setCoverageDurationMs(Date.now() - startedAt)
|
||||||
setLoadingCoverage(false)
|
setLoadingCoverage(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -59,5 +68,10 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
|||||||
coverage,
|
coverage,
|
||||||
loadingCoverage,
|
loadingCoverage,
|
||||||
coverageError,
|
coverageError,
|
||||||
|
coverageDurationMs,
|
||||||
|
coverageBudgetExceeded: exceedsPerformanceBudget(
|
||||||
|
coverageDurationMs,
|
||||||
|
COVERAGE_RESPONSE_BUDGET_MS,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
COVERAGE_RESPONSE_BUDGET_MS,
|
||||||
|
MAP_ANALYSIS_BUDGET_MS,
|
||||||
|
exceedsPerformanceBudget,
|
||||||
|
formatPerformanceDuration,
|
||||||
|
} from './performanceBudget'
|
||||||
|
|
||||||
|
describe('release performance budgets', () => {
|
||||||
|
it('keeps coverage stricter than complete map analysis', () => {
|
||||||
|
expect(COVERAGE_RESPONSE_BUDGET_MS).toBe(4_000)
|
||||||
|
expect(MAP_ANALYSIS_BUDGET_MS).toBe(15_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only marks completed work above its budget as slow', () => {
|
||||||
|
expect(exceedsPerformanceBudget(null, 4_000)).toBe(false)
|
||||||
|
expect(exceedsPerformanceBudget(4_000, 4_000)).toBe(false)
|
||||||
|
expect(exceedsPerformanceBudget(4_001, 4_000)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats timings for compact operator feedback', () => {
|
||||||
|
expect(formatPerformanceDuration(null)).toBeNull()
|
||||||
|
expect(formatPerformanceDuration(238)).toBe('238 ms')
|
||||||
|
expect(formatPerformanceDuration(1_250)).toBe('1,3 s')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const COVERAGE_RESPONSE_BUDGET_MS = 4_000
|
||||||
|
export const MAP_ANALYSIS_BUDGET_MS = 15_000
|
||||||
|
|
||||||
|
export function exceedsPerformanceBudget(
|
||||||
|
durationMs: number | null,
|
||||||
|
budgetMs: number,
|
||||||
|
): boolean {
|
||||||
|
return durationMs !== null && durationMs > budgetMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPerformanceDuration(durationMs: number | null): string | null {
|
||||||
|
if (durationMs === null) return null
|
||||||
|
if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))} ms`
|
||||||
|
return `${(durationMs / 1_000).toLocaleString('nl-BE', {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
})} s`
|
||||||
|
}
|
||||||
@@ -7347,6 +7347,133 @@ section {
|
|||||||
.workspace-persistent-map[hidden] {
|
.workspace-persistent-map[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#workspace-main:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-bootstrap-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
border: 1px solid #b9d2ca;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
background: #f1f8f6;
|
||||||
|
color: #24453d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-bootstrap-status > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-bootstrap-status strong {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-bootstrap-status small {
|
||||||
|
color: #5b6e68;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-bootstrap-status-error {
|
||||||
|
border-color: #d6aaa0;
|
||||||
|
background: #fff6f3;
|
||||||
|
color: #7a2f21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-loading-indicator {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 1rem;
|
||||||
|
border: 2px solid #a7c8bf;
|
||||||
|
border-top-color: #176a5c;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: geo-loading-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes geo-loading-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-performance-status {
|
||||||
|
margin: 0;
|
||||||
|
border-left: 3px solid #79a89c;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
background: #f5f9f8;
|
||||||
|
color: #4d625c;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-performance-status-warning {
|
||||||
|
border-left-color: #b36b1e;
|
||||||
|
background: #fff8ec;
|
||||||
|
color: #78450f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-theme-option strong,
|
||||||
|
.geo-theme-option small {
|
||||||
|
overflow: visible;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.geo-loading-indicator {
|
||||||
|
animation: none;
|
||||||
|
border-top-color: #a7c8bf;
|
||||||
|
background: #176a5c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2400px) {
|
||||||
|
.geo-explorer-layout {
|
||||||
|
grid-template-columns: minmax(19rem, 21rem) minmax(46rem, 1fr) minmax(23rem, 25rem);
|
||||||
|
height: clamp(42rem, calc(100dvh - 11rem), 70rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-theme-panel,
|
||||||
|
.geo-results-panel {
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-map-canvas .map-container {
|
||||||
|
min-height: 42rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.geo-explorer {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-explorer-header,
|
||||||
|
.geo-theme-panel,
|
||||||
|
.geo-results-panel {
|
||||||
|
padding-inline: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-theme-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-map-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-map-actions button {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.coverage-resolution-surface {
|
.coverage-resolution-surface {
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
border-block: 1px solid var(--line);
|
border-block: 1px solid var(--line);
|
||||||
|
|||||||
@@ -1656,6 +1656,11 @@ details.ai-lab-model-surface > summary strong {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-main > .workspace-persistent-map {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-layout {
|
.workbench-layout {
|
||||||
grid-template-columns: 13.25rem minmax(0, 1fr);
|
grid-template-columns: 13.25rem minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2069,3 +2069,19 @@ The command is intentionally live and creates missing Area records, an export,
|
|||||||
an assistant conversation and explicit technical demo AI records. It never
|
an assistant conversation and explicit technical demo AI records. It never
|
||||||
fetches fake production provider data. Evidence is written below the requested
|
fetches fake production provider data. Evidence is written below the requested
|
||||||
ignored `artifacts/` directory as screenshots plus `manifest.json`.
|
ignored `artifacts/` directory as screenshots plus `manifest.json`.
|
||||||
|
|
||||||
|
## RC-9 UX and performance audit
|
||||||
|
|
||||||
|
Run the non-mutating frontend audit against a deployed runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/run_rc9_ux_audit.sh \
|
||||||
|
http://192.168.10.150:1202 \
|
||||||
|
artifacts/rc9-ux-audit
|
||||||
|
```
|
||||||
|
|
||||||
|
The audit checks the map-first flow at mobile, desktop and ultrawide
|
||||||
|
viewports; rejects page-level horizontal overflow and visible controls without
|
||||||
|
an accessible name; verifies keyboard tabs and skip-link focus; simulates a
|
||||||
|
slow project bootstrap; and proves coverage timing is visible. It writes only
|
||||||
|
ignored screenshots and `manifest.json`.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://127.0.0.1:1202}}"
|
||||||
|
OUTPUT_DIR="${2:-artifacts/rc9-ux-audit}"
|
||||||
|
|
||||||
|
if ! command -v npm >/dev/null 2>&1; then
|
||||||
|
echo "Node.js/npm is required to execute the RC9 UX audit." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm --prefix frontend run test:e2e:ux -- \
|
||||||
|
--base-url "$BASE_URL" \
|
||||||
|
--output "../$OUTPUT_DIR"
|
||||||
@@ -127,6 +127,7 @@ ${PYTHON_BIN} -m compileall backend/app
|
|||||||
(cd frontend && npm run typecheck)
|
(cd frontend && npm run typecheck)
|
||||||
(cd frontend && npm run build)
|
(cd frontend && npm run build)
|
||||||
"${NODE_BIN}" --check frontend/e2e/releaseJourneys.mjs
|
"${NODE_BIN}" --check frontend/e2e/releaseJourneys.mjs
|
||||||
|
"${NODE_BIN}" --check frontend/e2e/uxAudit.mjs
|
||||||
bash -n scripts/live_migration_smoke.sh
|
bash -n scripts/live_migration_smoke.sh
|
||||||
bash -n scripts/deploy_tower.sh
|
bash -n scripts/deploy_tower.sh
|
||||||
bash -n scripts/verify_release_fresh_install.sh
|
bash -n scripts/verify_release_fresh_install.sh
|
||||||
@@ -160,4 +161,5 @@ bash -n scripts/verify_golden_qa_benchmark.sh
|
|||||||
bash -n scripts/verify_demo_cleanup_dry_run.sh
|
bash -n scripts/verify_demo_cleanup_dry_run.sh
|
||||||
bash -n scripts/capture_workbench_screenshots.sh
|
bash -n scripts/capture_workbench_screenshots.sh
|
||||||
bash -n scripts/run_rc8_release_journeys.sh
|
bash -n scripts/run_rc8_release_journeys.sh
|
||||||
|
bash -n scripts/run_rc9_ux_audit.sh
|
||||||
echo "== Run readiness check passed =="
|
echo "== Run readiness check passed =="
|
||||||
|
|||||||
Reference in New Issue
Block a user