diff --git a/docs/javascripts/extra.js b/docs/javascripts/extra.js index a63d27f..717012f 100644 --- a/docs/javascripts/extra.js +++ b/docs/javascripts/extra.js @@ -447,75 +447,131 @@ } // ── Confetti ─────────────────────────────────────── - function fireConfetti(intensity) { - if (REDUCED_MOTION) return; + // Single shared canvas + one rAF loop. Multiple completions add particles + // to the same pool instead of stacking N fullscreen canvases (each of which + // would independently repaint the whole viewport at 60fps — the cause of + // click-induced slowdown). The loop fully stops when no particles remain, + // and the canvas is removed so there is no idle fullscreen layer. + var _confettiCanvas = null; + var _confettiCtx = null; + var _confettiParticles = []; + var _confettiRAF = 0; + var CONFETTI_COLORS = [ + "#4a8262", "#a8c5b6", "#3d6b52", "#e9dfc6", "#92ad9e", "#d4e8dc", + ]; + + function _ensureConfettiCanvas() { + if (_confettiCanvas) return; var canvas = document.createElement("canvas"); canvas.style.cssText = "position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:99999;"; document.body.appendChild(canvas); canvas.width = window.innerWidth; canvas.height = window.innerHeight; - var ctx = canvas.getContext("2d"); + _confettiCanvas = canvas; + _confettiCtx = canvas.getContext("2d"); + } + + function _destroyConfettiCanvas() { + if (_confettiCanvas) { + _confettiCanvas.remove(); + _confettiCanvas = null; + _confettiCtx = null; + } + _confettiParticles = []; + _confettiRAF = 0; + } + + function _confettiTick(now) { + var ctx = _confettiCtx; + var canvas = _confettiCanvas; + if (!ctx || !canvas) { + _confettiRAF = 0; + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + var alive = 0; + for (var i = 0; i < _confettiParticles.length; i++) { + var p = _confettiParticles[i]; + var elapsed = now - p.start; + var progress = elapsed / p.duration; + if (progress >= 1) continue; // dead — skip + alive++; + + p.x += p.vx; + p.vy += 0.12; + p.y += p.vy; + p.rot += p.rotV; + var fade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate((p.rot * Math.PI) / 180); + ctx.globalAlpha = fade; + ctx.fillStyle = p.color; + ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + ctx.restore(); + } + + if (alive === 0) { + _destroyConfettiCanvas(); + return; + } + + // Periodically compact the array so dead particles don't accumulate. + if (_confettiParticles.length > 400) { + _confettiParticles = _confettiParticles.filter(function (p) { + return now - p.start < p.duration; + }); + } + + _confettiRAF = requestAnimationFrame(_confettiTick); + } + + function fireConfetti(intensity) { + if (REDUCED_MOTION) return; + + _ensureConfettiCanvas(); + var canvas = _confettiCanvas; - var COLORS = ["#4a8262", "#a8c5b6", "#3d6b52", "#e9dfc6", "#92ad9e", "#d4e8dc"]; var count = intensity === "page" ? 140 : 55; var duration = intensity === "page" ? 3200 : 2200; var drift = intensity === "page" ? 1.8 : 1.2; + var start = performance.now(); - var particles = []; for (var i = 0; i < count; i++) { - particles.push({ + _confettiParticles.push({ x: Math.random() * canvas.width, y: -(Math.random() * 60 + 10), w: Math.random() * 7 + 2, h: Math.random() * 4 + 1.5, - color: COLORS[Math.floor(Math.random() * COLORS.length)], + color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)], vx: (Math.random() - 0.5) * drift, vy: Math.random() * 2 + 1.2, rot: Math.random() * 360, rotV: (Math.random() - 0.5) * 8, - opacity: 1, + start: start, + duration: duration, }); } - var start = performance.now(); - - function frame(now) { - var elapsed = now - start; - var progress = elapsed / duration; - if (progress >= 1) { - canvas.remove(); - return; - } - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Fade out in last 30% - var fade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; - - particles.forEach(function (p) { - p.x += p.vx; - p.vy += 0.12; - p.y += p.vy; - p.rot += p.rotV; - p.opacity = fade; - - ctx.save(); - ctx.translate(p.x, p.y); - ctx.rotate((p.rot * Math.PI) / 180); - ctx.globalAlpha = p.opacity; - ctx.fillStyle = p.color; - ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); - ctx.restore(); - }); - - requestAnimationFrame(frame); + // Start the single loop only if it isn't already running. + if (!_confettiRAF) { + _confettiRAF = requestAnimationFrame(_confettiTick); } - - requestAnimationFrame(frame); } + // Keep the shared canvas sized to the viewport. + window.addEventListener("resize", function () { + if (_confettiCanvas) { + _confettiCanvas.width = window.innerWidth; + _confettiCanvas.height = window.innerHeight; + } + }); + // ── Sound ────────────────────────────────────────── var _audioCtx = null;