박스 플롯

Box plot

최솟값·1사분위·중앙값·3사분위·최댓값 다섯 숫자로 분포의 중심과 퍼짐을 요약하는 차트.

다른 이름: Box-and-whisker plot상자 수염 그림
···
html
<div class="viz">
  <svg id="svg"></svg>
</div>
css
:root{--viz-4:#e8a23a;--viz-5:#4098d7}
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
.axis{stroke:var(--line);stroke-width:1}
.tick{fill:var(--muted);font-weight:600}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.wk{stroke-width:1.4}
.med{stroke-width:2.4}
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 = ['A','B','C','D'];
var COLORS = ['var(--accent)','var(--viz-4)','var(--accent-3)','var(--viz-5)'];

function genStats(){
  var median = R(35,68);
  var iqr = R(10,26);
  var q1 = Math.max(2, median-iqr/2);
  var q3 = Math.min(98, median+iqr/2);
  var lo = Math.max(0, q1-R(5,16));
  var hi = Math.min(100, q3+R(5,16));
  return { lo:lo, q1:q1, med:median, q3:q3, hi:hi };
}
var cur = GROUPS.map(genStats);
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=24, padR=10, padT=14, padB=20;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=GROUPS.length;
  var fs = Math.max(8, Math.min(12, W/72));
  var gap = pw*0.12/n;
  var bw = Math.min(38,(pw-gap*(n-1))/n);
  var totalW = bw*n+gap*(n-1);
  var startX = padL+(pw-totalW)/2;
  function Y(v){ return padT+ph-(v/100)*ph; }

  var base = se('line');
  sa(base,{x1:padL,y1:H-padB,x2:W-padR,y2:H-padB,class:'axis'});
  svg.appendChild(base);

  cur.forEach(function(s,i){
    var x = startX+i*(bw+gap);
    var cx = x+bw/2;
    var color = COLORS[i];
    var wk = se('line');
    sa(wk,{x1:cx,y1:Y(s.lo),x2:cx,y2:Y(s.hi),class:'wk',stroke:color});
    svg.appendChild(wk);
    [s.lo,s.hi].forEach(function(v){
      var cap = se('line');
      sa(cap,{x1:cx-bw*0.22,y1:Y(v),x2:cx+bw*0.22,y2:Y(v),class:'wk',stroke:color});
      svg.appendChild(cap);
    });
    var box = se('rect');
    sa(box,{x:x,y:Y(s.q3),width:bw,height:Math.max(Y(s.q1)-Y(s.q3),1),rx:2,fill:color,'fill-opacity':0.22,stroke:color,'stroke-width':1.6});
    svg.appendChild(box);
    var med = se('line');
    sa(med,{x1:x,y1:Y(s.med),x2:x+bw,y2:Y(s.med),class:'med',stroke:color});
    svg.appendChild(med);
    var cat = se('text');
    sa(cat,{x:cx,y:H-padB+14,'text-anchor':'middle',class:'tick','font-size':fs-1});
    cat.textContent = GROUPS[i];
    svg.appendChild(cat);
  });
}

function animate(target){
  var from = cur;
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/750);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(s,i){
      var to = target[i];
      var m = {};
      ['lo','q1','med','q3','hi'].forEach(function(k){ m[k] = s[k]+(to[k]-s[k])*e; });
      return m;
    });
    draw();
    if (p<1) requestAnimationFrame(step); else cur = target;
  }
  requestAnimationFrame(step);
}

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
setInterval(function(){ animate(GROUPS.map(genStats)); }, 3700);

상자의 아래위 변이 1사분위(Q1)와 3사분위(Q3), 즉 데이터의 가운데 50%가 들어있는 구간입니다. 상자 안의 선이 중앙값, 위아래로 뻗은 수염이 이상치를 제외한 나머지 범위입니다. 히스토그램 하나로는 여러 그룹을 나란히 놓기 부담스러운데, 박스 플롯은 그룹을 옆으로 쭉 늘어놓고 비교하기 좋습니다.

가장 큰 함정은 "같은 박스가 다른 분포일 수 있다"는 점입니다 — 봉우리가 하나인 분포와 두 개인(쌍봉) 분포가 5개 숫자만 보면 똑같이 생길 수 있습니다. 분포의 실제 모양까지 보고 싶다면 바이올린 플롯이나 점을 흩뿌린 스트립 플롯을 함께 씁니다.

그룹이 2개뿐이면 박스 플롯보다 값을 직접 보여주는 게 나을 때도 많습니다 — 이 차트의 진짜 값어치는 그룹이 4개 이상일 때 나옵니다.

언제 쓰나

여러 그룹의 분포(중심·퍼짐·이상치)를 한 줄로 나란히 비교하고 싶을 때.