feat: add interactive project atlas
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-07-26 02:03:36 +02:00
parent 15b31b981c
commit 5feaf76740
6 changed files with 604 additions and 0 deletions
+17
View File
@@ -11257,3 +11257,20 @@ Live release evidence:
- Tower stores the operator credential only as a quoted PBKDF2-SHA256 hash plus - 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 an independent random signing secret. The plaintext password was not added to
Git, documentation, the deployment template or the runtime environment. 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.
+2
View File
@@ -2,6 +2,8 @@
## Actieve post-RC datadekkingsfase ## 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- - [x] Implementeer de aangeleverde Stitch-landingspagina als echte React-
toegangspoort en bescherm de workbench met één veilig geconfigureerd toegangspoort en bescherm de workbench met één veilig geconfigureerd
operatoraccount, sessieverval, uitloggen en zichtbare foutstatussen. Dit is operatoraccount, sessieverval, uitloggen en zichtbare foutstatussen. Dit is
@@ -8,6 +8,7 @@ import type {
import type { useSourceFreshness } from '../../hooks/useSourceFreshness' import type { useSourceFreshness } from '../../hooks/useSourceFreshness'
import { SourceFreshnessPanel } from '../status/SourceFreshnessPanel' import { SourceFreshnessPanel } from '../status/SourceFreshnessPanel'
import { WorkbenchStatusStrip } from '../WorkbenchStatusStrip' import { WorkbenchStatusStrip } from '../WorkbenchStatusStrip'
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
export type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system' export type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
@@ -118,6 +119,15 @@ export function OverviewWorkspace({
return ( return (
<div className="workspace-stack"> <div className="workspace-stack">
<ProjectAtlasIllustration
projectName={selectedProject?.name ?? 'de GeoIntel-werkruimte'}
areaCount={areas.length}
datasetCount={datasets.length}
featureCount={activeLayerFeatureCount}
hasAnalysisOutput={hasAnalysisOutput}
exportCount={exports.length}
onOpenWorkspace={onOpenWorkspace}
/>
<WorkbenchStatusStrip <WorkbenchStatusStrip
selectedProject={selectedProject} selectedProject={selectedProject}
areas={areas} areas={areas}
@@ -0,0 +1,46 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
afterEach(cleanup)
describe('ProjectAtlasIllustration', () => {
it('derives the active project phase from real readiness and opens its workspace', () => {
const onOpenWorkspace = vi.fn()
render(
<ProjectAtlasIllustration
projectName="Belgium and North Sea Workbench"
areaCount={2}
datasetCount={7}
featureCount={0}
hasAnalysisOutput={false}
exportCount={0}
onOpenWorkspace={onOpenWorkspace}
/>,
)
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(
<ProjectAtlasIllustration
projectName="Mol Municipality Workbench"
areaCount={1}
datasetCount={3}
featureCount={14}
hasAnalysisOutput
exportCount={1}
onOpenWorkspace={() => undefined}
/>,
)
expect(document.querySelectorAll('.project-atlas-stage-ready')).toHaveLength(5)
expect(screen.getByText('14', { selector: '.project-atlas-metrics strong' })).toBeTruthy()
})
})
@@ -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 (
<section className="project-atlas" aria-labelledby="project-atlas-title">
<div className="project-atlas-copy">
<p className="eyebrow">Levende projectatlas</p>
<h2 id="project-atlas-title">Van grondgebied naar aantoonbaar resultaat</h2>
<p>
Volg de operationele keten van <strong>{projectName}</strong>. Elke halte opent de bijbehorende werkruimte.
</p>
<div className="project-atlas-metrics" aria-label="Projectkerncijfers">
<span><strong>{areaCount}</strong> gebieden</span>
<span><strong>{datasetCount}</strong> bronnen</span>
<span><strong>{featureCount.toLocaleString('nl-BE')}</strong> objecten</span>
</div>
</div>
<div className="project-atlas-visual" aria-hidden="true">
<svg viewBox="0 0 680 300" role="presentation">
<defs>
<linearGradient id="atlas-sea" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#0b4b45" />
<stop offset="1" stopColor="#082a32" />
</linearGradient>
<linearGradient id="atlas-land" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#80d2ba" />
<stop offset="1" stopColor="#2f8f7d" />
</linearGradient>
<filter id="atlas-glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="7" />
</filter>
</defs>
<rect width="680" height="300" rx="28" fill="url(#atlas-sea)" />
<g className="atlas-grid-lines">
<path d="M0 60H680M0 120H680M0 180H680M0 240H680" />
<path d="M85 0V300M170 0V300M255 0V300M340 0V300M425 0V300M510 0V300M595 0V300" />
</g>
<path className="atlas-coast-halo" d="M260 5C303 32 298 60 334 72c31 10 62-7 87 16 30 27 3 53 28 77 21 20 70 9 78 45 8 38-42 52-47 85H181c5-34 42-53 36-86-7-39-54-52-46-88 6-31 50-29 64-56 12-23 5-42 25-60Z" />
<path className="atlas-land-shape" d="M260 5C303 32 298 60 334 72c31 10 62-7 87 16 30 27 3 53 28 77 21 20 70 9 78 45 8 38-42 52-47 85H181c5-34 42-53 36-86-7-39-54-52-46-88 6-31 50-29 64-56 12-23 5-42 25-60Z" />
<g className="atlas-contours">
<path d="M225 61c46 18 63 2 102 25 38 22 54 15 91 33" />
<path d="M197 126c58-19 95 5 129 28 39 27 83 18 130 39" />
<path d="M210 220c44-23 87-17 126 3 38 19 79 5 132 24" />
</g>
<path className="atlas-route" pathLength="1" d="M116 222C180 188 193 114 275 130c65 13 91 78 157 49 55-24 69-78 129-92" />
{signalPoints.map((point, index) => (
<g className={`atlas-signal atlas-signal-${index + 1}`} key={`${point.x}-${point.y}`}>
<circle className="atlas-signal-glow" cx={point.x} cy={point.y} r="15" filter="url(#atlas-glow)" />
<circle className="atlas-signal-ring" cx={point.x} cy={point.y} r="10" />
<circle className="atlas-signal-core" cx={point.x} cy={point.y} r="3.5" />
</g>
))}
<g className="atlas-scan">
<path d="M60 48H620" />
<rect x="58" y="41" width="564" height="14" rx="7" />
</g>
</svg>
<span className="project-atlas-live"><i /> live projectbeeld</span>
</div>
<div className="project-atlas-stages" aria-label="Open een projectfase">
{stages.map((stage, index) => {
const Icon = stage.icon
const state = readiness[index] ? 'ready' : index === currentIndex ? 'active' : 'waiting'
return (
<button
key={`${stage.label}-${index}`}
type="button"
className={`project-atlas-stage project-atlas-stage-${state}`}
onClick={() => onOpenWorkspace(stage.key)}
aria-label={`Open ${stage.label.toLowerCase()}`}
>
<span><Icon aria-hidden="true" /></span>
<strong>{stage.label}</strong>
<small>{state === 'ready' ? 'gereed' : state === 'active' ? 'volgende' : 'wacht'}</small>
</button>
)
})}
</div>
</section>
)
}
+408
View File
@@ -964,3 +964,411 @@ body {
transition-duration: 0.01ms !important; 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;
}
}