비스웜 플롯

Beeswarm plot

값 하나를 축 위의 점으로 찍되, 점끼리 겹치지 않도록 옆으로 밀어내 개별 데이터와 전체 분포 모양을 동시에 보여주는 차트.

다른 이름: Swarm plotJittered dot plot
···
html
<div class="viz">
  <svg id="svg"></svg>
</div>
css
:root{--viz-4:#e8a23a}
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 GROUPS = [
  { name: '대조군', color: 'var(--viz-4)' },
  { name: '실험군', color: 'var(--accent)' }
];
var NP = 16;
var pts = [];
GROUPS.forEach(function(g,gi){
  for (var i=0;i<NP;i++) pts.push({ g:gi, v:50 });
});

function sampleBell(mean,spread){
  var s=0; for (var k=0;k<4;k++) s+=Math.random();
  return Math.max(2,Math.min(98, mean + (s/4-0.5)*2*spread));
}

function nextValues(){
  var out=[];
  GROUPS.forEach(function(){
    var mean = R(30,70), spread = R(24,46);
    for (var i=0;i<NP;i++) out.push(sampleBell(mean,spread));
  });
  return out;
}

var OFFSETS = (function(){ var a=[0]; for (var k=1;k<=30;k++){ a.push(k); a.push(-k); } return a; })();

function layoutSwarm(items, r){
  var order = items.slice().sort(function(a,b){ return a.px-b.px; });
  var placed = [];
  order.forEach(function(p){
    var chosen = 0;
    for (var oi=0; oi<OFFSETS.length; oi++){
      var cand = OFFSETS[oi]*r*1.08;
      var ok = true;
      for (var j=0;j<placed.length;j++){
        var q = placed[j];
        var dx = p.px-q.px, dy = cand-q.py;
        if (dx*dx+dy*dy < (r*2.02)*(r*2.02)){ ok=false; break; }
      }
      if (ok){ chosen=cand; break; }
    }
    p.py = chosen;
    placed.push(p);
  });
}

var W=300, H=200;
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 });
}

function draw(){
  svg.innerHTML = '';
  var padL=32,padR=14,padT=18,padB=16;
  var pw=W-padL-padR, ph=H-padT-padB;
  var rowY = [padT+ph*0.28, padT+ph*0.78];
  var r = Math.max(2.2, Math.min(3.6, W/110));
  GROUPS.forEach(function(g,gi){
    var items = pts.filter(function(p){ return p.g===gi; });
    items.forEach(function(p){ p.px = padL+(p.v/100)*pw; });
    layoutSwarm(items, r);
    items.forEach(function(p){ p.cx = p.px; p.cy = rowY[gi]+p.py; });
    var lab = se('text');
    sa(lab,{ x:padL-6, y:rowY[gi]+3, 'text-anchor':'end', fill:'var(--muted)', 'font-size':9, 'font-weight':700 });
    lab.textContent = g.name;
    svg.appendChild(lab);
  });
  var axis = se('line');
  sa(axis,{ x1:padL, y1:H-4, x2:W-padR, y2:H-4, stroke:'var(--line)', 'stroke-width':1 });
  svg.appendChild(axis);
  pts.forEach(function(p){
    var c = se('circle');
    sa(c,{ cx:p.cx, cy:p.cy, r:r, fill:GROUPS[p.g].color, 'fill-opacity':0.85 });
    svg.appendChild(c);
  });
}

function animate(targetVals){
  var from = pts.map(function(p){ return p.v; });
  var t0=performance.now();
  function step(t){
    var pr=Math.min(1,(t-t0)/850);
    var e=1-Math.pow(1-pr,3);
    pts.forEach(function(p,i){ p.v = from[i]+(targetVals[i]-from[i])*e; });
    draw();
    if (pr<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

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

히스토그램은 값을 구간으로 묶어 막대로 뭉개고, 박스플롯은 다섯 숫자로 요약합니다. 비스웜은 둘 다 하지 않고 표본 하나하나를 점으로 남깁니다 — 그래서 표본 수가 몇 개인지, 특정 구간에 값이 정말 몰려 있는지(막대의 착시가 아니라 점의 밀도 자체)를 그대로 확인할 수 있습니다.

핵심은 배치 알고리즘입니다. 값으로 정해지는 위치(축 방향)는 고정하고, 점이 겹치면 축과 수직 방향으로 밀어냅니다 — 알파벳처럼 값 순서대로 점을 하나씩 넣으면서, 이미 놓인 점들과 원이 겹치지 않는 첫 번째 위치(0, 그다음 +반지름, -반지름, +2반지름…)를 찾는 방식입니다. 그 결과 값이 몰린 구간은 점들이 옆으로 퍼져 폭이 넓어지고, 값이 드문 구간은 한 줄로 남습니다 — 폭 자체가 밀도를 보여줍니다.

점이 수백~수천 개로 많아지면 배치 계산이 느려지고 화면도 다시 뭉개지므로, 그 규모에서는 히스토그램이나 바이올린 플롯이 낫습니다. 점 반지름을 너무 작게 잡는 것도 흔한 실수입니다 — 밀도를 표현하려고 점을 줄이면 정작 "표본 하나하나가 보인다"는 비스웜의 존재 이유가 사라집니다.

언제 쓰나

표본 수가 수십~수백 개이고 개별 값과 분포 모양을 동시에 보여주고 싶을 때. 표본이 훨씬 많으면 히스토그램이나 바이올린 플롯을 쓰세요.