Add interactive GeoIntel storytelling and portfolio assets
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 08:28:09 +02:00
parent fe60d97a47
commit 61e2505940
15 changed files with 532 additions and 259 deletions
@@ -22,6 +22,7 @@ import { formatAuthError } from '../../lib/authError'
import '../../styles/landing.css'
import { GeoIntelMark } from '../brand/GeoIntelBrand'
import { ItWorxSignature } from '../brand/ItWorxSignature'
import { LandingProjectStory } from './LandingProjectStory'
interface LandingPageProps {
onAuthenticated: (session: AuthSession) => void
@@ -338,6 +339,8 @@ export function LandingPage({
</div>
</section>
<LandingProjectStory />
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
<div className="landing-workflow-visual">
<div className="landing-workflow-map-image" aria-hidden="true" />
@@ -0,0 +1,28 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { LandingProjectStory } from './LandingProjectStory'
afterEach(cleanup)
describe('LandingProjectStory', () => {
it('moves through the evidence-first project chain', () => {
render(<LandingProjectStory />)
expect(screen.getByRole('tabpanel').textContent).toContain('Teken één ruimtelijke vraag')
fireEvent.click(screen.getByRole('tab', { name: '03 Analyseer' }))
expect(screen.getByRole('tab', { name: '03 Analyseer' }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('tabpanel').textContent).toContain('Laat GIS en PyTorch samenwerken')
expect(screen.getByRole('tabpanel').textContent).toContain('186')
})
it('supports arrow-key navigation between stages', () => {
render(<LandingProjectStory />)
fireEvent.keyDown(screen.getByRole('tab', { name: '01 Selecteer' }), { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: '02 Bronnen' }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('tabpanel').textContent).toContain('Hou herkomst en dekking zichtbaar')
})
})
@@ -0,0 +1,151 @@
import { useState } from 'react'
import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react'
import '../../styles/landing-project-story.css'
const stages = [
{
key: 'select',
number: '01',
label: 'Selecteer',
title: 'Teken één ruimtelijke vraag',
description: 'Een officiële grens of vrije AOI wordt de vaste context voor elke volgende stap.',
metric: '2,14 km²',
metricLabel: 'geselecteerd',
icon: MapPinned,
},
{
key: 'source',
number: '02',
label: 'Bronnen',
title: 'Hou herkomst en dekking zichtbaar',
description: 'Bronautoriteit, CRS, meetmoment en lokale dekking blijven aan ieder resultaat gekoppeld.',
metric: '7',
metricLabel: 'bronnen gecontroleerd',
icon: Database,
},
{
key: 'analyse',
number: '03',
label: 'Analyseer',
title: 'Laat GIS en PyTorch samenwerken',
description: 'Ruimtelijke selectie, beeldanalyse en regionale context komen samen zonder ontbrekende AI-configuratie te verbergen.',
metric: '186',
metricLabel: 'objecten gevonden',
icon: ScanSearch,
},
{
key: 'prove',
number: '04',
label: 'Onderbouw',
title: 'Controleer bewijs vóór export',
description: 'QA/QC, uitzonderingen en provenance maken het resultaat navolgbaar en verdedigbaar.',
metric: '0,91',
metricLabel: 'kwaliteitsscore',
icon: BadgeCheck,
},
] as const
export function LandingProjectStory(): JSX.Element {
const [activeIndex, setActiveIndex] = useState(0)
const activeStage = stages[activeIndex]
return (
<section className="landing-project-story" aria-labelledby="project-story-title">
<div className="landing-project-story-copy">
<p className="landing-workflow-eyebrow">Interactieve projectillustratie</p>
<h2 id="project-story-title">Van gebied naar resultaat, zonder de bewijsvoering kwijt te raken</h2>
<p className="landing-project-story-intro">
Verken de vier schakels van de GeoIntel-keten. Elke stap verandert de kaart en toont welke context behouden blijft.
</p>
<div className="landing-story-tabs" role="tablist" aria-label="GeoIntel-projectketen">
{stages.map(({ key, number, label, icon: Icon }, index) => (
<button
key={key}
id={`story-tab-${key}`}
type="button"
role="tab"
aria-selected={activeIndex === index}
aria-controls="story-stage-panel"
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
setActiveIndex((current) => (current + direction + stages.length) % stages.length)
}}
>
<span>{number}</span>
<Icon aria-hidden="true" />
<strong>{label}</strong>
</button>
))}
</div>
<div
className="landing-story-detail"
id="story-stage-panel"
role="tabpanel"
aria-live="polite"
aria-labelledby={`story-tab-${activeStage.key}`}
key={activeStage.key}
>
<div>
<small>Actieve schakel</small>
<h3>{activeStage.title}</h3>
<p>{activeStage.description}</p>
</div>
<span className="landing-story-metric">
<strong>{activeStage.metric}</strong>
<small>{activeStage.metricLabel}</small>
</span>
</div>
</div>
<div className={`landing-story-visual is-stage-${activeIndex + 1}`} aria-hidden="true">
<div className="landing-story-map" />
<svg viewBox="0 0 720 610" role="presentation">
<defs>
<linearGradient id="story-area" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8ee7d2" stopOpacity=".34" />
<stop offset="1" stopColor="#23a68e" stopOpacity=".1" />
</linearGradient>
<filter id="story-glow" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="9" />
</filter>
</defs>
<g className="story-tile-grid">
<path d="M0 122H720M0 244H720M0 366H720M0 488H720" />
<path d="M144 0V610M288 0V610M432 0V610M576 0V610" />
</g>
<path className="story-aoi" d="M210 152 493 123 582 284 515 473 258 504 139 319Z" fill="url(#story-area)" />
<path className="story-route" d="M157 419C231 345 236 223 339 241s121 126 219 45" />
<g className="story-buildings">
<path d="m260 286 48-15 21 68-49 15Z" />
<path d="m340 207 69-11 10 57-67 13Z" />
<path d="m392 330 74-23 28 88-76 24Z" />
<path d="m220 376 52-18 19 55-51 19Z" />
</g>
<g className="story-evidence">
<circle cx="307" cy="314" r="8" />
<circle cx="402" cy="224" r="8" />
<circle cx="445" cy="365" r="8" />
<circle cx="255" cy="396" r="8" />
</g>
<g className="story-pulse">
<circle cx="445" cy="365" r="27" filter="url(#story-glow)" />
<circle cx="445" cy="365" r="12" />
</g>
<path className="story-scan" d="M142 219H581" />
</svg>
<div className="landing-story-status">
<span><i /> Live analyse</span>
<strong>{activeStage.label}</strong>
<small>{activeStage.metricLabel}</small>
</div>
<div className="landing-story-export"><Download /><span>Controleerbaar resultaat</span></div>
</div>
</section>
)
}