Connect live analysis journey to map workflow
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:
Jens
2026-08-01 12:42:01 +02:00
parent 45fac06935
commit d7752cbb96
6 changed files with 477 additions and 0 deletions
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { LiveAnalysisJourney, resolveLiveAnalysisJourney } from './LiveAnalysisJourney'
const baseState = {
selectionMode: false,
hasSelection: false,
sourceLoading: false,
sourceReady: false,
processing: false,
validating: false,
hasResult: false,
verified: false,
error: null,
}
describe('resolveLiveAnalysisJourney', () => {
it('follows real workflow state without assuming completion', () => {
expect(resolveLiveAnalysisJourney(baseState).currentLabel).toBe('Selecteer')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceLoading: true }).currentLabel).toBe('Bronnen')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, processing: true }).currentLabel).toBe('Verwerk')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true }).currentLabel).toBe('Controleer')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true, verified: true }).status).toBe('complete')
})
it('marks the reached stage as failed', () => {
const view = resolveLiveAnalysisJourney({
...baseState,
hasSelection: true,
sourceReady: true,
processing: true,
error: 'Bron tijdelijk niet beschikbaar.',
})
expect(view.status).toBe('error')
expect(view.steps[2]).toBe('error')
})
})
describe('LiveAnalysisJourney', () => {
it('announces verified evidence only when verification exists', () => {
render(
<LiveAnalysisJourney
{...baseState}
hasSelection
sourceReady
hasResult
verified
selectionLabel="Vrije kaartselectie"
sourceLabel="Gebouwen"
statusMessage="Analyse voltooid"
resultLabel="Kwaliteitsbewijs beschikbaar"
/>,
)
expect(screen.getAllByText('Kwaliteitsbewijs beschikbaar')).toHaveLength(2)
expect(screen.getByTestId('live-analysis-journey').getAttribute('data-stage')).toBe('controleer')
})
})
@@ -0,0 +1,97 @@
import { BadgeCheck, Database, MapPinned, ScanSearch, ShieldAlert } from 'lucide-react'
export type LiveAnalysisStepStatus = 'waiting' | 'active' | 'complete' | 'error'
export interface LiveAnalysisJourneyState {
selectionMode: boolean
hasSelection: boolean
sourceLoading: boolean
sourceReady: boolean
processing: boolean
validating: boolean
hasResult: boolean
verified: boolean
error: string | null
}
export interface LiveAnalysisJourneyView {
currentIndex: number
currentLabel: string
status: 'idle' | 'running' | 'complete' | 'error'
steps: LiveAnalysisStepStatus[]
}
const STEP_LABELS = ['Selecteer', 'Bronnen', 'Verwerk', 'Controleer'] as const
export function resolveLiveAnalysisJourney(state: LiveAnalysisJourneyState): LiveAnalysisJourneyView {
let currentIndex = 0
if (state.validating || state.verified || state.hasResult) currentIndex = 3
else if (state.processing) currentIndex = 2
else if (state.hasSelection) currentIndex = state.sourceLoading || !state.sourceReady ? 1 : 2
const status: LiveAnalysisJourneyView['status'] = state.error
? 'error'
: state.verified
? 'complete'
: state.sourceLoading || state.processing || state.validating
? 'running'
: 'idle'
const steps: LiveAnalysisStepStatus[] = [
state.hasSelection ? 'complete' : 'active',
state.hasSelection && state.sourceReady ? 'complete' : state.hasSelection ? 'active' : 'waiting',
state.hasResult ? 'complete' : state.processing || (state.hasSelection && state.sourceReady) ? 'active' : 'waiting',
state.verified ? 'complete' : state.validating || state.hasResult ? 'active' : 'waiting',
]
if (state.error) steps[currentIndex] = 'error'
return { currentIndex, currentLabel: STEP_LABELS[currentIndex], status, steps }
}
interface LiveAnalysisJourneyProps extends LiveAnalysisJourneyState {
selectionLabel: string
sourceLabel: string
statusMessage: string
resultLabel: string
}
const steps = [
{ label: 'Selecteer', icon: MapPinned },
{ label: 'Bronnen', icon: Database },
{ label: 'Verwerk', icon: ScanSearch },
{ label: 'Controleer', icon: BadgeCheck },
] as const
export function LiveAnalysisJourney({ selectionLabel, sourceLabel, statusMessage, resultLabel, ...state }: LiveAnalysisJourneyProps): JSX.Element {
const view = resolveLiveAnalysisJourney(state)
const detail = view.currentIndex === 0
? state.selectionMode ? 'Teken nu op de kaart' : selectionLabel
: view.currentIndex === 1
? sourceLabel
: view.currentIndex === 3 && state.verified
? resultLabel
: statusMessage
return (
<div className={`live-analysis-journey live-analysis-journey-${view.status}`} data-testid="live-analysis-journey" data-stage={view.currentLabel.toLocaleLowerCase('nl-BE')} aria-live="polite">
<div className="live-analysis-status-card">
<span className="live-analysis-kicker"><i aria-hidden="true" /> Live analyse</span>
<strong>{state.error ? 'Analyse onderbroken' : view.currentLabel}</strong>
<small>{state.error ?? detail}</small>
</div>
<ol className="live-analysis-steps" aria-label="Voortgang van de gebiedsanalyse">
{steps.map(({ label, icon: Icon }, index) => (
<li className={`live-analysis-step live-analysis-step-${view.steps[index]}`} key={label}>
<span><Icon aria-hidden="true" /></span>
<small>{label}</small>
</li>
))}
</ol>
<div className={`live-analysis-proof live-analysis-proof-${state.verified ? 'ready' : 'pending'}`}>
{state.error ? <ShieldAlert aria-hidden="true" /> : <BadgeCheck aria-hidden="true" />}
<span>{state.verified ? resultLabel : state.hasResult ? 'Resultaat wacht op controle' : 'Controleerbaar resultaat'}</span>
</div>
</div>
)
}
@@ -8,6 +8,7 @@ import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart'
import { MunicipalitySearch } from './MunicipalitySearch'
import { LiveAnalysisJourney } from './LiveAnalysisJourney'
import { terrainImageUrl } from '../../lib/terrainImage'
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
@@ -1310,6 +1311,39 @@ export function MapWorkspace({
: onDemandProductMap.get(activeTheme.id) ?? null
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
const liveJourneyError = mapSelectionError
?? themeResultsError
?? temporalComparisonError
?? orthophotoAnalysisError
?? coverageError
?? workspaceError
const liveJourneyHasResult = Boolean(
mapSelectionResult
|| themeInsights.length > 0
|| temporalComparison
|| orthophotoResult
|| analysisOverlayActive,
)
const liveJourneyVerified = Boolean(mapSelectionQaResult || orthophotoAnalysisQuality)
const liveJourneyProcessing = Boolean(
mapSelectionLoading
|| themeResultsLoading
|| temporalComparisonLoading
|| orthophotoAnalysisRunning,
)
const liveJourneyValidating = Boolean(
mapSelectionQaRunning || orthophotoAnalysisStage === 'validating',
)
const liveJourneyStatus = orthophotoAnalysisStatus
|| (temporalComparisonLoading ? 'Officiële meetmomenten vergelijken…' : '')
|| (themeResultsLoading ? 'Begrensde bronnen verwerken…' : '')
|| (mapSelectionLoading ? 'Objecten binnen de selectie ophalen…' : '')
|| (liveJourneyHasResult ? 'Resultaat op de kaart beschikbaar' : 'Klaar om de selectie te verwerken')
const liveJourneyResultLabel = liveJourneyVerified
? 'Kwaliteitsbewijs beschikbaar'
: latestMapSelectionQualityCheckId
? 'Bewaard kwaliteitsbewijs beschikbaar'
: 'Controleerbaar resultaat'
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
const orthophotoImageOverlay = useMemo(
() => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4
@@ -2609,6 +2643,21 @@ export function MapWorkspace({
onMapBboxSelect={handleMapBboxSelect}
onViewportChange={onMapViewportChange}
/>
<LiveAnalysisJourney
selectionMode={bboxSelectionMode}
hasSelection={Boolean(mapSelectionBbox)}
sourceLoading={workspaceLoading || coverageLoading || officialMapProductsLoading}
sourceReady={activeThemeAvailable && !workspaceLoading && !coverageLoading}
processing={liveJourneyProcessing}
validating={liveJourneyValidating}
hasResult={liveJourneyHasResult}
verified={liveJourneyVerified}
error={liveJourneyError}
selectionLabel={mapSelectionBbox ? 'Begrensde kaartselectie' : selectedMapArea?.name ?? 'Nog geen gebied geselecteerd'}
sourceLabel={activeThemeAvailable ? activeTheme.shortLabel : 'Dekking wordt gecontroleerd'}
statusMessage={liveJourneyStatus}
resultLabel={liveJourneyResultLabel}
/>
<div className="geo-map-legend" aria-label="Kaartlegende">
<span><i className="geo-legend-area" /> Werkgebied</span>
{thematicRasterImageOverlays.length > 0 || walousRasterImageOverlays.length > 0 ? (