diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index a4a4bb3b..6aa05b57 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -11257,3 +11257,20 @@ Live release evidence: - Tower stores the operator credential only as a quoted PBKDF2-SHA256 hash plus an independent random signing secret. The plaintext password was not added to Git, documentation, the deployment template or the runtime environment. +## 2026-07-26 - Interactieve projectatlas + +Implemented: + +- added a responsive SVG project atlas to the status workspace, driven by persisted area, dataset, visible-feature, analysis and export state; +- made all five project phases keyboard-accessible navigation actions into the existing workspaces; +- added restrained route, scan and signal animation with a complete `prefers-reduced-motion` fallback; +- added a topographic micro-illustration to active project context without changing API, persistence or geospatial semantics; +- kept the illustration code-native instead of generating a static bitmap so real operational state remains authoritative. + +Validation: + +- frontend unit tests: 38 passed across 11 files; +- frontend TypeScript check: passed; +- frontend production build: passed; +- frontend dependency audit: zero vulnerabilities at the high threshold; +- dedicated tests verify readiness derivation and workspace navigation. diff --git a/docs/TODO.md b/docs/TODO.md index 22b59fb4..13a01efb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,8 @@ ## Actieve post-RC datadekkingsfase +- [x] Voeg een interactieve, data-gedreven projectatlas toe aan de statuswerkruimte met toegankelijke navigatie, echte readiness-toestanden en reduced-motion ondersteuning. + - [x] Implementeer de aangeleverde Stitch-landingspagina als echte React- toegangspoort en bescherm de workbench met één veilig geconfigureerd operatoraccount, sessieverval, uitloggen en zichtbare foutstatussen. Dit is diff --git a/frontend/src/components/overview/OverviewWorkspace.tsx b/frontend/src/components/overview/OverviewWorkspace.tsx index ece79d6e..10e883df 100644 --- a/frontend/src/components/overview/OverviewWorkspace.tsx +++ b/frontend/src/components/overview/OverviewWorkspace.tsx @@ -8,6 +8,7 @@ import type { import type { useSourceFreshness } from '../../hooks/useSourceFreshness' import { SourceFreshnessPanel } from '../status/SourceFreshnessPanel' import { WorkbenchStatusStrip } from '../WorkbenchStatusStrip' +import { ProjectAtlasIllustration } from './ProjectAtlasIllustration' export type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system' @@ -118,6 +119,15 @@ export function OverviewWorkspace({ return (
+ { + it('derives the active project phase from real readiness and opens its workspace', () => { + const onOpenWorkspace = vi.fn() + + render( + , + ) + + expect(screen.getByText('2', { selector: '.project-atlas-metrics strong' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Open kaart' }).className).toContain('project-atlas-stage-active') + + fireEvent.click(screen.getByRole('button', { name: 'Open kaart' })) + expect(onOpenWorkspace).toHaveBeenCalledWith('map') + }) + + it('marks the complete chain ready without inventing additional data', () => { + render( + undefined} + />, + ) + + expect(document.querySelectorAll('.project-atlas-stage-ready')).toHaveLength(5) + expect(screen.getByText('14', { selector: '.project-atlas-metrics strong' })).toBeTruthy() + }) +}) diff --git a/frontend/src/components/overview/ProjectAtlasIllustration.tsx b/frontend/src/components/overview/ProjectAtlasIllustration.tsx new file mode 100644 index 00000000..4189df70 --- /dev/null +++ b/frontend/src/components/overview/ProjectAtlasIllustration.tsx @@ -0,0 +1,121 @@ +import { BadgeCheck, Download, Layers3, Map, ScanSearch } from 'lucide-react' +import type { WorkspaceKey } from './OverviewWorkspace' + +interface ProjectAtlasIllustrationProps { + projectName: string + areaCount: number + datasetCount: number + featureCount: number + hasAnalysisOutput: boolean + exportCount: number + onOpenWorkspace: (target: WorkspaceKey) => void +} + +const stages = [ + { key: 'data', label: 'Gebied', icon: Map }, + { key: 'data', label: 'Bronnen', icon: Layers3 }, + { key: 'map', label: 'Kaart', icon: ScanSearch }, + { key: 'analysis', label: 'Analyse', icon: BadgeCheck }, + { key: 'exports', label: 'Export', icon: Download }, +] as const + +const signalPoints = [ + { x: 116, y: 222 }, + { x: 275, y: 130 }, + { x: 432, y: 179 }, + { x: 561, y: 87 }, +] as const + +export function ProjectAtlasIllustration({ + projectName, + areaCount, + datasetCount, + featureCount, + hasAnalysisOutput, + exportCount, + onOpenWorkspace, +}: ProjectAtlasIllustrationProps): JSX.Element { + const readiness = [areaCount > 0, datasetCount > 0, featureCount > 0, hasAnalysisOutput, exportCount > 0] + const activeIndex = readiness.findIndex((ready) => !ready) + const currentIndex = activeIndex === -1 ? readiness.length - 1 : activeIndex + + return ( +
+
+

Levende projectatlas

+

Van grondgebied naar aantoonbaar resultaat

+

+ Volg de operationele keten van {projectName}. Elke halte opent de bijbehorende werkruimte. +

+
+ {areaCount} gebieden + {datasetCount} bronnen + {featureCount.toLocaleString('nl-BE')} objecten +
+
+ + + +
+ {stages.map((stage, index) => { + const Icon = stage.icon + const state = readiness[index] ? 'ready' : index === currentIndex ? 'active' : 'waiting' + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/styles/atlas-premium-v2.css b/frontend/src/styles/atlas-premium-v2.css index 375f933d..dbab2af3 100644 --- a/frontend/src/styles/atlas-premium-v2.css +++ b/frontend/src/styles/atlas-premium-v2.css @@ -964,3 +964,411 @@ body { transition-duration: 0.01ms !important; } } + +/* Interactive project atlas */ + +.project-atlas { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: minmax(17rem, 0.78fr) minmax(26rem, 1.32fr); + gap: clamp(1.5rem, 3vw, 3.5rem); + overflow: hidden; + padding: clamp(1.35rem, 2.4vw, 2.35rem); + border: 1px solid rgba(111, 190, 169, 0.26); + border-radius: var(--atlas-radius-xl); + color: #fff; + background: + radial-gradient(circle at 18% 0%, rgba(64, 190, 164, 0.18), transparent 25rem), + linear-gradient(135deg, #092f2b 0%, #0b3f39 48%, #082c34 100%); + box-shadow: var(--atlas-shadow-floating); +} + +.project-atlas::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient(112deg, transparent 30%, rgba(255, 255, 255, 0.045) 49%, transparent 68%); + content: ""; + transform: translateX(-80%); + animation: atlas-surface-glint 12s ease-in-out infinite; +} + +.project-atlas-copy { + align-self: center; + max-width: 34rem; +} + +.project-atlas-copy .eyebrow { + color: #82d7c3; +} + +.project-atlas-copy h2 { + max-width: 14ch; + margin: 0.35rem 0 0.85rem; + color: #fff; + font-family: "Manrope", "Public Sans", sans-serif; + font-size: clamp(1.65rem, 2.2vw, 2.65rem); + line-height: 1.05; + letter-spacing: -0.045em; +} + +.project-atlas-copy > p:not(.eyebrow) { + margin: 0; + color: rgba(230, 247, 242, 0.72); + font-size: 0.9rem; + line-height: 1.7; +} + +.project-atlas-copy > p strong { + color: #fff; + font-weight: 650; +} + +.project-atlas-metrics { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin-top: 1.35rem; +} + +.project-atlas-metrics span { + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + padding: 0.55rem 0.7rem; + border: 1px solid rgba(178, 230, 216, 0.16); + border-radius: 999px; + color: rgba(230, 247, 242, 0.7); + background: rgba(255, 255, 255, 0.055); + font-size: 0.7rem; + backdrop-filter: blur(10px); +} + +.project-atlas-metrics strong { + color: #fff; + font-size: 0.82rem; +} + +.project-atlas-visual { + position: relative; + align-self: center; + min-width: 0; + perspective: 900px; +} + +.project-atlas-visual svg { + display: block; + width: 100%; + height: auto; + overflow: visible; + border: 1px solid rgba(157, 222, 205, 0.18); + border-radius: 1.75rem; + box-shadow: 0 30px 70px rgba(1, 19, 23, 0.36); + transform: rotateX(2deg) rotateY(-3deg); + transform-origin: center; + transition: transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 500ms ease; +} + +.project-atlas:hover .project-atlas-visual svg, +.project-atlas:focus-within .project-atlas-visual svg { + box-shadow: 0 35px 82px rgba(1, 19, 23, 0.46); + transform: rotateX(0) rotateY(0) translateY(-0.2rem); +} + +.atlas-grid-lines path { + fill: none; + stroke: rgba(193, 233, 223, 0.075); + stroke-width: 1; +} + +.atlas-coast-halo { + fill: rgba(87, 229, 196, 0.28); + filter: blur(16px); +} + +.atlas-land-shape { + fill: url(#atlas-land); + stroke: rgba(225, 250, 243, 0.48); + stroke-width: 1.2; +} + +.atlas-contours path { + fill: none; + stroke: rgba(230, 251, 244, 0.23); + stroke-width: 1.5; + stroke-dasharray: 4 7; +} + +.atlas-route { + fill: none; + stroke: #edc86c; + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 0.72 0.28; + animation: atlas-route-draw 5.5s ease-in-out infinite alternate; +} + +.atlas-signal-glow { + fill: #5fe0c2; + opacity: 0.75; + animation: atlas-signal-pulse 2.8s ease-out infinite; +} + +.atlas-signal-ring { + fill: rgba(8, 47, 43, 0.72); + stroke: #a3eddb; + stroke-width: 2; +} + +.atlas-signal-core { + fill: #fff; +} + +.atlas-signal-2 .atlas-signal-glow { animation-delay: 0.65s; } +.atlas-signal-3 .atlas-signal-glow { animation-delay: 1.3s; } +.atlas-signal-4 .atlas-signal-glow { animation-delay: 1.95s; } + +.atlas-scan path { + stroke: rgba(179, 242, 226, 0.76); + stroke-width: 1; +} + +.atlas-scan rect { + fill: rgba(105, 224, 198, 0.1); +} + +.atlas-scan { + animation: atlas-scan-pass 7s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.project-atlas-live { + position: absolute; + top: 0.85rem; + right: 0.85rem; + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.6rem; + border: 1px solid rgba(190, 236, 224, 0.16); + border-radius: 999px; + color: rgba(238, 252, 248, 0.78); + background: rgba(5, 35, 37, 0.7); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + backdrop-filter: blur(12px); +} + +.project-atlas-live i { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: #62dfbd; + box-shadow: 0 0 0 0 rgba(98, 223, 189, 0.55); + animation: atlas-live-pulse 2.2s ease-out infinite; +} + +.project-atlas-stages { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.55rem; + margin-top: -0.3rem; +} + +.project-atlas-stage { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 0.65rem; + align-items: center; + min-width: 0; + padding: 0.68rem; + border: 1px solid rgba(178, 230, 216, 0.14); + border-radius: 0.8rem; + color: rgba(230, 247, 242, 0.7); + background: rgba(255, 255, 255, 0.045); + text-align: left; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.project-atlas-stage:hover, +.project-atlas-stage:focus-visible { + border-color: rgba(142, 225, 205, 0.55); + color: #fff; + background: rgba(255, 255, 255, 0.1); + transform: translateY(-0.18rem); +} + +.project-atlas-stage > span { + grid-row: 1 / 3; + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 0.58rem; + background: rgba(255, 255, 255, 0.08); +} + +.project-atlas-stage svg { + width: 1rem; + height: 1rem; +} + +.project-atlas-stage strong, +.project-atlas-stage small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-atlas-stage strong { + color: currentColor; + font-size: 0.75rem; +} + +.project-atlas-stage small { + color: rgba(213, 238, 232, 0.48); + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.project-atlas-stage-ready > span { + color: #092f2b; + background: #79d7bd; +} + +.project-atlas-stage-active { + border-color: rgba(237, 200, 108, 0.5); + color: #fff; + background: rgba(237, 200, 108, 0.1); +} + +.project-atlas-stage-active > span { + color: #3a2b09; + background: #edc86c; + box-shadow: 0 0 0 0.25rem rgba(237, 200, 108, 0.1); +} + +.data-selection-summary-project { + position: relative; + overflow: hidden; +} + +.data-selection-summary-project::after { + position: absolute; + right: -1.25rem; + bottom: -1.6rem; + width: 7rem; + height: 5rem; + border: 1px solid rgba(13, 102, 91, 0.1); + border-radius: 48% 52% 35% 65%; + background: repeating-radial-gradient(ellipse at 40% 45%, transparent 0 7px, rgba(13, 102, 91, 0.07) 8px 9px); + content: ""; + transform: rotate(-12deg); + transition: transform 400ms ease; +} + +.data-selection-summary-project:hover::after { + transform: rotate(-4deg) scale(1.08); +} + +@keyframes atlas-surface-glint { + 0%, 18% { transform: translateX(-80%); } + 48%, 100% { transform: translateX(90%); } +} + +@keyframes atlas-route-draw { + from { stroke-dashoffset: 1; } + to { stroke-dashoffset: 0; } +} + +@keyframes atlas-signal-pulse { + 0% { opacity: 0.72; transform: scale(0.45); transform-origin: center; } + 70%, 100% { opacity: 0; transform: scale(1.65); transform-origin: center; } +} + +@keyframes atlas-scan-pass { + 0%, 12% { opacity: 0; transform: translateY(-28px); } + 20% { opacity: 1; } + 75% { opacity: 0.7; } + 88%, 100% { opacity: 0; transform: translateY(230px); } +} + +@keyframes atlas-live-pulse { + 0% { box-shadow: 0 0 0 0 rgba(98, 223, 189, 0.55); } + 70%, 100% { box-shadow: 0 0 0 0.45rem rgba(98, 223, 189, 0); } +} + +@media (max-width: 1100px) { + .project-atlas { + grid-template-columns: 1fr; + } + + .project-atlas-copy h2 { + max-width: 20ch; + } + + .project-atlas-visual { + max-width: 46rem; + } +} + +@media (max-width: 720px) { + .project-atlas { + gap: 1.15rem; + padding: 1rem; + border-radius: var(--atlas-radius-lg); + } + + .project-atlas-copy h2 { + font-size: 1.55rem; + } + + .project-atlas-metrics { + margin-top: 0.9rem; + } + + .project-atlas-stages { + grid-template-columns: repeat(5, minmax(3.15rem, 1fr)); + gap: 0.3rem; + overflow-x: auto; + padding-bottom: 0.2rem; + } + + .project-atlas-stage { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.55rem 0.25rem; + text-align: center; + } + + .project-atlas-stage > span { + width: 1.75rem; + height: 1.75rem; + } + + .project-atlas-stage strong { + width: 100%; + font-size: 0.62rem; + } + + .project-atlas-stage small { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .project-atlas::before, + .atlas-route, + .atlas-signal-glow, + .atlas-scan, + .project-atlas-live i { + animation: none !important; + } +}