Distribution:
Points: 1 000

Key concept — manual enter/update/exit in transition()

A Canvas 2D context gives you immediate-mode drawing surface: every frame you clear the canvas and repaint everything from scratch. There are no persistent objects in the DOM, so you must track your own particle array and implement enter/update/exit logic yourself. The payoff is raw speed — drawing 100 000 circles via ctx.arc() in a requestAnimationFrame loop is orders of magnitude faster than equivalent SVG elements. Note that all of this still runs on the CPU: what Canvas skips is the DOM's retained-mode scene graph, not the CPU drawing work itself. The GPU only gets involved at the very end, blitting the finished bitmap to the display.

function drawDots() {
    // compute current positions for all particles
    const rendered = particles.map(p => getRendered(p));

    // find nearest dot to the cursor
    let minDist2 = HOVER_PX * HOVER_PX;
    hoveredIdx = -1;
    for (let i = 0; i < particles.length; i++) {
    const { cx, cy } = rendered[i];
    const d2 = (cx - mouseX) ** 2 + (cy - mouseY) ** 2;
    if (d2 < minDist2) { minDist2 = d2; hoveredIdx = i; }
    }

    // draw — hovered dot last so it isn't obscured
    for (let pass = 0; pass < 2; pass++) {
    for (let i = 0; i < particles.length; i++) {
        const isHovered = i === hoveredIdx;
        if (pass === 0 && isHovered)  continue; // draw normal dots first
        if (pass === 1 && !isHovered) continue; // draw hovered dot on top

        const { cx, cy, r } = rendered[i];
        if (r <= 0) continue;

        ctx.beginPath();
        ctx.arc(cx, cy, r, 0, Math.PI * 2);

        if (isHovered) {
        ctx.fillStyle   = '#e5ffff';
        ctx.strokeStyle = '#0a0032';
        ctx.lineWidth   = 0.5;
        } else {
        ctx.fillStyle   = 'rgba(244,190,0,0.7)'; // otter shine gold @ 70%
        ctx.strokeStyle = '#0a0032';
        ctx.lineWidth   = 0.5;
        }

        ctx.fill();
        ctx.stroke();
    }
}
    

Hover & color

A canvas is just pixels — it has no built-in hit-testing. You track the mouse yourself and run a nearest-neighbour search every frame. To prevent a hovered dot from being obscured by its neighbours, use a two-pass draw: paint all normal dots first, then overdraw the highlighted one on top.

// 1. Track the mouse position
canvas.addEventListener('mousemove', e => {
  const rect = canvas.getBoundingClientRect();
  mouseX = e.clientX - rect.left;
  mouseY = e.clientY - rect.top;
  if (!rafId) render();   // repaint when animation is idle
});

canvas.addEventListener('mouseleave', () => {
  mouseX = -Infinity;
  if (!rafId) render();
});

// 2. Nearest-neighbour search (inside drawDots)
let minDist2 = HOVER_PX * HOVER_PX;
hoveredIdx = -1;
for (let i = 0; i < particles.length; i++) {
  const { cx, cy } = rendered[i];
  const d2 = (cx - mouseX)**2 + (cy - mouseY)**2;
  if (d2 < minDist2) { minDist2 = d2; hoveredIdx = i; }
}

// 3. Color branch — hovered dot is drawn on top in the second pass
ctx.fillStyle = isHovered ? '#e5ffff' : 'rgba(244,190,0,0.7)';