Beeswarm plot

비스웜 플롯

Plots each value as a dot on one axis, nudging overlapping dots sideways so every individual point stays visible while the swarm’s shape still reads as a distribution.

Also known as: 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);

A histogram bins values into bars, and a box plot compresses them into five numbers. A beeswarm does neither — every individual sample stays visible as its own dot, so you can see the actual sample count and confirm that values really cluster somewhere, rather than trusting a bar's illusion of it.

The layout algorithm is the whole trick: the axis position stays fixed by value, and only the perpendicular position moves to avoid overlap. Points are placed one at a time in value order, and each tries the first non-colliding offset in the sequence 0, then +radius, -radius, +2·radius, -2·radius, and so on against every point already placed. The result: crowded ranges spread the dots sideways into a wide swarm, and sparse ranges stay a thin single-file line — the width itself becomes the density encoding.

Past a few hundred or thousand points, that placement gets slow and the screen turns back into a smear — at that scale a histogram or violin plot works better. Shrinking the dot radius to squeeze in density is also a common mistake — do that enough and you lose the one thing a beeswarm exists for: seeing each individual sample.

When to use

Use it with tens to a few hundred samples where individual values and the overall distribution both matter. With far more samples, use a histogram or violin plot instead.