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
+24 -4
View File
@@ -41,6 +41,8 @@ respecteert automatisch `prefers-reduced-motion`.
![Interactieve GeoIntel-projectketen](docs/assets/portfolio/geointel-interactive-story.png)
![Geanimeerde GeoIntel-projectketen](docs/assets/portfolio/geointel-interactive-project-chain.gif)
### 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
```
![PyTorch- en NVIDIA-analysekten](docs/assets/portfolio/geointel-ai-pytorch-pipeline.png)
## 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.
![GeoIntel mobiele landingspagina](docs/assets/portfolio/geointel-landing-mobile.png)
+29
View File
@@ -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.
+10
View File
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

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}
+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: '';
}
File diff suppressed because one or more lines are too long
+240
View File
@@ -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()