Hexbin plot

헥스빈

Splits an over-plotted scatter into a hexagonal grid and colors each cell by how many points fall inside — density instead of a smear of dots.

Also known as: Hexagonal binning
···
html
<div class="viz">
  <svg id="svg"></svg>
</div>
css
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
js
var svg = document.getElementById('svg');
var NS = 'http://www.w3.org/2000/svg';
function se(t){ return document.createElementNS(NS,t); }
function sa(e,o){ for (var k in o) e.setAttribute(k,o[k]); }
function R(a,b){ return a + Math.random()*(b-a); }

var W=300, H=200;
var CENTERS = [];

function hexPath(cx,cy,r){
  var s=[];
  for (var k=0;k<6;k++){
    var a = Math.PI/180*(60*k-30);
    s.push((cx+r*Math.cos(a)).toFixed(1)+','+(cy+r*Math.sin(a)).toFixed(1));
  }
  return 'M'+s.join('L')+'Z';
}

function rebuild(){
  var pw=W-16, ph=H-16;
  var hr = Math.max(6, Math.min(15, pw/15));
  var w = hr*Math.sqrt(3);
  var rows = Math.ceil(ph/(hr*1.5))+1;
  var cols = Math.ceil(pw/w)+1;
  var arr=[];
  for (var row=0; row<rows; row++){
    for (var col=0; col<cols; col++){
      var x = col*w + (row%2? w/2:0);
      var y = row*hr*1.5;
      if (x<=pw+w*0.5 && y<=ph+hr*0.5) arr.push({ x:x, y:y, nx:x/pw, ny:y/ph, r:hr*0.95 });
    }
  }
  CENTERS = arr;
}

function measure(){
  var r = svg.getBoundingClientRect();
  W = Math.max(r.width,10); H = Math.max(r.height,10);
  sa(svg,{ viewBox: '0 0 ' + W + ' ' + H });
  rebuild();
}

function fieldAt(x,y,blobs){
  var v=0;
  for (var i=0;i<blobs.length;i++){
    var b=blobs[i], dx=x-b.x, dy=y-b.y;
    v += b.amp*Math.exp(-(dx*dx+dy*dy)/(2*b.s*b.s));
  }
  return v;
}

var BLOBS_CUR = [ { x:0.35, y:0.4, amp:1, s:0.22 }, { x:0.65, y:0.6, amp:0.8, s:0.2 } ];

function draw(){
  svg.innerHTML = '';
  var padL=8, padT=8;
  var vals = CENTERS.map(function(c){ return fieldAt(c.nx,c.ny,BLOBS_CUR); });
  var maxV = Math.max.apply(null, vals.concat([0.4]));
  CENTERS.forEach(function(c,i){
    var v = vals[i]/maxV;
    if (v<0.04) return;
    var h = se('path');
    sa(h,{ d: hexPath(padL+c.x, padT+c.y, c.r), fill:'var(--accent)', 'fill-opacity': 0.08+0.9*v });
    svg.appendChild(h);
  });
}

function animate(target){
  var from = BLOBS_CUR.map(function(b){ return { x:b.x, y:b.y, amp:b.amp, s:b.s }; });
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/900);
    var e = 1-Math.pow(1-p,3);
    BLOBS_CUR = from.map(function(b,i){
      return { x:b.x+(target[i].x-b.x)*e, y:b.y+(target[i].y-b.y)*e, amp:b.amp+(target[i].amp-b.amp)*e, s:b.s+(target[i].s-b.s)*e };
    });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextBlobs(){
  return [
    { x:R(0.2,0.45), y:R(0.2,0.55), amp:R(0.7,1.1), s:R(0.16,0.26) },
    { x:R(0.55,0.82), y:R(0.42,0.78), amp:R(0.55,1), s:R(0.14,0.24) }
  ];
}

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
animate(nextBlobs());
setInterval(function(){ animate(nextBlobs()); }, 3600);

With thousands of points, a scatter plot's dots overlap into a smear of ink — overplotting. A hexbin divides the plane into hexagonal cells and counts how many points land in each, turning that count into color intensity. It gives up each point's exact position in exchange for an accurate read on where points are actually dense.

Hexagons over squares is a geometric choice: a regular hexagon sits the same distance from all six neighbors (a square's diagonal neighbors are farther), so it introduces less directional grid bias — patterns don't skew toward looking stronger along one axis just because of how the grid is oriented. That makes hexagons genuinely more accurate than squares for showing isotropic (equal-in-every-direction) density.

Getting the cell radius wrong distorts the picture either way — too large and two separate clusters blur into one; too small and most cells hold zero or one point, which is really just the original scatter plot again. The color scale follows the same rule as a calendar heatmap: density is a magnitude, not a category, so it needs a single-hue sequential scale.

When to use

Use it once a scatter plot has so many points it turns into an ink blot. With a few hundred points or fewer, a plain scatter plot or beeswarm — which keeps individual values visible — works better.