Harden RC9 UX and performance states
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 06:37:54 +02:00
parent 55685ba1bd
commit 9b0239747d
21 changed files with 845 additions and 25 deletions
+8 -1
View File
@@ -741,7 +741,14 @@ function GeoMap({
})
}, [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
@@ -284,8 +284,8 @@ export function DatasetPanel({
))}
</div>
{loadingDatasets ? <p>Bronnen laden...</p> : null}
{datasets.length === 0 ? (
{loadingDatasets ? <p role="status" aria-live="polite">Bronnen laden...</p> : null}
{!loadingDatasets && datasets.length === 0 ? (
<div className="empty-state">
<strong>Nog geen bronnen beschikbaar</strong>
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
@@ -13,6 +13,7 @@ import {
interface SourceCatalogPanelProps {
datasets: DatasetCreateResponse[]
projectId?: string
loading?: boolean
}
const THEME_LABELS: Record<string, string> = {
@@ -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<BathymetrySourceProbeRead | null>(null)
const [mdkProbeLoading, setMdkProbeLoading] = useState(false)
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 (
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
<div className="panel-title-row">
+142 -15
View File
@@ -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<string | null>(null)
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 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<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 availableThemes: Array<MapThemeQuery<DataThemeId>> = []
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<Promise<unknown>> = [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 (
<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">
<div>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
@@ -1647,29 +1696,60 @@ export function MapWorkspace({
</div>
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
<button
id="geo-analysis-tab-current"
className={analysisMode === 'current' ? 'active' : ''}
type="button"
role="tab"
aria-selected={analysisMode === 'current'}
aria-controls="geo-explorer-results"
tabIndex={analysisMode === 'current' ? 0 : -1}
onClick={() => setExplorerMode('current')}
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'current')}
>
Laatste toestand
</button>
<button
id="geo-analysis-tab-evolution"
className={analysisMode === 'evolution' ? 'active' : ''}
type="button"
role="tab"
aria-selected={analysisMode === 'evolution'}
aria-controls="geo-explorer-results"
tabIndex={analysisMode === 'evolution' ? 0 : -1}
onClick={() => setExplorerMode('evolution')}
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'evolution')}
>
Evolutie
</button>
</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
</button>
</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">
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
<div className="geo-panel-heading">
@@ -1682,7 +1762,13 @@ export function MapWorkspace({
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
<span>Ingeladen bereik</span>
<strong>{activeScopeLabel}</strong>
<small>{municipalityAreaCount > 0 ? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar` : 'Regionale gegevens worden geladen'}</small>
<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 className="geo-theme-list">
{DATA_THEMES.map((theme) => {
@@ -1692,9 +1778,9 @@ export function MapWorkspace({
const temporalGroups = themeTemporalSeriesMap[theme.id]
const temporalGroup = temporalGroups[0]
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
const available = analysisMode === 'current'
const available = !workspaceLoading && (analysisMode === 'current'
? Boolean(dataset || onDemandProduct)
: Boolean(dataset) && evolutionAvailable
: Boolean(dataset) && evolutionAvailable)
const active = activeThemeId === theme.id
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
return (
@@ -1710,7 +1796,9 @@ export function MapWorkspace({
<span>
<strong>{theme.label}</strong>
<small>
{analysisMode === 'evolution'
{workspaceLoading
? 'Beschikbaarheid controleren'
: analysisMode === 'evolution'
? evolutionAvailable
? `${temporalGroup.items.length} meetmomenten${temporalRange ? ` · ${temporalRange}` : ''}`
: dataset
@@ -1724,7 +1812,9 @@ export function MapWorkspace({
</small>
</span>
<i>
{analysisMode === 'evolution'
{workspaceLoading
? 'Laden'
: analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Op aanvraag' : 'Ontbreekt'}
</i>
@@ -1736,7 +1826,9 @@ export function MapWorkspace({
<div className="geo-source-summary">
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
<strong>
{analysisOverlayActive
{workspaceLoading
? 'Databronnen worden geladen'
: analysisOverlayActive
? mapLayerLabel
: analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
@@ -1747,7 +1839,9 @@ export function MapWorkspace({
: activeOnDemandMapProduct?.displayName ?? 'Geen databron beschikbaar'}
</strong>
<small>
{analysisOverlayActive
{workspaceLoading
? 'Even geduld; ontbrekende bronnen worden pas na de laadcontrole gemeld.'
: analysisOverlayActive
? `${mapLayerSourceLabel} · AI-resultaat, controle vereist`
: analysisMode === 'evolution'
? activeTemporalSeries.length >= 2
@@ -2022,7 +2116,12 @@ export function MapWorkspace({
</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">
<span>3</span>
<div>
@@ -2301,6 +2400,19 @@ export function MapWorkspace({
) : 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>
</div>
@@ -2326,7 +2438,7 @@ export function MapWorkspace({
}
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)}>
Terug naar gebiedsverkenner
</button>
@@ -2589,7 +2701,11 @@ export function MapWorkspace({
) : null}
{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>
<p className="eyebrow">Dekking van deze selectie</p>
@@ -2597,7 +2713,18 @@ export function MapWorkspace({
</div>
{coverageLoading ? <span className="count-pill">controleren</span> : null}
</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 ? (
<>
<div className="coverage-zone-row">