Add interactive GeoIntel storytelling and portfolio assets
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
.landing-project-story {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, .88fr) minmax(30rem, 1.12fr);
|
||||
gap: clamp(2.5rem, 6vw, 6rem);
|
||||
align-items: center;
|
||||
width: min(92rem, 100%);
|
||||
margin-inline: auto;
|
||||
padding: clamp(5rem, 9vw, 8rem) clamp(1.25rem, 4vw, 4.5rem);
|
||||
}
|
||||
|
||||
.landing-project-story-copy > h2 {
|
||||
max-width: 18ch;
|
||||
margin: .75rem 0 0;
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-size: clamp(2.25rem, 4vw, 4rem);
|
||||
letter-spacing: -.055em;
|
||||
line-height: 1.04;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.landing-project-story-intro {
|
||||
max-width: 60ch;
|
||||
margin: 1.25rem 0 0;
|
||||
color: var(--landing-muted);
|
||||
font-size: .9rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.landing-story-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: .45rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.landing-story-tabs button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: .45rem;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--landing-line);
|
||||
border-radius: .8rem;
|
||||
padding: .8rem;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
color: var(--landing-muted);
|
||||
cursor: pointer;
|
||||
transition: transform .24s ease, border-color .24s ease, background .24s ease, color .24s ease;
|
||||
}
|
||||
|
||||
.landing-story-tabs button::after {
|
||||
position: absolute;
|
||||
right: .8rem;
|
||||
bottom: 0;
|
||||
left: .8rem;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--landing-primary);
|
||||
content: '';
|
||||
opacity: 0;
|
||||
transform: scaleX(.2);
|
||||
transition: opacity .24s ease, transform .24s ease;
|
||||
}
|
||||
|
||||
.landing-story-tabs button:hover { transform: translateY(-2px); }
|
||||
.landing-story-tabs button[aria-selected='true'] {
|
||||
border-color: #83c8bb;
|
||||
background: var(--landing-mint);
|
||||
color: var(--landing-primary-strong);
|
||||
}
|
||||
.landing-story-tabs button[aria-selected='true']::after { opacity: 1; transform: scaleX(1); }
|
||||
.landing-story-tabs button > span { font-size: .54rem; font-weight: 800; letter-spacing: .1em; }
|
||||
.landing-story-tabs svg { width: 1rem; height: 1rem; }
|
||||
.landing-story-tabs strong { overflow: hidden; max-width: 100%; font-size: .65rem; text-overflow: ellipsis; }
|
||||
|
||||
.landing-story-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1.5rem;
|
||||
align-items: end;
|
||||
margin-top: 1rem;
|
||||
border-left: 3px solid var(--landing-primary);
|
||||
border-radius: 0 .8rem .8rem 0;
|
||||
padding: 1rem 1.1rem;
|
||||
background: linear-gradient(90deg, rgba(222, 247, 241, .8), rgba(247, 252, 251, .55));
|
||||
animation: landing-story-enter .42s ease both;
|
||||
}
|
||||
|
||||
.landing-story-detail small { color: var(--landing-primary); font-size: .55rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.landing-story-detail h3 { margin: .25rem 0 0; font-family: "Manrope", sans-serif; font-size: 1rem; }
|
||||
.landing-story-detail p { max-width: 54ch; margin: .35rem 0 0; color: var(--landing-muted); font-size: .69rem; line-height: 1.6; }
|
||||
.landing-story-metric { display: grid; min-width: 6.5rem; text-align: right; }
|
||||
.landing-story-metric strong { color: var(--landing-primary-strong); font-family: "Manrope", sans-serif; font-size: 1.75rem; line-height: 1; }
|
||||
.landing-story-metric small { margin-top: .3rem; letter-spacing: .04em; }
|
||||
|
||||
.landing-story-visual {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 38rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(161, 215, 204, .6);
|
||||
border-radius: 1.6rem;
|
||||
background: #072f2c;
|
||||
box-shadow: 0 35px 90px rgba(10, 67, 59, .2);
|
||||
}
|
||||
|
||||
.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;
|
||||
filter: saturate(.78) contrast(1.03);
|
||||
transform: scale(1.035);
|
||||
transition: transform 1.1s cubic-bezier(.22, 1, .36, 1), filter .5s ease;
|
||||
}
|
||||
|
||||
.landing-story-visual:hover .landing-story-map { filter: saturate(.95) contrast(1.03); transform: scale(1.065); }
|
||||
.landing-story-visual svg { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; }
|
||||
.story-tile-grid { fill: none; stroke: rgba(156, 229, 214, .12); stroke-width: 1; }
|
||||
.story-aoi { stroke: #8ee7d2; stroke-width: 3; stroke-dasharray: 10 7; transition: opacity .4s ease, transform .55s ease; transform-origin: center; }
|
||||
.story-route { fill: none; stroke: #8ee7d2; stroke-width: 4; stroke-dasharray: 9 12; stroke-linecap: round; animation: landing-story-route 12s linear infinite; }
|
||||
.story-buildings { fill: rgba(102, 222, 197, .12); stroke: #a3f0df; stroke-width: 3; transition: opacity .35s ease; }
|
||||
.story-evidence { fill: #f5c267; stroke: #fff4d4; stroke-width: 2; transition: opacity .35s ease; }
|
||||
.story-pulse { fill: rgba(110, 236, 207, .68); stroke: #c8fff1; stroke-width: 2; animation: landing-story-pulse 2.8s ease-out infinite; transform-origin: 445px 365px; }
|
||||
.story-scan { fill: none; stroke: rgba(190, 255, 240, .78); stroke-width: 2; filter: drop-shadow(0 0 7px #7be6d1); animation: landing-story-scan 5.5s ease-in-out infinite; }
|
||||
.is-stage-1 .story-buildings, .is-stage-1 .story-evidence { opacity: .22; }
|
||||
.is-stage-2 .story-evidence { opacity: .28; }
|
||||
.is-stage-3 .story-aoi { transform: scale(.94); }
|
||||
.is-stage-4 .story-buildings { opacity: .5; }
|
||||
|
||||
.landing-story-status, .landing-story-export {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
border: 1px solid rgba(196, 244, 234, .24);
|
||||
border-radius: .9rem;
|
||||
background: rgba(3, 39, 35, .78);
|
||||
color: #fff;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, .24);
|
||||
backdrop-filter: blur(13px);
|
||||
}
|
||||
.landing-story-status { top: 1.25rem; left: 1.25rem; gap: .2rem; min-width: 10rem; padding: .8rem .95rem; }
|
||||
.landing-story-status span { color: #8ee7d2; font-size: .52rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.landing-story-status i { display: inline-block; width: .42rem; height: .42rem; margin-right: .4rem; border-radius: 50%; background: #77e2ca; box-shadow: 0 0 0 5px rgba(119, 226, 202, .11); }
|
||||
.landing-story-status strong { font-family: "Manrope", sans-serif; font-size: .92rem; }
|
||||
.landing-story-status small { color: rgba(255, 255, 255, .58); font-size: .59rem; }
|
||||
.landing-story-export { right: 1.25rem; bottom: 1.25rem; grid-template-columns: auto 1fr; gap: .55rem; align-items: center; padding: .75rem .9rem; font-size: .62rem; }
|
||||
.landing-story-export svg { position: static; width: 1rem; height: 1rem; color: #8ee7d2; }
|
||||
|
||||
@keyframes landing-story-enter { from { opacity: 0; transform: translateY(8px); } }
|
||||
@keyframes landing-story-route { to { stroke-dashoffset: -84; } }
|
||||
@keyframes landing-story-pulse { 50% { opacity: .45; transform: scale(1.45); } }
|
||||
@keyframes landing-story-scan { 0%, 100% { opacity: .12; transform: translateY(-120px); } 50% { opacity: .8; transform: translateY(250px); } }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.landing-project-story { grid-template-columns: 1fr; }
|
||||
.landing-project-story-copy > h2 { max-width: 21ch; }
|
||||
.landing-story-visual { min-height: 34rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.landing-project-story { padding: 4rem 1rem; }
|
||||
.landing-project-story-copy > h2 { font-size: 2.15rem; }
|
||||
.landing-story-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.landing-story-detail { grid-template-columns: 1fr; }
|
||||
.landing-story-metric { text-align: left; }
|
||||
.landing-story-visual { min-height: 28rem; border-radius: 1.15rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-project-story *, .landing-project-story *::before, .landing-project-story *::after {
|
||||
animation: none !important;
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -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('/landing-hero-belgium.webp') center / cover;
|
||||
url('/portfolio/geointel-belgium-north-sea-hero.png') center / cover;
|
||||
content: '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user