How a heatmap is actually rendered (and the mistake everyone makes first)
Painting each point in its final colour gives you a scatter plot with big dots; density has to compound in the alpha channel before anything gets a hue.
The first heatmap renderer I wrote produced a picture that looked plausible and was lying. Clicks came out as fuzzy red circles over the buttons, which is what a heatmap looks like in a screenshot, so it took a while to notice that a button with 800 clicks and a button with 12 clicks were the same shade of red. The map had no scale. It was a scatter plot with the dots blurred.
The bug was in one line. I was painting each point in its final colour:
// wrong
const g = ctx.createRadialGradient(x, y, 0, x, y, 26);
g.addColorStop(0, 'rgba(214,69,45,0.45)'); // hot
g.addColorStop(1, 'rgba(214,69,45,0)');
Stack two of those and you get a slightly more opaque red. Stack forty and you get red. Source-over compositing saturates: 0.45 over 0.45 is 0.70, then 0.83, then 0.91, and the RGB never moves because every layer was already the same RGB. Colour was set at paint time from a constant, so nothing downstream could make it mean anything. The intensity ramp in the sidebar legend — cold to hot — was decoration.
Paint black, colour later
The fix is two passes, and it is the whole idea. Every blob is painted as translucent black into an off-screen scratch canvas. Nothing is coloured while it is being drawn. What the scratch canvas ends up holding is not a picture, it is a density field: RGB is meaningless (all zero), and each pixel's alpha is the accumulated weight of every blob that touched it.
function blob(c, x, y, radius, intensity) {
const g = c.createRadialGradient(x, y, 0, x, y, radius);
g.addColorStop(0, 'rgba(0,0,0,' + intensity + ')');
g.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = g;
c.beginPath();
c.arc(x, y, radius, 0, Math.PI * 2);
c.fill();
}
Only when the whole layer is down does colour get assigned, by reading the pixels back and using alpha — a byte, conveniently 0–255 — as a direct index into a 256-entry lookup table:
function colorize(s, maxAlpha) {
const img = s.ctx.getImageData(0, 0, s.el.width, s.el.height);
const px = img.data;
for (let i = 0; i < px.length; i += 4) {
const a = px[i + 3];
if (a === 0) continue;
const o = a * 4; // alpha → offset into the 256px ramp
px[i] = RAMP[o];
px[i + 1] = RAMP[o + 1];
px[i + 2] = RAMP[o + 2];
px[i + 3] = Math.min(maxAlpha, a); // keep the backdrop legible
}
s.ctx.putImageData(img, 0, 0);
ctx.drawImage(s.el, 0, 0);
}
RAMP is itself a canvas trick: paint a linear gradient into a 256×1 canvas, getImageData it once, keep the Uint8ClampedArray. Lookup is then RAMP[a * 4] and there is no per-pixel interpolation to do at draw time.
const RAMP = ramp([
[0.00, 'rgba(47,107,163,0)'],
[0.18, T('--t-cold')],
[0.40, T('--t-cool')],
[0.60, T('--t-mid')],
[0.80, T('--t-warm')],
[1.00, T('--t-hot')],
]);
T() pulls those from CSS custom properties — the same variables the .ramp legend in the margin renders as a linear-gradient. That is deliberate and it is not a style choice. If the legend says a colour means "hot" and the canvas got its hot from a hard-coded hex somewhere in the JS, the two will drift on the first theme tweak and the map will be confidently mislabelled. One source of truth, or the legend is a lie.
The consequence of the two-pass approach is the thing that was broken before: overlap compounds. Two clicks 10 px apart accumulate alpha in the region where their gradients overlap, that region indexes further along the ramp, and it comes out hotter than either click alone. Density drives hue. Drawing order does not. That is the difference between a heatmap and a scatter plot with big dots, and it is not achievable with any amount of tuning on the naive version — you cannot recover a density field from an image that has already been colour-quantized.
Each layer gets its own scratch canvas and its own colorize call, because compounding within a layer is signal and compounding across layers is nonsense. A click and a mouse-dwell cell landing on the same pixel do not add up to something twice as hot; they are different measurements.
Not everything should be heat
Rage clicks and dead clicks are not drawn through this pipeline at all. They are drawn as discrete rings:
function markers(points, color) {
for (const p of points) {
const [x, y] = resolve(p);
if (y > canvas.height) continue;
ctx.beginPath();
ctx.arc(x, y, 13, 0, Math.PI * 2);
ctx.fillStyle = color + '33';
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = color;
ctx.stroke();
}
}
The reason is that heat is a lossy summary and rage clicks are individually meaningful. There are six of them on a page, not six thousand. Blurring six events into a cloud destroys the one fact that matters — that there were exactly six, on that element — in exchange for a prettier picture. Heat is for distributions. Six discrete failures are not a distribution.
The dwell layer needed a different concession. Mouse dwell is savagely long-tailed: one grid cell where somebody parked the cursor while reading a paragraph can hold 100× the count of the median cell. Feed that into a linear alpha scale and the map is one white-hot cell on an otherwise empty sheet, technically correct and useless. So it goes through a square root:
blob(s.ctx, gx * gs + gs / 2, y, r, Math.min(1, Math.sqrt(count / max)) * 0.5);
This is a real trade-off, not a free win. A sqrt scale flatters low-traffic regions and understates the difference between a cell with 400 samples and one with 100 (they render at 1.0 and 0.5 of the scale, not 4:1). I took it because a heatmap is read qualitatively — "people hover here, not there" — and a map where 95% of the ink is invisible answers no question at all. Clicks, whose distribution is far less brutal, get a flat 0.45 per point and are allowed to compound naturally.
The clamp
Canvas has a size ceiling and browsers do not tell you when you hit it. Ask for a canvas past the limit and you get back a canvas object that looks fine, has the width and height you asked for, and draws nothing. No exception, no console warning. In Isotherm the failure mode was a page that reported the right click count in the sidebar and rendered an empty sheet, which is the worst possible way to be wrong, because it looks like "no data".
The limits are not one number and they are not all the same shape. Browsers cap a single dimension (in the tens of thousands of pixels, varying by engine), and separately they cap total area. The area cap is the one that bites: WebKit on iOS has historically refused to back a canvas larger than about 16.7 million pixels, regardless of how that area is distributed. So the code does this:
const MAX_CANVAS_H = 12000;
...
canvas.width = data.docWidth;
canvas.height = Math.min(data.docHeight, MAX_CANVAS_H);
That clamps height and only height. Width is whatever the recorded document width was. 12,000 px is comfortably under any single-dimension cap I know of, which is what I was thinking about when I picked it — and it does not save you from the area cap at all. A 1440 px-wide page clamped to 12,000 px tall is 17.3 million pixels, which is over the iOS ceiling. I got this wrong: the clamp I wrote defends against the constraint that was easy to reason about, not the one that actually kills the render on a phone. The correct fix is to clamp on the product, something like min(docHeight, MAX_AREA / docWidth, MAX_CANVAS_H), and it is not in the code yet.
The vertical guards are also less uniform than they should be. The dwell loop and the click loop both bail with if (y > h) continue; before painting a point past the clamp. markers() does the same check against canvas.height rather than the local h — equivalent, since h is read from canvas.height at the top of draw(), but it is a second spelling of one rule. drawScroll() cannot use a continue at all, because its bands are ranges rather than points: it does if (top > h) break; to stop once the bands have run past the bottom, and clips the last surviving band with Math.min(bottom, h) - top. Three call sites, three shapes of the same guard. It works, and it is exactly the kind of thing that stops working the day somebody adds a fourth layer and forgets which spelling to copy.
The cost of the clamp itself is honest: on a page taller than 12,000 px, the deep tail is not drawn. I am fine with that — scroll data consistently says single-digit percentages of visitors ever reach 12,000 px, and the layer that would tell you that (scroll reach) is a fillRect band, so it still renders correctly down to the clamp. What I would not accept is the alternative: tiling the canvas into segments, which doubles the drawing code, complicates getImageData (the read-back would have to be per-tile), and buys visibility into a region where the answer is almost always "nobody went there".
Note the willReadFrequently: true context hint on both the visible canvas and every scratch canvas. colorize calls getImageData on the full layer every redraw, and without that hint the browser keeps the canvas on the GPU and every read-back is a stall. With it, the canvas is backed by CPU memory and the read is cheap. This matters because a redraw is not rare — toggling any layer checkbox re-runs draw() from scratch.
Where this technique is wrong
Two places.
If you have a handful of points — twenty clicks on a page — a heatmap is the wrong visualization entirely, and the two-pass pipeline will happily render twenty smudges that imply a density you did not measure. Heat implies a population. Below some threshold, the honest render is dots.
And if your points are already weighted with something that is not density — conversion rate per element, say, or average time-to-click — do not put it through this. Alpha means "how much stuff is here". If you smuggle a second variable into the colour, the overlaps compound it and produce a number that means nothing at all: two 40%-conversion elements overlapping do not make 80%. Colour has to mean temperature and nothing else. When Isotherm needs to say something that isn't a density, it says it in a table — the "Most clicked elements" list under the map, keyed to the selector rather than the pixel, which is where the facts a blob cannot carry go to live.
-
A dwell map is not a mouse trail
Sampling the pointer on a 100 ms timer and counting grid cells turns a minute of mousemove from ~7,000 events into a few hundred integers — and produces a better signal than the trail would have.
-
A dead click is about belief, not wiring
You cannot tell from the DOM whether an element has a handler, so Isotherm detects dead clicks by watching what the user believed and whether the page blinked.
-
Detecting a redesign without being told: DOM structure fingerprinting
The SDK hashes the DOM skeleton with runs of identical siblings folded to `tag*`, so a new column forks the heatmap version but a 25th table row does not.