133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* PROCESS — "The Feedback Loop" as pinned scroll storytelling.
|
|
* - GSAP ScrollTrigger pins the panel while the user scrolls through 4 steps.
|
|
* - A single SVG glyph MORPHS between 4 shapes (MorphSVGPlugin) — magnifier
|
|
* (Audit) -> route/plan -> bolt (Execute) -> chart (Report) — the animated-SVG
|
|
* moment for this section.
|
|
* - A progress rail + numbered steps light up in sync (scrub).
|
|
* - prefers-reduced-motion: no pin, no morph; steps render as a static list.
|
|
*/
|
|
import { useEffect, useRef } from "react";
|
|
// Side-effect import registers ScrollTrigger + MorphSVGPlugin; gsap exposes both.
|
|
import { gsap } from "./gsap";
|
|
import { processSteps } from "../content";
|
|
|
|
// Four target paths for the morph (drawn on a 100x100 canvas).
|
|
const SHAPES = [
|
|
// Audit — magnifying glass
|
|
"M44 20a24 24 0 1 0 15 43l18 18 7-7-18-18A24 24 0 0 0 44 20Zm0 10a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
|
|
// Plan — connected route / nodes
|
|
"M22 30a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm40 40a8 8 0 1 1 16 0 8 8 0 0 1-16 0ZM30 38v8a16 16 0 0 0 16 16h8a16 16 0 0 1 16 16v-2 2h-8a26 26 0 0 1-26-26v-8Z",
|
|
// Execute — lightning bolt
|
|
"M55 14 26 58h20l-6 30 32-46H50l9-28Z",
|
|
// Report — bar chart trending up
|
|
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14",
|
|
];
|
|
|
|
export default function ProcessLoop() {
|
|
const root = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = root.current;
|
|
if (!el) return;
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (reduce) return;
|
|
|
|
const ctx = gsap.context(() => {
|
|
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
|
|
if (!morph) return;
|
|
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
|
|
const total = SHAPES.length;
|
|
|
|
// One pinned, scrubbed timeline drives the morph + rotation + rail fill.
|
|
const tl = gsap.timeline({
|
|
scrollTrigger: {
|
|
trigger: ".loop-pin",
|
|
start: "top top",
|
|
end: () => `+=${window.innerHeight * (total - 0.2)}`,
|
|
pin: true,
|
|
scrub: 0.6,
|
|
anticipatePin: 1,
|
|
onUpdate: (self) => {
|
|
// Robust, independent active-step highlight tied to raw progress.
|
|
const idx = Math.min(
|
|
total - 1,
|
|
Math.floor(self.progress * total + 0.0001)
|
|
);
|
|
steps.forEach((s, j) => s.classList.toggle("is-active", j === idx));
|
|
},
|
|
},
|
|
});
|
|
|
|
steps[0]?.classList.add("is-active");
|
|
|
|
SHAPES.forEach((shape, i) => {
|
|
if (i === 0) return; // start shape already in markup
|
|
const at = i - 1;
|
|
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at)
|
|
.to(".loop-glyph", { rotate: i * 6, duration: 1, ease: "power2.inOut" }, at)
|
|
.to(
|
|
".loop-rail__fill",
|
|
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
|
|
at
|
|
);
|
|
});
|
|
}, el);
|
|
|
|
return () => ctx.revert();
|
|
}, []);
|
|
|
|
return (
|
|
<section ref={root} id="process" className="loop frame" data-invert aria-labelledby="loop-h">
|
|
<div className="loop-pin">
|
|
<div className="wrap loop__inner">
|
|
<header className="sec-head">
|
|
<p className="kicker">
|
|
<span className="kicker__dot" />
|
|
The Feedback Loop
|
|
</p>
|
|
<h2 id="loop-h" className="display sec-head__title">
|
|
How it works
|
|
</h2>
|
|
</header>
|
|
|
|
<div className="loop__stage">
|
|
{/* morphing glyph */}
|
|
<div className="loop-glyph" aria-hidden="true">
|
|
<svg viewBox="0 0 100 100">
|
|
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
|
|
<defs>
|
|
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0%" stopColor="#3b82f6" />
|
|
<stop offset="50%" stopColor="#8b5cf6" />
|
|
<stop offset="100%" stopColor="#10b981" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
</div>
|
|
|
|
{/* steps + progress rail */}
|
|
<div className="loop__steps">
|
|
<div className="loop-rail" aria-hidden="true">
|
|
<span className="loop-rail__fill" />
|
|
</div>
|
|
<ol>
|
|
{processSteps.map((p) => (
|
|
<li className="loop-step" key={p.n}>
|
|
<span className="loop-step__n">{p.n}</span>
|
|
<div>
|
|
<h3 className="loop-step__name">{p.name}</h3>
|
|
<p className="loop-step__desc">{p.desc}</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|