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
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.
getImageDatais 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 withrequestAnimationFrameso you never pick more than once per rendered frame.
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.
Related
- Canvas 2D vs WebGL for Data Visualization — the parent guide to the Canvas-versus-WebGL decision.
- Rendering 100k scatter points without frame drops — drawing the dense scatter this picking makes interactive.
- Fixing blurry Canvas on high-DPI / Retina displays — the devicePixelRatio handling the pick buffer must match.