Files
cfw-autumn/apps/website/components/problem-animation.jsx
2026-04-15 16:39:41 +01:00

216 lines
5.8 KiB
JavaScript
Executable File

"use client";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Matter from "matter-js";
import { useEffect, useRef, useState } from "react";
gsap.registerPlugin(ScrollTrigger);
const IMAGE_SOURCES = [
"/images/issues/Slack.svg",
"/images/issues/Frame%202147243001.svg",
"/images/issues/Frame%202147242729.svg",
"/images/issues/Slack-1.svg",
"/images/issues/Frame%202147243134.svg",
"/images/issues/Slack-2.svg",
"/images/issues/Frame%202147243146.svg",
"/images/issues/Stripe.svg",
];
const CATEGORY_DEFAULT = 0x0001;
const CATEGORY_CEILING = 0x0002;
export default function ProblemAnimation() {
const containerRef = useRef(null);
const engineRef = useRef(Matter.Engine.create());
const runnerRef = useRef(null);
const itemsMap = useRef(new Map());
const grabbedBodies = useRef(new Set());
const [loadedImages, setLoadedImages] = useState({});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const { width, height } = container.getBoundingClientRect();
const engine = engineRef.current;
engine.gravity.y = 1.2;
const wallThickness = 100;
const ground = Matter.Bodies.rectangle(
width / 2,
height + wallThickness / 2,
width,
wallThickness,
{ isStatic: true },
);
const leftWall = Matter.Bodies.rectangle(
-wallThickness / 2,
height / 2,
wallThickness,
height * 2,
{ isStatic: true },
);
const rightWall = Matter.Bodies.rectangle(
width + wallThickness / 2,
height / 2,
wallThickness,
height * 2,
{ isStatic: true },
);
// Ceiling — only collides with bodies that have been grabbed
const ceiling = Matter.Bodies.rectangle(
width / 2,
-wallThickness / 2,
width,
wallThickness,
{
isStatic: true,
collisionFilter: {
category: CATEGORY_CEILING,
mask: CATEGORY_CEILING,
},
},
);
Matter.World.add(engine.world, [ground, leftWall, rightWall, ceiling]);
const isMobile = window.innerWidth < 768;
// Mouse Interaction (Desktop Only)
if (!isMobile) {
const mouse = Matter.Mouse.create(container);
const mouseConstraint = Matter.MouseConstraint.create(engine, {
mouse: mouse,
constraint: { stiffness: 0.15, render: { visible: false } },
});
Matter.World.add(engine.world, mouseConstraint);
mouse.element.removeEventListener("mousewheel", mouse.mousewheel);
// On grab: mark body as grabbed + enable ceiling collision
Matter.Events.on(mouseConstraint, "startdrag", (event) => {
grabbedBodies.current.add(event.body.id);
Matter.Body.set(event.body, {
collisionFilter: {
category: CATEGORY_DEFAULT,
mask: CATEGORY_DEFAULT | CATEGORY_CEILING,
},
});
});
}
// Sync Loop
const update = () => {
itemsMap.current.forEach(({ body, element, w, h }) => {
// Freeze rotation until the user grabs the body
if (!grabbedBodies.current.has(body.id)) {
Matter.Body.setAngle(body, 0);
Matter.Body.setAngularVelocity(body, 0);
}
if (element) {
gsap.set(element, {
x: body.position.x - w / 2,
y: body.position.y - h / 2,
rotation: body.angle * (180 / Math.PI),
opacity: 1,
});
}
});
};
// Trigger Engine on Scroll
const st = ScrollTrigger.create({
trigger: container,
start: "top 85%",
onEnter: () => {
runnerRef.current = Matter.Runner.create();
Matter.Runner.run(runnerRef.current, engine);
gsap.ticker.add(update);
},
});
return () => {
st.kill();
gsap.ticker.remove(update);
if (runnerRef.current) Matter.Runner.stop(runnerRef.current);
Matter.World.clear(engine.world);
Matter.Engine.clear(engine);
};
}, []);
const handleImageLoad = (index, event) => {
const img = event.target;
const isMobile = window.innerWidth < 768;
const scale = isMobile ? 0.7 : 1;
const w = img.naturalWidth * scale;
const h = img.naturalHeight * scale;
img.style.width = `${w}px`;
img.style.height = `${h}px`;
const container = containerRef.current;
const spread = isMobile ? 0.7 : 0.4;
const spawnX =
container.offsetWidth / 2 +
(Math.random() - 0.5) * (container.offsetWidth * spread);
const spawnY = isMobile ? -100 : -100 - index * 50;
const body = Matter.Bodies.rectangle(spawnX, spawnY, w, h, {
chamfer: { radius: h / 4 },
restitution: 0.4,
friction: 0.1,
angle: 0,
// Spawns ignoring the ceiling — falls in freely from above
collisionFilter: {
category: CATEGORY_DEFAULT,
mask: CATEGORY_DEFAULT,
},
});
Matter.World.add(engineRef.current.world, body);
itemsMap.current.set(index, {
body,
element: img.parentElement,
w,
h,
});
setLoadedImages((prev) => ({ ...prev, [index]: true }));
};
return (
<div
ref={containerRef}
className="relative w-full h-140 md:h-175 lg:h-175 xl:h-full bg-[#080808] overflow-hidden touch-none"
>
{IMAGE_SOURCES.map((src, i) => (
<div
key={i}
className="absolute top-0 left-0 opacity-0 will-change-transform"
style={{ visibility: loadedImages[i] ? "visible" : "hidden" }}
>
<img
src={src}
alt=""
loading="lazy"
onLoad={(e) => handleImageLoad(i, e)}
className="block cursor-grab active:cursor-grabbing"
style={{ width: "auto", height: "auto" }}
draggable={false}
/>
</div>
))}
</div>
);
}