Aggregating Points into a Density Heatmap

Your scatter plot draws a million points, the centre is a solid opaque blob, and no one can tell whether that mass is ten thousand points or half the dataset — overplotting has destroyed the very distribution you wanted to show.

The remedy is to stop drawing individual points and start drawing how many fall in each region: bin the cloud into a 2D grid or hexagonal lattice, count the points per cell, and map those counts to colour. This page is a companion within large-dataset rendering strategies and the aggregation counterpart to LTTB downsampling: a line downsamples because it has one path to preserve, but a scattered cloud has no path, so you summarise its density instead. The output is a fixed grid of a few thousand cells no matter whether the input was a million points or a hundred million.

Diagnostic checklist

Binning an overplotted scatter into a density grid A dense point cloud on the left is aggregated into a grid of per-cell counts on the right, then mapped to a sequential colour scale. Count per cell, then colour by count overplotted scatter bin density heatmap high count low count sequential scale
An overplotted cloud carries no readable density; binning it into a grid of per-cell counts and mapping those counts to a sequential colour scale recovers the distribution the raw scatter hid.
Left: sixty thousand translucent points overplotted into a saturated blob where structure is lost. Right: the same points binned into a 40 by 30 grid and coloured by count, revealing two density peaks.
A real capture of 60,000 points: drawn individually (left) the marks saturate into an unreadable blob; binned into a density grid (right) the two clusters reappear. This is why heavy scatter data is aggregated, not overplotted.
Point density as a binned heatmap Six thousand overlapping points binned into a 24×12 grid and coloured by count. x biny bin
Instead of overplotting thousands of marks for points into a density heatmap, points are binned and shaded by density so structure survives at scale.

Broken vs fixed

// ❌ BROKEN: draw a million semi-transparent dots and hope density "reads".
function overplot(ctx: CanvasRenderingContext2D, pts: readonly Point[]): void {
  ctx.fillStyle = "rgba(30,41,59,0.05)";
  for (const p of pts) {                     // a million fill calls per frame
    ctx.beginPath();
    ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
    ctx.fill();                               // centre saturates to opaque; density lost
  }
}
// ✅ FIXED: bin into a grid once, draw one rect per occupied cell.
interface Point { x: number; y: number }

function binToGrid(pts: readonly Point[], w: number, h: number, cell: number): Uint32Array {
  const cols: number = Math.ceil(w / cell);
  const rows: number = Math.ceil(h / cell);
  const counts = new Uint32Array(cols * rows); // pre-allocated, GC-free
  for (const p of pts) {                        // single O(n) pass
    const cx: number = (p.x / cell) | 0;
    const cy: number = (p.y / cell) | 0;
    if (cx >= 0 && cx < cols && cy >= 0 && cy < rows) counts[cy * cols + cx]++;
  }
  return counts;                                // <= cols*rows cells, not n points
}

function drawHeatmap(ctx: CanvasRenderingContext2D, counts: Uint32Array, cols: number, cell: number): void {
  let max = 0;
  for (const c of counts) if (c > max) max = c;
  // PERF: log transform so a few dense cells do not flatten every sparse one.
  const norm: number = Math.log1p(max) || 1;
  for (let i = 0; i < counts.length; i++) {
    if (counts[i] === 0) continue;             // skip empty cells entirely
    const t: number = Math.log1p(counts[i]) / norm;
    ctx.fillStyle = viridis(t);                // A11Y: perceptually uniform, colourblind-safer
    ctx.fillRect((i % cols) * cell, ((i / cols) | 0) * cell, cell, cell);
  }
}

declare function viridis(t: number): string;

The transformation collapses cost twice over. The binning pass is O(n) but writes into a fixed Uint32Array whose size is set by the grid, not the dataset, so a million points and a hundred million points both produce the same few-thousand-cell grid. The draw then iterates cells, not points: at a cell size of four pixels a 800×600 chart has 30,000 cells, of which only the occupied ones are filled — a couple of orders of magnitude fewer draw calls than one-per-point. Density becomes an explicit, measurable count rather than an emergent property of overlapping alpha that saturates to opaque and lies about the peak.

Step-by-step fix

function aggregateForView(pts: readonly Point[], w: number, h: number): { counts: Uint32Array; cols: number; cell: number } {
  const cell = 4;                              // px per cell
  const cols: number = Math.ceil(w / cell);
  return { counts: binToGrid(pts, w, h, cell), cols, cell }; // run on view change, cache
}

Verification

const pts: Point[] = Array.from({ length: 1_000_000 }, () => ({ x: Math.random() * 800, y: Math.random() * 600 }));
const counts: Uint32Array = binToGrid(pts, 800, 600, 4);
let total = 0;
for (const c of counts) total += c;
console.assert(total === pts.length, `counts sum ${total} != point count ${pts.length}`);
console.assert(counts.length === Math.ceil(800 / 4) * Math.ceil(600 / 4), "grid dimensions wrong");

The count array must sum to the number of in-bounds points — that conservation check catches off-by-one binning and clamping bugs immediately. In DevTools, profile the draw call: fill count should equal the number of occupied cells (a few thousand), not the point count, confirming the aggregation reached the renderer rather than the raw cloud.

Square grid versus hexbin, and colour honesty

Square cells are simplest and map to fillRect directly, but the grid’s axis-aligned edges can create visual banding along rows and columns. Hexagonal bins pack more uniformly — each hexagon has six equidistant neighbours rather than a mix of edge and corner adjacencies — so density gradients read more smoothly, at the cost of a slightly more involved point-to-cell mapping (offset every other row and pick the nearest hex centre). For exploratory density where the eye follows gradients, hexbin usually looks better; for a heatmap that will be read cell-by-cell or exported to a grid, squares are clearer.

The colour scale is where a density heatmap most often misleads. A rainbow scale invents perceptual boundaries — the eye reads the green-to-cyan transition as a sharper step than the equal numeric step from red to orange — so it fabricates structure that is not in the data. A perceptually uniform sequential scale such as viridis or magma keeps equal count differences looking equal, and both remain distinguishable under the common forms of colour-vision deficiency. Choosing one is the same discipline covered in colorblind-safe palettes for categorical data, applied to a sequential rather than categorical encoding. Pair the scale with a log or square-root count transform whenever a handful of cells dwarf the rest, or those few peaks compress every other cell into the same near-empty colour.

Edge cases & gotchas

  • Empty cells versus zero counts. Skip empty cells entirely rather than painting them the scale’s minimum colour, or a sparse plot fills with a wash that hides where data actually is.
  • A single dominant cell. One extreme bin sets the max and flattens everything else to near-zero colour; a log transform or a clipped percentile max restores contrast.
  • Cell size and zoom. Binning at a fixed data-space cell size makes cells grow and shrink on zoom; bin in screen pixels and re-aggregate per view so cell size stays constant on screen.
  • Points on cell borders. Consistent floor-based mapping avoids double-counting, but be explicit about the half-open interval so a point exactly on a boundary lands in one cell only.
  • Weighted points. If points carry a value (not just presence), accumulate the sum and a count per cell and divide for a mean-density heatmap; a plain increment answers a different question.
  • Antialiasing seams. Drawing adjacent fillRect cells with default antialiasing leaves faint gaps; disable image smoothing or overlap cells by a fraction of a pixel to close them.
Per-frame time vs the 16.7ms budget Measured-style frame durations with bars over budget marked in red. frame #ms/frame16.7ms budget (60fps)
Frame durations for points into a density heatmap against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

Frequently Asked Questions

When should I aggregate instead of downsample?

Aggregate when the data is a scattered cloud with no single path to trace — many points sharing similar x-values with different y-values, where the interesting quantity is how many points fall where. Downsample when the data is a connected series with one value per position, like a time series, where LTTB can keep a representative subset of real samples. The mark geometry decides: a cloud aggregates, a line downsamples.

Why does my colour scale choice matter so much?

Because a heatmap encodes its entire message in colour, and a non-uniform scale like rainbow makes equal count differences look unequal, inventing boundaries the data does not contain. A perceptually uniform sequential scale keeps equal steps looking equal and stays legible for colourblind viewers. Combined with a log or square-root transform for skewed counts, it keeps the density honest.

How big should each bin be?

Tie the cell size to a few screen pixels so the grid resolution matches what the display can resolve — around three to six pixels is a common sweet spot. Smaller cells reintroduce the noise you were trying to smooth and produce many single-point bins; larger cells blur genuine structure. Because it is screen-relative, re-aggregate on zoom so the on-screen cell size stays constant.