From fbc488cc95a15f273426118db92329537f025219 Mon Sep 17 00:00:00 2001 From: Tolaria Date: Wed, 3 Jun 2026 23:17:44 +0800 Subject: [PATCH] perf(action-points): pool confetti into one shared canvas + single rAF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each section/page completion previously appended a new full-viewport (100vw x 100vh, z-index 99999) canvas with its own requestAnimationFrame loop. Completing several sections in a row stacked multiple fullscreen layers, each independently repainting the whole viewport at 60fps — the real cause of click-induced slowdown (the change handler itself is ~1ms). Now: one shared canvas, one rAF loop, particles pooled across completions. The loop stops and the canvas is removed once no particles remain, so there is no idle fullscreen compositing layer. Resize keeps it sized. Verified live: 1 canvas after two back-to-back section completions, 0 canvases after the animation ends, green pills still white-on-green. --- docs/javascripts/extra.js | 140 ++++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 42 deletions(-) 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;