pmtool / src /lib /confetti.ts
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
4.58 kB
export type ConfettiOrigin = {
x: number;
y: number;
};
export type AttendanceConfettiMode = "checkIn" | "checkOut";
type ConfettiOptions = {
origin?: ConfettiOrigin;
colors: string[];
particleCount: number;
durationMs: number;
};
let activeCanvas: HTMLCanvasElement | null = null;
let activeAnimationId: number | null = null;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const createCanvas = () => {
const canvas = document.createElement("canvas");
canvas.style.position = "fixed";
canvas.style.inset = "0";
canvas.style.pointerEvents = "none";
canvas.style.zIndex = "9999";
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
document.body.appendChild(canvas);
return canvas;
};
const teardownCanvas = () => {
if (activeAnimationId != null) {
cancelAnimationFrame(activeAnimationId);
activeAnimationId = null;
}
if (activeCanvas && activeCanvas.parentElement) {
activeCanvas.parentElement.removeChild(activeCanvas);
}
activeCanvas = null;
};
export const getConfettiOriginFromElement = (el: HTMLElement | null): ConfettiOrigin | undefined => {
if (!el) return undefined;
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height) return undefined;
return {
x: clamp01((rect.left + rect.width / 2) / window.innerWidth),
y: clamp01((rect.top + rect.height / 2) / window.innerHeight),
};
};
export const triggerConfetti = (opts: ConfettiOptions) => {
if (typeof window === "undefined" || typeof document === "undefined") return;
teardownCanvas();
activeCanvas = createCanvas();
const ctx = activeCanvas.getContext("2d");
if (!ctx) {
teardownCanvas();
return;
}
const origin = {
x: clamp01(opts.origin?.x ?? 0.5),
y: clamp01(opts.origin?.y ?? 0.35),
};
const start = performance.now();
const duration = Math.max(300, opts.durationMs);
const resize = () => {
if (!activeCanvas) return;
activeCanvas.width = window.innerWidth;
activeCanvas.height = window.innerHeight;
};
window.addEventListener("resize", resize, { passive: true });
const w = () => activeCanvas?.width ?? window.innerWidth;
const h = () => activeCanvas?.height ?? window.innerHeight;
const startX = origin.x * w();
const startY = origin.y * h();
const colors = opts.colors;
const particles = Array.from({ length: Math.max(10, opts.particleCount) }).map(() => {
const angle = (-Math.PI / 2) + (Math.random() - 0.5) * (Math.PI / 1.6);
const speed = 6 + Math.random() * 10;
const size = 4 + Math.random() * 6;
const spin = (Math.random() - 0.5) * 0.3;
return {
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
g: 0.25 + Math.random() * 0.35,
r: Math.random() * Math.PI,
vr: spin,
size,
color: colors[Math.floor(Math.random() * colors.length)],
seed: Math.random(),
};
});
const loop = (now: number) => {
const t = now - start;
const p = Math.min(1, t / duration);
if (!activeCanvas) return;
ctx.clearRect(0, 0, w(), h());
for (const part of particles) {
part.vx *= 0.985;
part.vy = part.vy * 0.985 + part.g;
part.x += part.vx;
part.y += part.vy;
part.r += part.vr;
const alpha = 1 - Math.pow(p, 1.8);
if (alpha <= 0) continue;
const flutter = Math.sin((now / 80) + part.seed * 10) * 0.6;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(part.x, part.y);
ctx.rotate(part.r + flutter);
ctx.fillStyle = part.color;
ctx.fillRect(-part.size / 2, -part.size / 2, part.size, part.size * 0.6);
ctx.restore();
}
if (p >= 1) {
window.removeEventListener("resize", resize);
teardownCanvas();
return;
}
activeAnimationId = requestAnimationFrame(loop);
};
activeAnimationId = requestAnimationFrame(loop);
};
export const triggerAttendanceConfetti = (
mode: AttendanceConfettiMode,
origin?: ConfettiOrigin
) => {
if (mode === "checkIn") {
triggerConfetti({
origin,
colors: ["#22c55e", "#16a34a", "#3b82f6", "#38bdf8", "#facc15"],
particleCount: 90,
durationMs: 1200,
});
return;
}
triggerConfetti({
origin,
colors: ["#f97316", "#fb7185", "#a855f7", "#60a5fa", "#facc15"],
particleCount: 110,
durationMs: 1400,
});
};