헥스빈

Hexbin plot

점이 너무 많아 뭉개지는 산점도를 육각형 격자로 나눠, 칸마다 점의 개수(밀도)를 색으로 칠하는 차트.

다른 이름: 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);

산점도에 점이 수천 개 찍히면 점들이 겹쳐 그냥 잉크 얼룩(overplotting)이 됩니다. 헥스빈은 평면을 육각형 칸으로 나누고 각 칸에 떨어진 점의 개수를 세어 색의 진하기로 바꿔줍니다 — 점 하나하나의 정확한 위치는 포기하는 대신 "어디에 점이 몰려 있는가"라는 밀도 정보를 정확하게 남깁니다.

정사각형이 아니라 육각형으로 나누는 이유는 기하학적입니다 — 정육각형은 여섯 이웃 칸 모두와 같은 거리로 붙어 있어서(정사각형은 대각선 이웃이 더 멀다) 격자 방향에 따라 패턴이 한쪽으로 쏠려 보이는 격자 편향이 적습니다. 그래서 등방성(모든 방향에 균일한) 밀도를 보여줄 때 육각형이 사각형보다 실제로 더 정확합니다.

칸 크기(반지름)를 잘못 잡으면 정보가 왜곡됩니다 — 너무 크면 서로 다른 두 군집이 하나로 뭉개지고, 너무 작으면 칸마다 점이 0~1개뿐이라 원래의 흩뿌려진 산점도와 다를 바 없어집니다. 색 스케일도 캘린더 히트맵과 같은 원칙입니다 — 밀도는 크기 비교이지 범주가 아니므로 단일 색조의 순차 스케일을 써야 합니다.

언제 쓰나

점이 너무 많아 산점도가 잉크 얼룩처럼 뭉개질 때. 점이 수백 개 이하로 적으면 개별 값이 보이는 산점도나 비스웜이 낫습니다.