perf(action-points): pool confetti into one shared canvas + single rAF

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.
This commit is contained in:
Tolaria 2026-06-03 23:17:44 +08:00
parent 69f69be5e4
commit fbc488cc95

View file

@ -447,75 +447,131 @@
} }
// ── Confetti ─────────────────────────────────────── // ── Confetti ───────────────────────────────────────
function fireConfetti(intensity) { // Single shared canvas + one rAF loop. Multiple completions add particles
if (REDUCED_MOTION) return; // 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"); var canvas = document.createElement("canvas");
canvas.style.cssText = canvas.style.cssText =
"position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:99999;"; "position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:99999;";
document.body.appendChild(canvas); document.body.appendChild(canvas);
canvas.width = window.innerWidth; canvas.width = window.innerWidth;
canvas.height = window.innerHeight; 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 count = intensity === "page" ? 140 : 55;
var duration = intensity === "page" ? 3200 : 2200; var duration = intensity === "page" ? 3200 : 2200;
var drift = intensity === "page" ? 1.8 : 1.2; var drift = intensity === "page" ? 1.8 : 1.2;
var start = performance.now();
var particles = [];
for (var i = 0; i < count; i++) { for (var i = 0; i < count; i++) {
particles.push({ _confettiParticles.push({
x: Math.random() * canvas.width, x: Math.random() * canvas.width,
y: -(Math.random() * 60 + 10), y: -(Math.random() * 60 + 10),
w: Math.random() * 7 + 2, w: Math.random() * 7 + 2,
h: Math.random() * 4 + 1.5, 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, vx: (Math.random() - 0.5) * drift,
vy: Math.random() * 2 + 1.2, vy: Math.random() * 2 + 1.2,
rot: Math.random() * 360, rot: Math.random() * 360,
rotV: (Math.random() - 0.5) * 8, rotV: (Math.random() - 0.5) * 8,
opacity: 1, start: start,
duration: duration,
}); });
} }
var start = performance.now(); // Start the single loop only if it isn't already running.
if (!_confettiRAF) {
function frame(now) { _confettiRAF = requestAnimationFrame(_confettiTick);
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);
} }
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 ────────────────────────────────────────── // ── Sound ──────────────────────────────────────────
var _audioCtx = null; var _audioCtx = null;