Public Access
100 lines
3.9 KiB
React
100 lines
3.9 KiB
React
import {useEffect, useRef, useState} from 'react';
|
|
|
|
import {LOGO_COLORS} from './Logo.jsx';
|
|
|
|
const DURATION_MS = 2800;
|
|
const PIECES = {small: 70, big: 240};
|
|
const GRAVITY = 0.12;
|
|
// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
|
|
const FRAME_MS = 1000 / 60;
|
|
|
|
const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
|
|
// The logo colors of the chosen accent and the accent itself.
|
|
function colors() {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const logo = LOGO_COLORS.map(([name, hex]) => style.getPropertyValue(name).trim() || hex);
|
|
const accent = style.getPropertyValue('--primary').trim();
|
|
return accent ? [...logo, accent] : logo;
|
|
}
|
|
|
|
// Small: one burst from the top center. Big: two cannons from the lower corners.
|
|
function pieces(size, width, height) {
|
|
const palette = colors();
|
|
const big = size === 'big';
|
|
return Array.from({length: PIECES[size]}, (_, i) => {
|
|
const left = i % 2 === 0;
|
|
const angle = big ? (left ? -Math.PI / 3 : -2 * Math.PI / 3) + (Math.random() - 0.5) * 0.6
|
|
: Math.PI / 2 + (Math.random() - 0.5) * 2.4;
|
|
const speed = big ? 9 + Math.random() * 9 : 2 + Math.random() * 5;
|
|
return {
|
|
x: big ? (left ? 0 : width) : width / 2,
|
|
y: big ? height : height * 0.1,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
spin: Math.random() * Math.PI,
|
|
spinSpeed: (Math.random() - 0.5) * 0.3,
|
|
w: 6 + Math.random() * 6,
|
|
h: 3 + Math.random() * 4,
|
|
color: palette[i % palette.length],
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Confetti over the whole page for about 3 s after mount, then onDone. Remount (a new key) to play again.
|
|
*
|
|
* @param size 'small' or 'big'
|
|
*/
|
|
export default function Confetti({size, onDone}) {
|
|
const canvasRef = useRef(null);
|
|
const [reduce] = useState(reducedMotion);
|
|
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
|
|
const onDoneRef = useRef(onDone);
|
|
onDoneRef.current = onDone;
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
const context = canvas?.getContext('2d');
|
|
if (!context) return undefined;
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
const all = pieces(size, canvas.width, canvas.height);
|
|
const start = performance.now();
|
|
let last = start;
|
|
let frame;
|
|
const draw = (now) => {
|
|
const elapsed = now - start;
|
|
const steps = (now - last) / FRAME_MS;
|
|
last = now;
|
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (elapsed >= DURATION_MS) {
|
|
onDoneRef.current?.();
|
|
return;
|
|
}
|
|
context.globalAlpha = 1 - elapsed / DURATION_MS;
|
|
for (const piece of all) {
|
|
piece.vy += GRAVITY * steps;
|
|
piece.vx *= 0.99 ** steps;
|
|
piece.x += piece.vx * steps;
|
|
piece.y += piece.vy * steps;
|
|
piece.spin += piece.spinSpeed * steps;
|
|
if (piece.y > canvas.height + 20) continue;
|
|
const cos = Math.cos(piece.spin);
|
|
const sin = Math.sin(piece.spin);
|
|
context.setTransform(cos, sin, -sin, cos, piece.x, piece.y);
|
|
context.fillStyle = piece.color;
|
|
context.fillRect(-piece.w / 2, -piece.h / 2, piece.w, piece.h * Math.abs(cos));
|
|
}
|
|
frame = requestAnimationFrame(draw);
|
|
};
|
|
frame = requestAnimationFrame(draw);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [size]);
|
|
|
|
if (reduce) return null;
|
|
return <canvas ref={canvasRef} aria-hidden="true" data-size={size}
|
|
className="pointer-events-none fixed inset-0 z-50 size-full"/>;
|
|
}
|