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
+33
View File
@@ -12091,3 +12091,36 @@ Open:
- The guest screenshots intentionally show governed fixture/demo evidence, not
a claim that the rejected V66 candidate labels or a new detector have been
promoted.
## 2026-08-01 - Live animated area-analysis workflow
### Changed
- Added a reusable `LiveAnalysisJourney` overlay to the real map canvas, using
the same dark glass, mint signal and four-stage language as the public
interactive project illustration.
- Bound Select, Sources, Process and Verify to persisted workbench state:
bounded AOI selection, source/coverage loading, extraction or image analysis,
QA validation, completed evidence and real error responses.
- Added a processing-only scan line, status pulse, responsive compact layout and
a complete `prefers-reduced-motion` fallback. The overlay uses
`pointer-events: none`, so drawing, panning and object inspection remain
available beneath it.
- Added a pure state resolver and component coverage for idle, loading,
processing, verification, completion and error transitions.
### Tested before deployment
- `npm run test:unit -- --run src/components/map/LiveAnalysisJourney.test.tsx`
passed: 3 tests.
- Complete frontend unit suite passed: 49 tests in 15 files.
- `npm run build` passed TypeScript compilation and the Vite production build.
- React review confirmed static step metadata is module-scoped, status is
derived during render, icons use direct imports and the overlay has an
accessible live region.
### Known limitation and next gate
- The local frontend preview cannot enter the workbench without its API runtime.
Tower deployment and browser validation against the real guest/operator data
remain the final acceptance gate for this pass.
+9
View File
@@ -1029,3 +1029,12 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Keep near-gate regional precision/recall stabilization inside a 0.03 sampling guard-band to prevent cross-region seesaw regressions.
- [ ] Open protected test and pure-background evidence only after every calibration gate passes.
- [ ] Queue final representative human sign-off only after all automated gates pass, then promote and redeploy the exact checksummed model.
# Sprint 233 - Live animated gebiedsanalyse
- [x] Vertaal de vier schakels van de welkomstillustratie naar de echte kaartworkflow.
- [x] Koppel Selecteer, Bronnen, Verwerk en Controleer uitsluitend aan bestaande API- en hookstatussen.
- [x] Toon verwerking, QA, fout en gereed als onderscheidbare live kaartstatussen zonder fictieve successen.
- [x] Voeg een responsieve desktop- en mobiele procesrail met reduced-motion fallback toe.
- [x] Dek de statusresolver en het geverifieerde-bewijslabel af met gerichte componenttests.
- [ ] Controleer na Tower-redeploy de operator- en gastworkflow visueel op desktop en mobiel.
@@ -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 ? (
+232
View File
@@ -1415,3 +1415,235 @@ button.overview-command-card { cursor: pointer; }
transition-duration: .01ms !important;
}
}
/* De marketingillustratie wordt hier een echte productstatus. Alle stappen
volgen API- en hookstate uit MapWorkspace; de laag onderschept geen kaartinput. */
.live-analysis-journey {
position: absolute;
z-index: 4;
inset: 0;
overflow: hidden;
color: #f5fffc;
pointer-events: none;
}
.live-analysis-status-card,
.live-analysis-proof,
.live-analysis-steps {
border: 1px solid rgba(190, 244, 232, 0.24);
background: rgba(4, 45, 40, 0.86);
box-shadow: 0 14px 36px rgba(2, 28, 25, 0.24);
backdrop-filter: blur(12px);
}
.live-analysis-status-card {
position: absolute;
top: var(--gi-space-4);
left: var(--gi-space-4);
display: grid;
gap: 2px;
width: min(14rem, calc(100% - 2rem));
border-radius: var(--gi-radius-md);
padding: 0.72rem 0.82rem;
}
.live-analysis-kicker {
color: #8ee7d2;
font-size: 0.55rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.live-analysis-kicker i {
display: inline-block;
width: 0.42rem;
height: 0.42rem;
margin-right: 0.4rem;
border-radius: 50%;
background: #77e2ca;
box-shadow: 0 0 0 5px rgba(119, 226, 202, 0.12);
}
.live-analysis-status-card strong {
font-family: Manrope, sans-serif;
font-size: 0.88rem;
}
.live-analysis-status-card small {
overflow: hidden;
color: rgba(245, 255, 252, 0.68);
font-size: 0.62rem;
line-height: 1.35;
text-overflow: ellipsis;
}
.live-analysis-steps {
position: absolute;
bottom: var(--gi-space-4);
left: var(--gi-space-4);
display: grid;
grid-template-columns: repeat(4, minmax(3.6rem, 1fr));
width: min(25rem, calc(100% - 2rem));
margin: 0;
border-radius: var(--gi-radius-md);
padding: 0.52rem 0.62rem;
list-style: none;
}
.live-analysis-step {
position: relative;
display: grid;
gap: 0.24rem;
justify-items: center;
color: rgba(245, 255, 252, 0.45);
}
.live-analysis-step:not(:last-child)::after {
position: absolute;
top: 0.68rem;
left: calc(50% + 0.9rem);
width: calc(100% - 1.8rem);
border-top: 1px dashed rgba(142, 231, 210, 0.3);
content: '';
}
.live-analysis-step > span {
display: grid;
z-index: 1;
place-items: center;
width: 1.45rem;
height: 1.45rem;
border: 1px solid rgba(190, 244, 232, 0.24);
border-radius: 50%;
background: #103f3a;
}
.live-analysis-step svg {
width: 0.72rem;
height: 0.72rem;
}
.live-analysis-step small {
font-size: 0.54rem;
font-weight: 750;
}
.live-analysis-step-active,
.live-analysis-step-complete {
color: #a8f3e2;
}
.live-analysis-step-active > span {
border-color: #8ee7d2;
box-shadow: 0 0 0 5px rgba(119, 226, 202, 0.12);
}
.live-analysis-step-complete > span {
border-color: #66d6bd;
background: #176a5c;
}
.live-analysis-step-error,
.live-analysis-journey-error .live-analysis-kicker {
color: #ffd2c2;
}
.live-analysis-step-error > span,
.live-analysis-journey-error .live-analysis-status-card {
border-color: rgba(255, 137, 105, 0.7);
}
.live-analysis-proof {
position: absolute;
top: var(--gi-space-4);
right: var(--gi-space-4);
display: flex;
gap: 0.46rem;
align-items: center;
max-width: min(15rem, 42%);
border-radius: var(--gi-radius-md);
padding: 0.62rem 0.72rem;
color: rgba(245, 255, 252, 0.64);
font-size: 0.59rem;
font-weight: 700;
}
.live-analysis-proof svg {
flex: 0 0 auto;
width: 0.88rem;
height: 0.88rem;
color: #8ee7d2;
}
.live-analysis-proof-ready {
border-color: rgba(142, 231, 210, 0.62);
color: #f5fffc;
}
.live-analysis-journey-running::after {
position: absolute;
top: 0;
bottom: 0;
left: 16%;
width: 2px;
background: linear-gradient(180deg, transparent, rgba(174, 255, 237, 0.86), transparent);
filter: drop-shadow(0 0 7px #7be6d1);
content: '';
animation: gi-live-analysis-scan 3.8s ease-in-out infinite;
}
.live-analysis-journey-running .live-analysis-kicker i {
animation: gi-live-analysis-pulse 1.8s ease-out infinite;
}
@keyframes gi-live-analysis-scan {
0%, 100% { opacity: 0; transform: translateX(0); }
15%, 85% { opacity: 0.72; }
50% { opacity: 0.92; transform: translateX(34vw); }
}
@keyframes gi-live-analysis-pulse {
50% { box-shadow: 0 0 0 9px rgba(119, 226, 202, 0); }
}
@media (max-width: 720px) {
.live-analysis-status-card {
top: var(--gi-space-3);
left: var(--gi-space-3);
width: min(12rem, calc(100% - 6rem));
}
.live-analysis-proof {
top: var(--gi-space-3);
right: var(--gi-space-3);
width: 2rem;
min-height: 2rem;
padding: 0;
justify-content: center;
}
.live-analysis-proof span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.live-analysis-steps {
right: var(--gi-space-3);
bottom: var(--gi-space-3);
left: var(--gi-space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
width: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.live-analysis-journey *,
.live-analysis-journey::after {
animation: none !important;
transition-duration: 0.01ms !important;
}
}