Complete GeoIntel visual system and portfolio case study
@@ -41,6 +41,8 @@ respecteert automatisch `prefers-reduced-motion`.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Kaart als werkruimte
|
||||
|
||||
De kaart blijft het primaire werkblad. Thema, broncontext, selectie en
|
||||
@@ -135,6 +137,23 @@ Controleer GPU-zichtbaarheid in de container met:
|
||||
docker exec geointel nvidia-smi
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Portfolio case study
|
||||
|
||||
De presentatieklare case study combineert productvraag, interface, PyTorch- en
|
||||
NVIDIA-keten, architectuur en resultaten in één visueel document:
|
||||
|
||||
- [GeoIntel case study PDF](output/pdf/geointel-case-study.pdf)
|
||||
- [Donkere cover in hoge resolutie](frontend/public/portfolio/geointel-dark-case-study-cover.png)
|
||||
- [Architectuurvisual](docs/assets/portfolio/geointel-architecture.png)
|
||||
|
||||
De PDF kan reproduceerbaar opnieuw worden opgebouwd met:
|
||||
|
||||
```bash
|
||||
python scripts/create_portfolio_case_study.py
|
||||
```
|
||||
|
||||
## Kwaliteitscontrole
|
||||
|
||||
```bash
|
||||
@@ -183,10 +202,11 @@ healthchecks en rollback.
|
||||
## Portfolio-assets
|
||||
|
||||
De map [`docs/assets/portfolio`](docs/assets/portfolio) bevat presentatieklare
|
||||
screenshots van de landingspagina, interactieve illustratie, kaartwerkruimte,
|
||||
kwaliteitscontrole en mobiele weergave. De twee originele campagnebeelden
|
||||
staan in [`frontend/public/portfolio`](frontend/public/portfolio) en worden
|
||||
ook door de applicatie gebruikt.
|
||||
screenshots, de geanimeerde projectketen, architectuur- en AI-visuals. De drie
|
||||
originele campagnebeelden staan in
|
||||
[`frontend/public/portfolio`](frontend/public/portfolio). Geoptimaliseerde
|
||||
WebP-varianten worden door de applicatie gebruikt; de PNG-bronnen blijven
|
||||
beschikbaar voor drukwerk en portfolio-opmaak.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -11704,3 +11704,32 @@ Verified in this pass:
|
||||
- `npm run typecheck`
|
||||
- `npm run test:unit -- --maxWorkers=1 --reporter=verbose` (`45 passed`)
|
||||
- `npm run build` (`1892 modules transformed`, production build passed)
|
||||
|
||||
## 2026-07-28 - Complete visual system and portfolio case study
|
||||
|
||||
- Extended the motion language into the workbench: workspace entry, result
|
||||
drawer arrival, status-card elevation, loading sweeps and error transitions.
|
||||
Motion communicates state and remains fully disabled for reduced-motion
|
||||
users.
|
||||
- Added an interactive PyTorch pipeline to the building-detection workbench.
|
||||
Its orthophoto, tile, NVIDIA CUDA, detection and QA stages derive readiness
|
||||
from the real selected data, runtime preflight, persisted runs and checks.
|
||||
- Added a compact project briefing to the overview with real workflow
|
||||
completion, latest quality evidence, source count and latest export status.
|
||||
- Produced WebP production variants for the hero and QA artwork, reducing
|
||||
their delivered size from multi-megabyte PNGs to approximately 246 KB and
|
||||
366 KB while retaining the PNG masters for portfolio reuse.
|
||||
- Added a dark GeoIntel campaign cover and a four-state animated project-chain
|
||||
GIF, plus standalone architecture and PyTorch/NVIDIA visuals.
|
||||
- Added a reproducible ReportLab generator and six-page portfolio PDF under
|
||||
`output/pdf/geointel-case-study.pdf`. All pages were rendered with Poppler,
|
||||
inspected together at full contact-sheet scale and verified for page count
|
||||
and extractable text.
|
||||
|
||||
Verified before deployment:
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run test:unit -- --maxWorkers=1 --reporter=verbose` (`46 passed`)
|
||||
- `npm run build` (`1893 modules transformed`, exit code 0)
|
||||
- Browser interaction checks for all four project-story states and portfolio
|
||||
GIF capture; no console errors in the inspected local flow.
|
||||
|
||||
@@ -989,3 +989,13 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Capture portfolio-ready landing, story, map, quality and mobile screenshots.
|
||||
- [x] Rewrite the repository README around product value, architecture, operation and visual evidence.
|
||||
- [ ] Deploy and visually verify the presentation release on the Tower runtime.
|
||||
|
||||
# Sprint 231 - Complete visual system and portfolio case study
|
||||
|
||||
- [x] Add functional workbench motion for workspace changes, results, jobs, errors and loading skeletons.
|
||||
- [x] Add an interactive evidence-first PyTorch/NVIDIA pipeline tied to real detection readiness.
|
||||
- [x] Add a project briefing surface with workflow progress, latest QA, source coverage and export status.
|
||||
- [x] Add production WebP variants while retaining high-resolution portfolio masters.
|
||||
- [x] Produce a dark campaign cover, architecture visual, AI pipeline visual and animated project GIF.
|
||||
- [x] Generate and visually verify a six-page portfolio case-study PDF.
|
||||
- [ ] Commit, push and redeploy the complete presentation release from `/mnt/user/appdata/geointel`.
|
||||
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.lib.colors import HexColor, white
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / "output" / "pdf" / "geointel-case-study.pdf"
|
||||
ASSETS = ROOT / "docs" / "assets" / "portfolio"
|
||||
PUBLIC = ROOT / "frontend" / "public" / "portfolio"
|
||||
|
||||
PAGE = landscape(A4)
|
||||
W, H = PAGE
|
||||
INK = HexColor("#082f2b")
|
||||
TEAL = HexColor("#0d7f70")
|
||||
MINT = HexColor("#7ce0c8")
|
||||
PALE = HexColor("#edf8f5")
|
||||
MUTED = HexColor("#667b76")
|
||||
AMBER = HexColor("#d9a94f")
|
||||
|
||||
|
||||
def register_fonts() -> tuple[str, str]:
|
||||
font_root = ROOT / "frontend" / "node_modules" / "@fontsource" / "manrope" / "files"
|
||||
regular = font_root / "manrope-latin-500-normal.woff"
|
||||
bold = font_root / "manrope-latin-700-normal.woff"
|
||||
# ReportLab does not load WOFF. Use stable system fonts when TTF is absent.
|
||||
if regular.suffix.lower() == ".ttf" and regular.exists():
|
||||
pdfmetrics.registerFont(TTFont("GeoIntel", str(regular)))
|
||||
pdfmetrics.registerFont(TTFont("GeoIntelBold", str(bold)))
|
||||
return "GeoIntel", "GeoIntelBold"
|
||||
return "Helvetica", "Helvetica-Bold"
|
||||
|
||||
|
||||
FONT, FONT_BOLD = register_fonts()
|
||||
|
||||
|
||||
def image_cover(c: canvas.Canvas, path: Path, x: float, y: float, w: float, h: float) -> None:
|
||||
with Image.open(path) as image:
|
||||
iw, ih = image.size
|
||||
scale = max(w / iw, h / ih)
|
||||
draw_w, draw_h = iw * scale, ih * scale
|
||||
c.saveState()
|
||||
clip = c.beginPath()
|
||||
clip.rect(x, y, w, h)
|
||||
c.clipPath(clip, stroke=0, fill=0)
|
||||
c.drawImage(ImageReader(str(path)), x + (w - draw_w) / 2, y + (h - draw_h) / 2, draw_w, draw_h, mask="auto")
|
||||
c.restoreState()
|
||||
|
||||
|
||||
def title(c: canvas.Canvas, kicker: str, heading: str, sub: str | None = None) -> None:
|
||||
c.setFillColor(TEAL)
|
||||
c.setFont(FONT_BOLD, 8)
|
||||
c.drawString(42, H - 44, kicker.upper())
|
||||
c.setFillColor(INK)
|
||||
c.setFont(FONT_BOLD, 25)
|
||||
c.drawString(42, H - 78, heading)
|
||||
if sub:
|
||||
c.setFillColor(MUTED)
|
||||
c.setFont(FONT, 9.5)
|
||||
c.drawString(42, H - 99, sub)
|
||||
|
||||
|
||||
def footer(c: canvas.Canvas, number: int) -> None:
|
||||
c.setStrokeColor(HexColor("#d7e8e3"))
|
||||
c.line(42, 27, W - 42, 27)
|
||||
c.setFillColor(MUTED)
|
||||
c.setFont(FONT, 7)
|
||||
c.drawString(42, 14, "GeoIntel - evidence-first GeoAI for Belgium and the Belgian North Sea")
|
||||
c.drawRightString(W - 42, 14, f"{number:02d}")
|
||||
|
||||
|
||||
def paragraph(c: canvas.Canvas, text: str, x: float, y: float, w: float, size: float = 10, color=INK, leading: float | None = None) -> float:
|
||||
style = ParagraphStyle("body", fontName=FONT, fontSize=size, leading=leading or size * 1.45, textColor=color, alignment=TA_LEFT)
|
||||
p = Paragraph(text, style)
|
||||
_, ph = p.wrap(w, H)
|
||||
p.drawOn(c, x, y - ph)
|
||||
return y - ph
|
||||
|
||||
|
||||
def metric(c: canvas.Canvas, x: float, y: float, value: str, label: str, width: float) -> None:
|
||||
c.setFillColor(PALE)
|
||||
c.roundRect(x, y, width, 55, 9, fill=1, stroke=0)
|
||||
c.setFillColor(TEAL)
|
||||
c.setFont(FONT_BOLD, 18)
|
||||
c.drawString(x + 13, y + 27, value)
|
||||
c.setFillColor(MUTED)
|
||||
c.setFont(FONT_BOLD, 7)
|
||||
c.drawString(x + 13, y + 12, label.upper())
|
||||
|
||||
|
||||
def draw_cover(c: canvas.Canvas) -> None:
|
||||
image_cover(c, PUBLIC / "geointel-dark-case-study-cover.png", 0, 0, W, H)
|
||||
c.setFillColor(HexColor("#031916"))
|
||||
c.rect(0, 0, W * 0.55, H, fill=1, stroke=0)
|
||||
c.setFillColor(MINT)
|
||||
c.setFont(FONT_BOLD, 9)
|
||||
c.drawString(48, H - 72, "PORTFOLIO CASE STUDY / 2026")
|
||||
c.setFillColor(white)
|
||||
c.setFont(FONT_BOLD, 35)
|
||||
c.drawString(48, H - 135, "GeoIntel")
|
||||
c.setFont(FONT_BOLD, 21)
|
||||
c.drawString(48, H - 171, "Van kaartlaag naar")
|
||||
c.setFillColor(MINT)
|
||||
c.drawString(48, H - 198, "aantoonbaar inzicht.")
|
||||
paragraph(c, "Een kaartgerichte GeoAI-workbench waarin officiële bronnen, GIS, PyTorch en kwaliteitsbewijs één reproduceerbare keten vormen.", 48, H - 228, 315, 11, HexColor("#c6ddd7"), 17)
|
||||
c.setFillColor(HexColor("#173b36"))
|
||||
c.roundRect(48, 47, 290, 48, 9, fill=1, stroke=0)
|
||||
c.setFillColor(white)
|
||||
c.setFont(FONT_BOLD, 8)
|
||||
c.drawString(62, 74, "BELGIË + BELGISCHE NOORDZEE")
|
||||
c.setFillColor(HexColor("#9ec8bd"))
|
||||
c.setFont(FONT, 7.5)
|
||||
c.drawString(62, 59, "React · FastAPI · PostGIS · PyTorch · NVIDIA CUDA")
|
||||
|
||||
|
||||
def draw_challenge(c: canvas.Canvas) -> None:
|
||||
c.setFillColor(white); c.rect(0, 0, W, H, fill=1, stroke=0)
|
||||
title(c, "01 / Productvraag", "Niet alleen zien, maar kunnen verdedigen")
|
||||
y = paragraph(c, "Geoportalen tonen vaak lagen en losse metingen. GeoIntel maakt van bron, gebied, analyse en bewijs één operationele workflow.", 42, H - 128, 330, 13, INK, 19)
|
||||
cards = [
|
||||
("01", "Context behouden", "Autoriteit, meetmoment, CRS en dekking blijven zichtbaar."),
|
||||
("02", "AI begrenzen", "Modelversie, confidence en validatiescope reizen mee."),
|
||||
("03", "Kwaliteit bewijzen", "Objectbewijs en fouten zijn vóór export inspecteerbaar."),
|
||||
]
|
||||
for index, (num, head, body) in enumerate(cards):
|
||||
cy = y - 42 - index * 78
|
||||
c.setFillColor(PALE); c.roundRect(42, cy - 54, 330, 64, 10, fill=1, stroke=0)
|
||||
c.setFillColor(TEAL); c.setFont(FONT_BOLD, 8); c.drawString(56, cy - 6, num)
|
||||
c.setFillColor(INK); c.setFont(FONT_BOLD, 11); c.drawString(86, cy - 7, head)
|
||||
paragraph(c, body, 86, cy - 18, 266, 8.2, MUTED, 12)
|
||||
image_cover(c, ASSETS / "geointel-landing-hero.png", 410, 53, 385, 415)
|
||||
footer(c, 2)
|
||||
|
||||
|
||||
def draw_experience(c: canvas.Canvas) -> None:
|
||||
c.setFillColor(HexColor("#f5faf8")); c.rect(0, 0, W, H, fill=1, stroke=0)
|
||||
title(c, "02 / Experience design", "De kaart blijft het werkblad", "Rustige hiërarchie, zichtbare herkomst en bewijs op het moment dat het nodig is.")
|
||||
image_cover(c, ASSETS / "geointel-workbench-map.png", 42, 73, 485, 360)
|
||||
image_cover(c, ASSETS / "geointel-workbench-quality.png", 547, 73, 248, 360)
|
||||
metric(c, 42, 440, "1", "ruimtelijke context", 160)
|
||||
metric(c, 214, 440, "7+", "bronfamilies", 160)
|
||||
metric(c, 386, 440, "QA", "vóór export", 160)
|
||||
footer(c, 3)
|
||||
|
||||
|
||||
def draw_ai(c: canvas.Canvas) -> None:
|
||||
c.setFillColor(INK); c.rect(0, 0, W, H, fill=1, stroke=0)
|
||||
c.setFillColor(MINT); c.setFont(FONT_BOLD, 8); c.drawString(42, H - 44, "03 / PYTORCH + NVIDIA")
|
||||
c.setFillColor(white); c.setFont(FONT_BOLD, 25); c.drawString(42, H - 78, "Van bronpixel naar gecontroleerde detectie")
|
||||
c.setFillColor(HexColor("#b8d4cd")); c.setFont(FONT, 9); c.drawString(42, H - 99, "Elke schakel bewaart de context die nodig is om het resultaat opnieuw te beoordelen.")
|
||||
stages = [
|
||||
("01", "Orthofoto", "CRS + dekking"),
|
||||
("02", "Beeldtegels", "overlap + tile-id"),
|
||||
("03", "NVIDIA GPU", "CUDA + model"),
|
||||
("04", "Detecties", "confidence + geometrie"),
|
||||
("05", "QA-bewijs", "precision + IoU"),
|
||||
]
|
||||
x0, gap, bw = 42, 13, 139
|
||||
c.setStrokeColor(HexColor("#4bb99f")); c.setLineWidth(2); c.line(95, 285, 746, 285)
|
||||
for i, (num, head, body) in enumerate(stages):
|
||||
x = x0 + i * (bw + gap)
|
||||
c.setFillColor(HexColor("#12443d")); c.roundRect(x, 205, bw, 160, 12, fill=1, stroke=0)
|
||||
c.setFillColor(MINT); c.setFont(FONT_BOLD, 8); c.drawString(x + 14, 340, num)
|
||||
c.setFillColor(white); c.setFont(FONT_BOLD, 10); c.drawString(x + 14, 295, head)
|
||||
c.setFillColor(HexColor("#9fc9bf")); c.setFont(FONT, 7.4); c.drawString(x + 14, 275, body)
|
||||
c.setFillColor(MINT if i < 3 else AMBER); c.circle(x + bw / 2, 230, 6, fill=1, stroke=0)
|
||||
paragraph(c, "Fail-closed runtime: wanneer CUDA vereist maar niet beschikbaar is, of wanneer een lokaal model ontbreekt, toont GeoIntel een expliciete niet-geconfigureerde toestand. Productie-AI wordt nooit gesimuleerd.", 42, 166, 735, 11, HexColor("#d7ebe6"), 17)
|
||||
footer(c, 4)
|
||||
|
||||
|
||||
def draw_architecture(c: canvas.Canvas) -> None:
|
||||
c.setFillColor(white); c.rect(0, 0, W, H, fill=1, stroke=0)
|
||||
title(c, "04 / Architectuur", "Eén keten, expliciete verantwoordelijkheden")
|
||||
nodes = [
|
||||
(55, 270, 130, 66, "React + MapLibre", "kaartwerkruimte"),
|
||||
(220, 270, 130, 66, "FastAPI", "contractlaag"),
|
||||
(385, 355, 150, 66, "GeoPandas / Rasterio", "ruimtelijke verwerking"),
|
||||
(385, 185, 150, 66, "Redis + RQ", "hervatbare jobs"),
|
||||
(570, 270, 150, 66, "PyTorch + CUDA", "beeldanalyse"),
|
||||
(570, 90, 150, 66, "PostGIS + artifacts", "resultaat + provenance"),
|
||||
]
|
||||
for x, y, w, h, head, body in nodes:
|
||||
c.setFillColor(PALE if "PyTorch" not in head else HexColor("#0d5047")); c.roundRect(x, y, w, h, 10, fill=1, stroke=0)
|
||||
c.setFillColor(white if "PyTorch" in head else INK); c.setFont(FONT_BOLD, 9); c.drawString(x + 12, y + 39, head)
|
||||
c.setFillColor(HexColor("#b9d8d0") if "PyTorch" in head else MUTED); c.setFont(FONT, 7.2); c.drawString(x + 12, y + 21, body)
|
||||
c.setStrokeColor(TEAL); c.setLineWidth(1.5)
|
||||
arrows = [((185,303),(220,303)),((350,303),(385,388)),((350,303),(385,218)),((535,388),(645,336)),((535,218),(570,303)),((645,270),(645,156))]
|
||||
for (x1,y1),(x2,y2) in arrows:
|
||||
c.line(x1,y1,x2,y2); c.circle(x2,y2,2.5,fill=1,stroke=0)
|
||||
paragraph(c, "PostGIS bewaart querybare geometrie en lifecycle-records. Grote raster-, mask- en modelbestanden blijven versioned artifacts; de database bewaart hun identiteit, checksum en provenance.", 55, 150, 460, 9.5, MUTED, 15)
|
||||
footer(c, 5)
|
||||
|
||||
|
||||
def draw_outcomes(c: canvas.Canvas) -> None:
|
||||
image_cover(c, PUBLIC / "geointel-dark-case-study-cover.png", 0, 0, W, H)
|
||||
c.setFillColor(HexColor("#041d19")); c.rect(0, 0, W, H, fill=1, stroke=0)
|
||||
c.setFillColor(MINT); c.setFont(FONT_BOLD, 8); c.drawString(42, H - 44, "05 / RESULTAAT")
|
||||
c.setFillColor(white); c.setFont(FONT_BOLD, 27); c.drawString(42, H - 82, "Een professionele GeoAI-workbench")
|
||||
c.setFillColor(HexColor("#c0dad4")); c.setFont(FONT, 10); c.drawString(42, H - 104, "Nationaal/maritiem van scope, lokaal controleerbaar in bewijs.")
|
||||
outcomes = [
|
||||
("Map-first", "De gebruiker blijft in dezelfde ruimtelijke context."),
|
||||
("Evidence-first", "QA en provenance zijn onderdeel van het resultaat."),
|
||||
("GPU-ready", "NVIDIA CUDA is expliciet geconfigureerd en gevalideerd."),
|
||||
("Accessible motion", "Animatie verduidelijkt toestand en respecteert reduced motion."),
|
||||
]
|
||||
for i, (head, body) in enumerate(outcomes):
|
||||
x = 42 + (i % 2) * 375
|
||||
y = 330 - (i // 2) * 105
|
||||
c.setFillColor(HexColor("#123f38")); c.roundRect(x, y, 350, 78, 11, fill=1, stroke=0)
|
||||
c.setFillColor(MINT); c.setFont(FONT_BOLD, 11); c.drawString(x + 16, y + 48, head)
|
||||
paragraph(c, body, x + 16, y + 38, 316, 8, HexColor("#b8d2cc"), 12)
|
||||
c.setFillColor(white); c.setFont(FONT_BOLD, 11); c.drawString(42, 58, "GeoIntel · Jens / ITWorx.tech")
|
||||
c.setFillColor(HexColor("#8fb9af")); c.setFont(FONT, 8); c.drawString(42, 42, "Belgium and the Belgian North Sea · v1.0.0")
|
||||
|
||||
|
||||
def build() -> None:
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
c = canvas.Canvas(str(OUTPUT), pagesize=PAGE, pageCompression=1)
|
||||
c.setTitle("GeoIntel - Evidence-first GeoAI case study")
|
||||
c.setAuthor("Jens / ITWorx.tech")
|
||||
c.setSubject("Portfolio case study for the GeoIntel Belgium and Belgian North Sea workbench")
|
||||
for draw in (draw_cover, draw_challenge, draw_experience, draw_ai, draw_architecture, draw_outcomes):
|
||||
draw(c)
|
||||
c.showPage()
|
||||
c.save()
|
||||
print(OUTPUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||