Hit Detection for Canvas Chart Elements

You have drawn ten thousand points to a <canvas>, the user clicks one, and nothing happens — because a canvas is a flat bitmap that remembers no objects to click.

Unlike SVG, where every mark is a DOM node that fires its own pointer events, a Canvas mark vanishes into pixels the instant it is drawn. To make Canvas charts interactive you must reconstruct which data element sits under the pointer yourself. This guide is part of Canvas 2D vs WebGL for Data Visualization, and it is the interaction counterpart to rendering 100k scatter points without frame drops: once you can draw that many points fast, this is how you let the user pick one. Two techniques dominate — a hidden color-buffer “pick” canvas, and a spatial index such as a quadtree — and they trade memory for geometry in opposite ways.

Diagnostic checklist

Color-buffer picking versus a quadtree spatial index Left: an offscreen canvas draws each point in a unique colour so a single pixel read returns the element id. Right: a quadtree partitions space so a nearest-point search visits only nearby cells. Hidden color buffer unique colour = id read 1 pixel at pointer → colour → id O(1) lookup 2nd buffer in RAM Quadtree index search visits only near cells O(log N) find no 2nd buffer DPR pitfall: scale both the buffer AND the read coordinate by devicePixelRatio
The color buffer trades a second full-resolution canvas for an O(1) pixel read that is pixel-exact for any shape. The quadtree trades a tree in memory for an O(log N) nearest-point search with no extra pixels.
Clustered scatter sample Three seeded gaussian clusters of points on a shared pair of axes. xy
A representative point cloud of the kind hit detection for canvas chart elements operates on — three clusters that a renderer must place, pick, or pan without dropping frames.

Broken vs fixed

// ❌ BROKEN: linear scan of every point on every mousemove — O(N) per event.
interface Point { id: number; x: number; y: number; }

function hitTest(points: Point[], px: number, py: number): Point | null {
  for (const p of points) {                 // 10k points × 60 events/sec = jank
    const dx = p.x - px, dy = p.y - py;
    if (dx * dx + dy * dy < 25) return p;    // also ignores devicePixelRatio entirely
  }
  return null;
}
// ✅ FIXED: a hidden color buffer maps one pixel read to an element id in O(1).
class ColorPicker {
  private buf: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private byColor = new Map<number, Point>();
  constructor(cssW: number, cssH: number, private dpr: number) {
    this.buf = document.createElement("canvas");
    this.buf.width = Math.round(cssW * dpr);   // pick buffer matches DPR of the visible canvas
    this.buf.height = Math.round(cssH * dpr);
    this.ctx = this.buf.getContext("2d", { willReadFrequently: true })!;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.ctx.imageSmoothingEnabled = false;    // PERF: no blended colours → no phantom ids
  }
  draw(points: Point[]): void {
    for (const p of points) {
      const rgb = idToColor(p.id);             // pack id into r,g,b
      this.byColor.set(p.id, p);
      this.ctx.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
      this.ctx.beginPath();
      this.ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
      this.ctx.fill();
    }
  }
  pick(cssX: number, cssY: number): Point | null {
    // A11Y: mirror the picked element to an aria-live region so keyboard users get it too.
    const px = Math.round(cssX * this.dpr), py = Math.round(cssY * this.dpr); // scale the READ too
    const [r, g, b] = this.ctx.getImageData(px, py, 1, 1).data;
    return this.byColor.get(colorToId(r, g, b)) ?? null;
  }
}

function idToColor(id: number): [number, number, number] {
  return [(id >> 16) & 255, (id >> 8) & 255, id & 255];
}
function colorToId(r: number, g: number, b: number): number {
  return (r << 16) | (g << 8) | b;
}

The critical detail is that the pick buffer and the read coordinate must share the same device pixel ratio. If your visible canvas is DPR-scaled (as it must be to stay sharp) but you draw the pick buffer at CSS size, or you read getImageData at a CSS coordinate against a DPR-sized buffer, the pixel you sample is offset from where the point was drawn and picking silently misses — worst near the edges, which is maddening to debug. Scale the buffer dimensions by DPR, scale the context by DPR, and scale the pointer coordinate by DPR before reading.

Step-by-step fix

// Alternative: d3-quadtree for nearest-point picking without a second canvas.
import { quadtree, type Quadtree } from "d3-quadtree";

function buildIndex(points: Point[]): Quadtree<Point> {
  return quadtree<Point>()
    .x((p) => p.x)                            // same space as the pointer you query with
    .y((p) => p.y)
    .addAll(points);
}

function pickNearest(tree: Quadtree<Point>, px: number, py: number, radius = 6): Point | null {
  // PERF: find() prunes whole quadrants, visiting O(log N) nodes not O(N).
  return tree.find(px, py, radius) ?? null;
}

Verification

// Assert a known point at a known screen position picks back to itself.
function assertPicks(picker: ColorPicker, p: Point): void {
  picker.draw([p]);
  const hit = picker.pick(p.x, p.y);          // pass CSS coords; picker scales by DPR
  console.assert(hit?.id === p.id, `expected id ${p.id}, got ${hit?.id ?? "null"}`);
}

In DevTools, temporarily draw the pick buffer to the screen instead of the visible layer and confirm every element is a flat, unique, un-anti-aliased colour patch with no gradient fringes. Then log the picked id on mousemove and hover slowly across a dense cluster to confirm the id changes exactly as the pointer crosses each mark’s edge.

Choosing between the color buffer and a spatial index

The two techniques answer subtly different questions. The color buffer answers “which shape’s pixels are exactly under the pointer?”, so it is pixel-perfect for irregular marks — a pie wedge, a thick line, a country in a choropleth — where “nearest centre” would be wrong. Its cost is a second full-resolution canvas held in memory (four bytes per device pixel, which at DPR 2 on a large chart is tens of megabytes) and a redraw of that buffer whenever positions change. The getImageData read itself is O(1), and the willReadFrequently context hint keeps the readback cheap by steering the browser away from GPU-backed storage that is slow to read.

The quadtree answers “which point is nearest the pointer, within a radius?”, which is the right question for scatter and bubble charts made of small markers. It costs a tree in memory proportional to the point count — far less than a second canvas — and its find prunes whole quadrants so a query visits O(log N) nodes rather than all N. Its weakness is that it indexes positions, not pixels, so it treats every mark as a point; a large or oddly shaped mark whose centre is far from the pointer but whose body is under it will be missed. As a rule, reach for the color buffer when hit areas are large or irregular and pixel accuracy matters, and for a quadtree when marks are small, numerous, and “closest wins” is acceptable — which for dense scatter plots it almost always is.

Edge cases & gotchas

  • DPR mismatch on the read. The single most common bug: a DPR-scaled pick buffer read at an un-scaled CSS coordinate samples the wrong pixel, missing hits increasingly toward the canvas edges. Scale buffer, context, and read coordinate by the same ratio.
  • Anti-aliased pick colours. Leaving image smoothing on blends an element’s colour with its neighbour along shared edges, producing intermediate colours that decode to ids that were never assigned. Always disable smoothing on the pick pass.
  • Id space overflow. Packing ids into 24-bit RGB caps you at about 16.7 million elements; reserve rgb(0,0,0) for “no hit” and start ids at 1, or a background read returns element zero.
  • Stale index after pan or zoom. A quadtree built in screen space is invalid the moment the view transform changes. Either index in data space and transform the query point back, or rebuild the tree after each transform change.
  • getImageData is not free at scale. Reading a region rather than a single pixel, or reading every frame during a drag, can stall the pipeline. Read exactly one pixel and only on the events you need.
  • Hover throttling. Even O(1) picking runs on every mousemove, which can fire faster than paint. Throttle picks to the frame with requestAnimationFrame so you never pick more than once per rendered frame.
SVG vs Canvas render-cost crossover Frame cost against element count for a DOM-node SVG chart and a single-element Canvas. element count (×1k)frame costSVG (DOM nodes)Canvas (1 element)
The render-cost crossover underlying hit detection for canvas chart elements: SVG cost climbs with node count while Canvas stays flat, which is why heavy scenes switch engines.

Frequently Asked Questions

How do I detect clicks on individual Canvas chart elements?

Because a canvas is a single bitmap with no per-mark DOM nodes, you reconstruct the hit yourself. Either draw a hidden pick canvas where each element has a unique fill colour and read the one pixel under the pointer to recover its id, or build a spatial index such as a quadtree and query the nearest point to the pointer. The color buffer is pixel-exact for any shape; the quadtree is a fast nearest-point search for markers.

What is the color-buffer picking technique?

You render the scene a second time to an offscreen canvas, giving every element a unique flat colour that encodes its id (for example packing a 24-bit id into the red, green, and blue channels). To hit-test, you read a single pixel from that buffer at the pointer position with getImageData and decode the colour back into an id. It is O(1) per query and pixel-exact, at the memory cost of a second full-resolution canvas that you must keep in sync.

When should I use a quadtree instead of a pick buffer?

Use a quadtree when your marks are small points and “nearest within a radius” is the correct hit semantics, as in scatter and bubble charts. It stores only positions, so it uses far less memory than a second canvas, and find runs in about O(log N) by pruning distant quadrants. Prefer the color buffer instead when hit areas are large or irregular and you need pixel-exact selection rather than nearest-centre selection.