213 lines
5.7 KiB
TypeScript
213 lines
5.7 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Play } from 'lucide-react';
|
|
|
|
export default function StartConfettiButton({
|
|
isDraftLike,
|
|
onStart,
|
|
className,
|
|
label = 'Start',
|
|
}: {
|
|
isDraftLike: boolean;
|
|
onStart?: () => void;
|
|
className?: string;
|
|
label?: string;
|
|
}) {
|
|
const [showConfetti, setShowConfetti] = useState(false);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const rafRef = useRef<number | null>(null);
|
|
|
|
// ---- Confetti Engine (inline, light) ----
|
|
const runConfetti = useCallback(() => {
|
|
const canvas = canvasRef.current!;
|
|
const ctx = canvas.getContext('2d')!;
|
|
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
|
|
|
// size canvas
|
|
const resize = () => {
|
|
const { innerWidth: w, innerHeight: h } = window;
|
|
canvas.width = Math.floor(w * DPR);
|
|
canvas.height = Math.floor(h * DPR);
|
|
canvas.style.width = `${w}px`;
|
|
canvas.style.height = `${h}px`;
|
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
|
};
|
|
resize();
|
|
|
|
const onResize = () => resize();
|
|
window.addEventListener('resize', onResize);
|
|
|
|
const colors = [
|
|
'#ef4444', // red-500
|
|
'#f59e0b', // amber-500
|
|
'#10b981', // emerald-500
|
|
'#3b82f6', // blue-500
|
|
'#8b5cf6', // violet-500
|
|
'#ec4899', // pink-500
|
|
'#22c55e', // green-500
|
|
'#eab308', // yellow-500
|
|
];
|
|
|
|
type Particle = {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
vx: number;
|
|
vy: number;
|
|
rot: number;
|
|
rotSpeed: number;
|
|
tilt: number;
|
|
tiltSpeed: number;
|
|
color: string;
|
|
alpha: number;
|
|
decay: number;
|
|
shape: 'rect' | 'triangle';
|
|
};
|
|
|
|
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
|
|
|
|
const particles: Particle[] = [];
|
|
const bursts = 5;
|
|
const perBurst = 80;
|
|
const spread = Math.PI / 3;
|
|
const gravity = 0.25;
|
|
const air = 0.002;
|
|
const floorFriction = 0.96;
|
|
|
|
for (let b = 0; b < bursts; b++) {
|
|
const originX = (window.innerWidth / (bursts + 1)) * (b + 1);
|
|
const originY = window.innerHeight * 0.15;
|
|
const angleBase = -Math.PI / 2;
|
|
for (let i = 0; i < perBurst; i++) {
|
|
const angle = angleBase + rand(-spread, spread);
|
|
const speed = rand(6, 12);
|
|
particles.push({
|
|
x: originX + rand(-8, 8),
|
|
y: originY + rand(-8, 8),
|
|
w: rand(4, 8),
|
|
h: rand(6, 14),
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
rot: rand(0, Math.PI * 2),
|
|
rotSpeed: rand(-0.2, 0.2),
|
|
tilt: rand(-0.8, 0.8),
|
|
tiltSpeed: rand(-0.02, 0.02),
|
|
color: colors[(Math.random() * colors.length) | 0] ?? '#ef4444',
|
|
alpha: 1,
|
|
decay: rand(0.004, 0.01),
|
|
shape: Math.random() < 0.3 ? 'triangle' : 'rect',
|
|
});
|
|
}
|
|
}
|
|
|
|
const tick = () => {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
for (const p of particles) {
|
|
// physics
|
|
p.vy += gravity;
|
|
p.vx *= 1 - air;
|
|
p.vy *= 1 - air * 0.5;
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.rot += p.rotSpeed;
|
|
p.tilt += p.tiltSpeed;
|
|
p.alpha -= p.decay;
|
|
|
|
// floor bounce & friction
|
|
const floor = window.innerHeight - 6;
|
|
if (p.y > floor) {
|
|
p.y = floor;
|
|
p.vy *= -0.45;
|
|
p.vx *= floorFriction;
|
|
p.rotSpeed *= floorFriction;
|
|
p.tiltSpeed *= floorFriction;
|
|
}
|
|
|
|
// draw
|
|
if (p.alpha > 0) {
|
|
ctx.globalAlpha = Math.max(0, p.alpha);
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rot + p.tilt);
|
|
ctx.fillStyle = p.color;
|
|
|
|
if (p.shape === 'rect') {
|
|
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
|
} else {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -p.h / 2);
|
|
ctx.lineTo(p.w / 2, p.h / 2);
|
|
ctx.lineTo(-p.w / 2, p.h / 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
const alive = particles.some((p) => p.alpha > 0.02);
|
|
if (alive) {
|
|
rafRef.current = requestAnimationFrame(tick);
|
|
} else {
|
|
// cleanup
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
window.removeEventListener('resize', onResize);
|
|
}
|
|
};
|
|
|
|
tick();
|
|
}, []);
|
|
|
|
const handleStart = () => {
|
|
onStart?.();
|
|
|
|
if (isDraftLike) {
|
|
setShowConfetti(true);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!showConfetti) return;
|
|
|
|
runConfetti();
|
|
|
|
const t = setTimeout(() => {
|
|
setShowConfetti(false);
|
|
}, 2200);
|
|
|
|
return () => {
|
|
clearTimeout(t);
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = null;
|
|
};
|
|
}, [showConfetti, runConfetti]);
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleStart}
|
|
className={`min-w-24 ${isDraftLike ? 'bg-green-600 text-white hover:bg-green-700' : ''} ${className ?? ''}`}
|
|
disabled={!isDraftLike}
|
|
title={isDraftLike ? 'Start the experiment' : 'Only available for Draft/Created'}
|
|
>
|
|
<Play className="w-4 h-4 mr-2" />
|
|
{label}
|
|
</Button>
|
|
|
|
{/* Confetti Overlay */}
|
|
{showConfetti && (
|
|
<div
|
|
className="fixed inset-0 z-100 pointer-events-none mask-[radial-gradient(80%_80%_at_50%_40%,#000,transparent)]"
|
|
aria-hidden
|
|
>
|
|
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
|
<div className="absolute inset-x-0 top-0 h-32 bg-linear-to-b from-white/60 to-transparent" />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|