Complete GeoIntel visual system and portfolio case study
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-28 11:10:35 +02:00
parent 61e2505940
commit 0cd84fa5c5
19 changed files with 784 additions and 6 deletions
@@ -0,0 +1,26 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { AiPipelineIllustration } from './AiPipelineIllustration'
afterEach(cleanup)
describe('AiPipelineIllustration', () => {
it('shows the real readiness state and explains the selected stage', () => {
render(
<AiPipelineIllustration
hasImagery
hasTiles
gpuReady
hasDetections={false}
hasQualityEvidence={false}
running={false}
/>,
)
expect(screen.getByText('CUDA gereed')).toBeTruthy()
expect(screen.getByRole('tab', { name: /Detecties/ }).textContent).toContain('volgende stap')
fireEvent.click(screen.getByRole('tab', { name: /NVIDIA GPU/ }))
expect(screen.getByRole('tabpanel').textContent).toContain('Lokale PyTorch-inferentie')
})
})
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react'
interface AiPipelineIllustrationProps {
hasImagery: boolean
hasTiles: boolean
gpuReady: boolean
hasDetections: boolean
hasQualityEvidence: boolean
running: boolean
}
const pipelineStages = [
{ key: 'imagery', label: 'Orthofoto', title: 'Gegeorefereerd bronbeeld', detail: 'CRS, resolutie en ruimtelijke dekking blijven bij de dataset bewaard.', icon: Image },
{ key: 'tiles', label: 'Beeldtegels', title: 'Controleerbare tilevoorbereiding', detail: 'Overlap en tile-identiteit houden detecties herleidbaar naar hun bronpixel.', icon: Boxes },
{ key: 'gpu', label: 'NVIDIA GPU', title: 'Lokale PyTorch-inferentie', detail: 'GeoIntel gebruikt de server-GPU en faalt gesloten wanneer CUDA vereist maar niet beschikbaar is.', icon: Cpu },
{ key: 'detections', label: 'Detecties', title: 'Gegeorefereerde gebouwobjecten', detail: 'Confidence, modelversie, brontegel en geometrie worden als reproduceerbaar resultaat bewaard.', icon: ScanSearch },
{ key: 'quality', label: 'QA-bewijs', title: 'Controle vóór vrijgave', detail: 'Precision, recall, IoU en foutbewijs bepalen of een resultaat alleen verkennend of operationeel bruikbaar is.', icon: BadgeCheck },
] as const
export function AiPipelineIllustration({
hasImagery,
hasTiles,
gpuReady,
hasDetections,
hasQualityEvidence,
running,
}: AiPipelineIllustrationProps): JSX.Element {
const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence]
const firstIncomplete = readiness.findIndex((ready) => !ready)
const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete)
const selected = pipelineStages[selectedIndex]
return (
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby="ai-pipeline-title">
<div className="ai-pipeline-heading">
<div>
<p className="eyebrow">Van pixel naar bewijs</p>
<h3 id="ai-pipeline-title">PyTorch-keten op de NVIDIA-server</h3>
<p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p>
</div>
<span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}>
<i /> {gpuReady ? 'CUDA gereed' : 'CUDA controleren'}
</span>
</div>
<div className="ai-pipeline-track" role="tablist" aria-label="PyTorch-analysekten">
<span className="ai-pipeline-flow" aria-hidden="true" />
{pipelineStages.map(({ key, label, icon: Icon }, index) => (
<button
key={key}
id={`ai-pipeline-${key}`}
type="button"
role="tab"
aria-selected={selectedIndex === index}
aria-controls="ai-pipeline-detail"
className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'}
onClick={() => setSelectedIndex(index)}
>
<span><Icon aria-hidden="true" /></span>
<strong>{label}</strong>
<small>{readiness[index] ? 'gereed' : index === firstIncomplete ? 'volgende stap' : 'wachten'}</small>
</button>
))}
</div>
<div
id="ai-pipeline-detail"
className="ai-pipeline-detail"
role="tabpanel"
aria-labelledby={`ai-pipeline-${selected.key}`}
key={selected.key}
>
<span>{String(selectedIndex + 1).padStart(2, '0')}</span>
<div><strong>{selected.title}</strong><p>{selected.detail}</p></div>
</div>
</section>
)
}
@@ -13,6 +13,7 @@ import type {
import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../hooks/useDetectionWorkflow'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
import { AiPipelineIllustration } from './AiPipelineIllustration'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
@@ -285,6 +286,15 @@ export function DetectionLab({
</button>
</div>
<AiPipelineIllustration
hasImagery={detectionHasDataset}
hasTiles={detectionHasTileManifest}
gpuReady={yoloRuntimeReady && Boolean(yoloPreflight?.runtime.cuda_available)}
hasDetections={detectionItems.length > 0 || detectionRuns.some((run) => run.status === 'completed')}
hasQualityEvidence={Boolean(detectionQaResult) || qualityChecks.length > 0}
running={runningDetection || runningDetectionQa || runningDetectionCalibration}
/>
<div className="ai-user-summary" aria-label="Status gebouwdetectie">
<div className="ai-user-summary-card ai-user-summary-card-primary">
<span>Actieve analyse</span>
@@ -116,6 +116,9 @@ export function OverviewWorkspace({
target: 'exports',
},
]
const readyStepCount = steps.filter((step) => step.ready).length
const latestQualityCheck = qualityChecks[0] ?? null
const latestExport = exports[0] ?? null
return (
<div className="workspace-stack">
@@ -128,6 +131,40 @@ export function OverviewWorkspace({
exportCount={exports.length}
onOpenWorkspace={onOpenWorkspace}
/>
<section className="overview-command-center" aria-labelledby="overview-command-title">
<div className="overview-command-heading">
<div>
<p className="eyebrow">Projectbriefing</p>
<h2 id="overview-command-title">Wat vraagt nu aandacht?</h2>
</div>
<button type="button" className="secondary-action" onClick={() => onOpenWorkspace(recommendedTarget)}>
Volgende stap openen
</button>
</div>
<div className="overview-command-grid">
<article className="overview-command-card overview-command-card-progress">
<span>Voortgang</span>
<strong>{readyStepCount} / {steps.length}</strong>
<p>{workflowComplete ? 'De volledige keten is afgerond.' : steps.find((step) => !step.ready)?.detail}</p>
<div aria-hidden="true"><i style={{ width: `${(readyStepCount / steps.length) * 100}%` }} /></div>
</article>
<button type="button" className="overview-command-card" onClick={() => onOpenWorkspace('analysis')}>
<span>Laatste kwaliteitsbewijs</span>
<strong>{latestQualityCheck?.score != null ? latestQualityCheck.score.toFixed(3) : 'Nog geen score'}</strong>
<p>{latestQualityCheck ? `${latestQualityCheck.check_type.replace(/_/g, ' ')} · ${latestQualityCheck.status}` : 'Voer een controle uit wanneer kandidaat- en referentiedata klaarstaan.'}</p>
</button>
<button type="button" className="overview-command-card" onClick={() => onOpenWorkspace('data')}>
<span>Brondekking</span>
<strong>{datasets.length} bronnen</strong>
<p>{areas.length > 0 ? `Beschikbaar voor ${areas.length} ${areas.length === 1 ? 'gebied' : 'gebieden'}.` : 'Definieer eerst een werkgebied.'}</p>
</button>
<button type="button" className="overview-command-card" onClick={() => onOpenWorkspace('exports')}>
<span>Laatste resultaat</span>
<strong>{latestExport ? latestExport.status : 'Nog niet bewaard'}</strong>
<p>{latestExport ? latestExport.export_type.replace(/_/g, ' ') : 'Een gecontroleerde export verschijnt hier als volgende overdraagbare versie.'}</p>
</button>
</div>
</section>
<WorkbenchStatusStrip
selectedProject={selectedProject}
areas={areas}
+114
View File
@@ -1301,3 +1301,117 @@
scroll-behavior: auto !important;
}
}
/* ==========================================================================
Sprint 231 - motion language and interactive AI pipeline
========================================================================== */
.ai-pipeline {
position: relative;
isolation: isolate;
overflow: hidden;
padding: clamp(1rem, 2vw, 1.5rem);
border: 1px solid color-mix(in srgb, var(--gi-brand-500) 25%, var(--gi-line));
border-radius: var(--gi-radius-lg);
color: #edfdf8;
background:
linear-gradient(115deg, rgba(255,255,255,.035), transparent 38%),
radial-gradient(circle at 82% 10%, rgba(69, 209, 177, .15), transparent 20rem),
#082f2b;
box-shadow: var(--gi-shadow-md);
}
.overview-command-center { display: grid; gap: var(--gi-space-4); padding: clamp(1rem, 2vw, 1.5rem); border: 1px solid var(--gi-line); border-radius: var(--gi-radius-lg); background: var(--gi-surface); box-shadow: var(--gi-shadow-sm); }
.overview-command-heading { display: flex; gap: var(--gi-space-4); align-items: end; justify-content: space-between; }
.overview-command-heading h2 { margin: .2rem 0 0; font: 700 clamp(1.15rem, 2vw, 1.55rem)/1.2 Manrope, sans-serif; }
.overview-command-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: var(--gi-space-3); }
.overview-command-card { position: relative; display: grid; gap: .35rem; min-width: 0; min-height: 8rem; padding: var(--gi-space-4); border: 1px solid var(--gi-line); border-radius: var(--gi-radius-md); color: var(--gi-ink-800); background: var(--gi-surface-soft); text-align: left; transition: transform .2s var(--gi-ease), border-color .2s var(--gi-ease), box-shadow .2s var(--gi-ease); }
button.overview-command-card { cursor: pointer; }
.overview-command-card:hover { transform: translateY(-2px); border-color: var(--gi-line-accent); box-shadow: var(--gi-shadow-md); }
.overview-command-card > span { color: var(--gi-ink-400); font-size: var(--gi-text-3xs); font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.overview-command-card > strong { overflow: hidden; color: var(--gi-ink-950); font: 700 1.2rem/1.2 Manrope,sans-serif; text-overflow: ellipsis; white-space: nowrap; }
.overview-command-card > p { margin: 0; color: var(--gi-ink-400); font-size: var(--gi-text-2xs); line-height: 1.55; }
.overview-command-card-progress { color: #fff; background: linear-gradient(145deg,var(--gi-brand-800),#0a5148); }
.overview-command-card-progress > :where(span,strong,p) { color: inherit; }
.overview-command-card-progress > span, .overview-command-card-progress > p { color: rgba(239,255,250,.66); }
.overview-command-card-progress > div { align-self: end; height: 4px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.14); }
.overview-command-card-progress > div > i { display: block; height: 100%; border-radius: inherit; background: #84e0c9; transition: width .5s var(--gi-ease-out); }
.ai-pipeline::before {
position: absolute;
z-index: -1;
inset: 0;
background-image:
linear-gradient(rgba(150, 232, 212, .055) 1px, transparent 1px),
linear-gradient(90deg, rgba(150, 232, 212, .055) 1px, transparent 1px);
background-size: 3rem 3rem;
content: '';
mask-image: linear-gradient(90deg, transparent, #000 25%, #000);
}
.ai-pipeline-heading {
display: flex;
gap: var(--gi-space-5);
align-items: start;
justify-content: space-between;
}
.ai-pipeline-heading .eyebrow { color: #84dfca; }
.ai-pipeline-heading h3 { margin: .25rem 0 0; color: #fff; font: 700 clamp(1.15rem, 2vw, 1.55rem)/1.15 Manrope, sans-serif; }
.ai-pipeline-heading p:last-child { max-width: 68ch; margin: .45rem 0 0; color: rgba(231, 251, 245, .65); font-size: var(--gi-text-xs); line-height: 1.6; }
.ai-pipeline-gpu { display: inline-flex; flex: 0 0 auto; gap: .45rem; align-items: center; padding: .48rem .65rem; border: 1px solid rgba(255,255,255,.16); border-radius: 999px; background: rgba(255,255,255,.06); font-size: var(--gi-text-2xs); font-weight: 700; }
.ai-pipeline-gpu i { width: .48rem; height: .48rem; border-radius: 50%; background: #e3b867; }
.ai-pipeline-gpu-ready i { background: #67e0bc; box-shadow: 0 0 0 5px rgba(103,224,188,.12); animation: ai-gpu-pulse 2.4s ease-out infinite; }
.ai-pipeline-track { position: relative; display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); gap: .55rem; margin-top: 1.35rem; }
.ai-pipeline-flow { position: absolute; top: 1.55rem; right: 8%; left: 8%; height: 2px; overflow: hidden; background: rgba(154, 228, 211, .15); }
.ai-pipeline-flow::after { position: absolute; inset: 0; background: linear-gradient(90deg, transparent, #76e1c7, transparent); content: ''; transform: translateX(-100%); animation: ai-flow 4.8s ease-in-out infinite; }
.ai-pipeline-stage { position: relative; z-index: 1; display: grid; gap: .28rem; justify-items: center; min-width: 0; padding: .65rem .4rem; border: 1px solid rgba(190, 235, 224, .12); border-radius: .8rem; color: rgba(232, 249, 244, .58); background: rgba(4, 39, 36, .82); text-align: center; cursor: pointer; transition: transform .22s ease, border-color .22s ease, background .22s ease; }
.ai-pipeline-stage:hover, .ai-pipeline-stage:focus-visible { transform: translateY(-3px); border-color: rgba(131, 226, 204, .52); }
.ai-pipeline-stage[aria-selected='true'] { border-color: #79d9c3; color: #fff; background: rgba(25, 102, 89, .88); box-shadow: 0 12px 30px rgba(0,0,0,.2); }
.ai-pipeline-stage > span { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(255,255,255,.15); border-radius: .65rem; background: rgba(255,255,255,.06); }
.ai-pipeline-stage-ready > span { color: #8ce6d1; }
.ai-pipeline-stage svg { width: 1rem; height: 1rem; }
.ai-pipeline-stage strong { overflow: hidden; max-width: 100%; font-size: var(--gi-text-2xs); text-overflow: ellipsis; white-space: nowrap; }
.ai-pipeline-stage small { color: rgba(231, 250, 245, .45); font-size: var(--gi-text-3xs); text-transform: uppercase; }
.ai-pipeline-detail { display: grid; grid-template-columns: auto minmax(0,1fr); gap: .8rem; align-items: center; margin-top: .8rem; padding: .8rem; border-left: 2px solid #76dfc6; border-radius: 0 .7rem .7rem 0; background: rgba(255,255,255,.055); animation: ai-detail-in .36s ease both; }
.ai-pipeline-detail > span { color: #7edfc9; font: 700 1.35rem/1 Manrope, sans-serif; }
.ai-pipeline-detail strong { color: #fff; font-size: var(--gi-text-sm); }
.ai-pipeline-detail p { margin: .2rem 0 0; color: rgba(233, 250, 246, .61); font-size: var(--gi-text-2xs); line-height: 1.55; }
/* Micro-interactions stay functional rather than decorative. */
.workbench-content > * { animation: gi-workspace-enter .32s var(--gi-ease-out) both; }
.status-tile, .workflow-guidance-step, .quality-history-list > *, .latest-artifact-card { transition: transform .2s var(--gi-ease), box-shadow .2s var(--gi-ease), border-color .2s var(--gi-ease); }
.status-tile:hover, .workflow-guidance-step:hover, .quality-history-list > *:hover, .latest-artifact-card:hover { transform: translateY(-2px); box-shadow: var(--gi-shadow-md); }
.result-state-loading, .geo-results-loading { position: relative; overflow: hidden; }
.result-state-loading::after, .geo-results-loading::after { position: absolute; inset: 0; background: linear-gradient(105deg, transparent 20%, rgba(116, 218, 194, .13) 42%, transparent 64%); content: ''; transform: translateX(-100%); animation: gi-skeleton-sweep 1.8s ease-in-out infinite; }
.result-state-error { animation: gi-error-enter .32s ease both; }
.geo-results-panel:not(:has(.geo-results-empty)) { animation: gi-drawer-enter .3s var(--gi-ease-out) both; }
@keyframes ai-flow { 55%,100% { transform: translateX(100%); } }
@keyframes ai-gpu-pulse { 70% { box-shadow: 0 0 0 9px rgba(103,224,188,0); } }
@keyframes ai-detail-in { from { opacity: 0; transform: translateY(5px); } }
@keyframes gi-workspace-enter { from { opacity: 0; transform: translateY(6px); } }
@keyframes gi-skeleton-sweep { 60%,100% { transform: translateX(100%); } }
@keyframes gi-error-enter { from { opacity: 0; transform: translateX(-4px); } }
@keyframes gi-drawer-enter { from { opacity: .4; transform: translateX(12px); } }
@media (max-width: 840px) {
.ai-pipeline-heading { flex-direction: column; }
.ai-pipeline-track { grid-template-columns: repeat(5, minmax(6.5rem, 1fr)); overflow-x: auto; padding-bottom: .35rem; }
.ai-pipeline-flow { display: none; }
.overview-command-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
}
@media (max-width: 560px) {
.overview-command-heading { align-items: stretch; flex-direction: column; }
.overview-command-grid { grid-template-columns: minmax(0,1fr); }
}
@media (prefers-reduced-motion: reduce) {
.ai-pipeline *, .ai-pipeline *::before, .ai-pipeline *::after,
.workbench-content > *, .result-state-loading::after, .geo-results-loading::after,
.geo-results-panel:not(:has(.geo-results-empty)) {
animation: none !important;
transition-duration: .01ms !important;
}
}
@@ -107,7 +107,7 @@
.landing-story-map {
position: absolute;
inset: 0;
background: linear-gradient(145deg, rgba(4, 35, 32, .2), rgba(2, 26, 24, .78)), url('/portfolio/geointel-building-qa.png') center / cover;
background: linear-gradient(145deg, rgba(4, 35, 32, .2), rgba(2, 26, 24, .78)), url('/portfolio/geointel-building-qa.webp') center / cover;
filter: saturate(.78) contrast(1.03);
transform: scale(1.035);
transition: transform 1.1s cubic-bezier(.22, 1, .36, 1), filter .5s ease;
+1 -1
View File
@@ -234,7 +234,7 @@ body.landing-body {
inset: 0;
background:
linear-gradient(112deg, rgba(244, 250, 248, 1) 8%, rgba(241, 249, 247, 0.96) 45%, rgba(225, 241, 237, 0.77) 100%),
url('/portfolio/geointel-belgium-north-sea-hero.png') center / cover;
url('/portfolio/geointel-belgium-north-sea-hero.webp') center / cover;
content: '';
}