누적 막대 차트

Stacked bar chart

한 막대 안에 여러 세그먼트를 쌓아 전체 합과 구성 비율을 동시에 보여주는 차트.

다른 이름: Stacked column chart
···
html
<div class="viz">
  <svg id="svg"></svg>
  <div class="legend" id="lg"></div>
</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}
.val{fill:var(--fg);font-weight:700}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.legend{position:absolute;top:4px;right:6px;display:flex;gap:8px;font:600 9px/1 system-ui,sans-serif;color:var(--muted)}
.legend b{display:inline-flex;align-items:center;gap:3px;font-weight:600}
.legend i{width:7px;height:7px;border-radius:2px;display:inline-block}
js
var svg = document.getElementById('svg');
var lg = document.getElementById('lg');
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 CATS = ['1분기','2분기','3분기','4분기'];
var SERIES = [
  { name: '온라인', color: 'var(--accent)' },
  { name: '매장', color: 'var(--viz-4)' },
  { name: '제휴', color: 'var(--viz-5)' }
];
lg.innerHTML = SERIES.map(function(s){ return '<b><i style="background:'+s.color+'"></i>'+s.name+'</b>'; }).join('');

var cur = CATS.map(function(){ return SERIES.map(function(){ return 0; }); });
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=26, padR=10, padT=20, padB=22;
  var pw=W-padL-padR, ph=H-padT-padB;
  var maxV=100, n=CATS.length;
  var fs = Math.max(8, Math.min(12, W/72));
  var gap = pw*0.2/n;
  var bw = Math.min(46,(pw-gap*(n-1))/n);
  var totalW = bw*n+gap*(n-1);
  var startX = padL+(pw-totalW)/2;
  var seg = 2;

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

  CATS.forEach(function(c,i){
    var vals = cur[i];
    var total = vals.reduce(function(a,b){ return a+b; },0);
    var x = startX+i*(bw+gap);
    var y = H-padB;
    vals.forEach(function(v,si){
      var segH = Math.max((v/maxV)*ph - seg, 0.5);
      y -= segH;
      var rect = se('rect');
      sa(rect,{x:x,y:y,width:bw,height:segH,rx:2.5,ry:2.5,fill:SERIES[si].color});
      svg.appendChild(rect);
      y -= seg;
    });
    var lab = se('text');
    sa(lab,{x:x+bw/2,y:Math.max(y-3,padT+fs),'text-anchor':'middle',class:'val','font-size':fs});
    lab.textContent = Math.round(total);
    svg.appendChild(lab);
    var cat = se('text');
    sa(cat,{x:x+bw/2,y:H-padB+14,'text-anchor':'middle',class:'tick','font-size':fs-1});
    cat.textContent = c;
    svg.appendChild(cat);
  });
}

function animate(target){
  var from = cur.map(function(a){ return a.slice(); });
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/700);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(row,i){ return row.map(function(v,si){ return v+(target[i][si]-v)*e; }); });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextTarget(){
  return CATS.map(function(){ return SERIES.map(function(){ return R(14,34); }); });
}

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

막대 전체 높이는 합계를, 세그먼트 크기는 구성 비율을 보여줍니다. 잘 맞는 건 "전체 크기 비교 + 대략적인 구성"이고, 잘 안 맞는 건 "중간에 있는 세그먼트끼리 비교"입니다 — 맨 아래 세그먼트만 공통 기준선(0)을 공유하고, 그 위 세그먼트들은 시작점이 막대마다 달라서 눈으로 길이를 비교할 수 없습니다.

세그먼트 사이에는 테두리 대신 서지스 색(배경색)으로 2px 간격을 둬서 구분합니다 — 선을 그으면 그 자체가 잉크가 되어 데이터보다 시끄러워집니다. 범례는 시리즈가 2개 이상이면 항상 필요합니다.

특정 세그먼트 하나의 값 변화를 시계열로 추적하고 싶다면 누적 막대보다 그룹 막대(bar가 나란히)나 작은 배수(small multiples)가 더 정확합니다.

언제 쓰나

전체 합계와 대략적인 구성비를 함께 보여주고 싶을 때. 한 구성요소만 정밀 비교하려면 그룹 막대를 쓰세요.