// Effects & helpers -------------------------------------------------- // Screenshot hover --------------------------------------------------- export function setupCardVisualHover(el, url) { if (!el || !url) return; el.addEventListener("mouseenter", () => { el.classList.add("hovering"); }); ["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, () => { el.classList.remove("hovering"); }), ); } // Celebration FX ----------------------------------------------------- let fxCanvas; let fxCtx; let fxParticles = []; let fxAnimating = false; function ensureFxCanvas() { if (fxCanvas) return; fxCanvas = document.createElement("canvas"); fxCanvas.className = "fx-canvas"; fxCanvas.width = window.innerWidth; fxCanvas.height = window.innerHeight; fxCtx = fxCanvas.getContext("2d"); document.body.appendChild(fxCanvas); window.addEventListener("resize", () => { fxCanvas.width = window.innerWidth; fxCanvas.height = window.innerHeight; }); } export function triggerCelebration(button) { ensureFxCanvas(); const rect = (button || document.body).getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; spawnConfetti(x, y, 80); spawnFirework(x, y, 40); if (!fxAnimating) { fxAnimating = true; requestAnimationFrame(fxStep); } } function spawnConfetti(x, y, count) { for (let i = 0; i < count; i++) { fxParticles.push({ x: x + (Math.random() - 0.5) * 20, y: y + (Math.random() - 0.5) * 200, vx: (Math.random() - 0.5) * 6, vy: Math.random() * -6 - 2, size: 6 + Math.random() * 4, life: 600 + Math.random() * 200, color: randomColor(), type: "confetti", wobble: Math.random() * Math.PI * 2, }); } } function spawnFirework(x, y, count) { for (let i = 0; i < count; i++) { const angle = (Math.PI * 2 * i) / count + Math.random() * 0.3; const speed = (3 + Math.random() * 3) * 0.1; fxParticles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 3, size: 4 + Math.random() * 3, life: 500 + Math.random() * 200, color: randomColor(), type: "spark", }); } } function fxStep() { if (!fxCtx || !fxCanvas) return; fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height); fxParticles = fxParticles.filter((p) => p.life > 0); for (const p of fxParticles) { if (p.type === "confetti") { p.vy += 0.05; p.vx *= 0.999; p.wobble += 0.2; p.x += p.vx + Math.cos(p.wobble) * 0.8; p.y += p.vy; } else { p.vy += 0.02; p.vx *= 0.9995; p.x += p.vx; p.y += p.vy; } p.life -= 1; fxCtx.fillStyle = p.color; fxCtx.beginPath(); if (p.type === "confetti") { fxCtx.fillRect(p.x, p.y, p.size, p.size * 0.6); } else { fxCtx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2); fxCtx.fill(); } } if (fxParticles.length > 0) { requestAnimationFrame(fxStep); } else { fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height); fxAnimating = false; } } function randomColor() { const palette = ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"]; return palette[Math.floor(Math.random() * palette.length)]; }