Connect live analysis journey to map workflow
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user