Harden RC9 UX and performance states
This commit is contained in:
+44
-4
@@ -76,8 +76,13 @@ function App(): JSX.Element {
|
||||
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
||||
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
|
||||
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
||||
const previousWorkspaceRef = useRef<WorkspaceKey>(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 (
|
||||
<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
|
||||
</a>
|
||||
<header className="workbench-topbar">
|
||||
@@ -771,7 +796,8 @@ function App(): JSX.Element {
|
||||
</div>
|
||||
</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">
|
||||
<aside className="workbench-sidebar" aria-label="Navigatie van de werkruimte">
|
||||
@@ -804,7 +830,13 @@ function App(): JSX.Element {
|
||||
</nav>
|
||||
</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">
|
||||
<div className="workspace-heading-copy">
|
||||
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
|
||||
@@ -861,7 +893,11 @@ function App(): JSX.Element {
|
||||
|
||||
{activeWorkspace === 'data' ? (
|
||||
<div className="workspace-grid workspace-grid-data">
|
||||
<SourceCatalogPanel datasets={datasets} projectId={selectedProjectId ?? undefined} />
|
||||
<SourceCatalogPanel
|
||||
datasets={datasets}
|
||||
projectId={selectedProjectId ?? undefined}
|
||||
loading={loadingDatasets}
|
||||
/>
|
||||
<ProjectPanel
|
||||
projects={projects}
|
||||
selectedProjectId={selectedProjectId}
|
||||
@@ -943,6 +979,10 @@ function App(): JSX.Element {
|
||||
coverage={mapCoverage}
|
||||
coverageLoading={mapCoverageLoading}
|
||||
coverageError={mapCoverageError}
|
||||
coverageDurationMs={mapCoverageDurationMs}
|
||||
coverageBudgetExceeded={mapCoverageBudgetExceeded}
|
||||
workspaceLoading={workspaceDataLoading}
|
||||
workspaceError={errorMessage}
|
||||
selectionExporting={selectionExporting}
|
||||
selectionExportError={selectionExportError}
|
||||
latestSelectionExportPath={latestSelectionExport?.path ?? null}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('useCoverageResolver', () => {
|
||||
expect(result.current.coverage).toBeNull()
|
||||
expect(result.current.coverageError).toBeNull()
|
||||
expect(result.current.loadingCoverage).toBe(false)
|
||||
expect(result.current.coverageDurationMs).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes provider failures without retaining stale results', async () => {
|
||||
@@ -95,4 +96,28 @@ describe('useCoverageResolver', () => {
|
||||
expect(result.current.coverage).toBeNull()
|
||||
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 { externalApi } from '../services/api'
|
||||
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
|
||||
import {
|
||||
COVERAGE_RESPONSE_BUDGET_MS,
|
||||
exceedsPerformanceBudget,
|
||||
} from '../lib/performanceBudget'
|
||||
|
||||
interface CoverageResolverOptions {
|
||||
projectId: string | null
|
||||
@@ -11,18 +15,22 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
||||
const [coverage, setCoverage] = useState<CoverageResolveResponse | null>(null)
|
||||
const [loadingCoverage, setLoadingCoverage] = useState(false)
|
||||
const [coverageError, setCoverageError] = useState<string | null>(null)
|
||||
const [coverageDurationMs, setCoverageDurationMs] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !bbox) {
|
||||
setCoverage(null)
|
||||
setCoverageError(null)
|
||||
setLoadingCoverage(false)
|
||||
setCoverageDurationMs(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
const startedAt = Date.now()
|
||||
setLoadingCoverage(true)
|
||||
setCoverageError(null)
|
||||
setCoverageDurationMs(null)
|
||||
externalApi.resolveCoverage({
|
||||
projectId,
|
||||
bbox: {
|
||||
@@ -45,6 +53,7 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setCoverageDurationMs(Date.now() - startedAt)
|
||||
setLoadingCoverage(false)
|
||||
}
|
||||
})
|
||||
@@ -59,5 +68,10 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
|
||||
coverage,
|
||||
loadingCoverage,
|
||||
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] {
|
||||
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 {
|
||||
padding: 18px 20px;
|
||||
border-block: 1px solid var(--line);
|
||||
|
||||
@@ -1656,6 +1656,11 @@ details.ai-lab-model-surface > summary strong {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.workbench-main > .workspace-persistent-map {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.workbench-layout {
|
||||
grid-template-columns: 13.25rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user