Waterfall chart

폭포 차트

Chains floating bars from a starting value to an ending value — the chart for "which items actually drove this change."

Also known as: Bridge chart브리지 차트
···
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}
.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}}
.conn{stroke:var(--muted);stroke-width:1;opacity:.5}
.up{fill:var(--accent)}
.down{fill:var(--accent-2)}
.tot{fill:var(--muted)}
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 LABELS = ['시작','1월','2월','3월','4월','합계'];

function genSteps(){
  var start = R(38,55);
  var d1=R(-14,20), d2=R(-14,20), d3=R(-18,16), d4=R(-10,22);
  var cum = [start, start+d1, start+d1+d2, start+d1+d2+d3, start+d1+d2+d3+d4];
  var total = cum[4];
  return [
    { from:0, to:start, kind:'tot' },
    { from:cum[0], to:cum[1], kind:d1>=0?'up':'down' },
    { from:cum[1], to:cum[2], kind:d2>=0?'up':'down' },
    { from:cum[2], to:cum[3], kind:d3>=0?'up':'down' },
    { from:cum[3], to:cum[4], kind:d4>=0?'up':'down' },
    { from:0, to:total, kind:'tot' }
  ];
}
var cur = genSteps();
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=18, padB=22;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=cur.length;
  var fs = Math.max(8, Math.min(11, W/95));
  var gap = pw*0.1/n;
  var bw = Math.min(34,(pw-gap*(n-1))/n);
  var totalW = bw*n+gap*(n-1);
  var startX = padL+(pw-totalW)/2;
  var maxV = 100;
  function Y(v){ return padT+ph-(Math.max(0,Math.min(100,v))/maxV)*ph; }

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

  var prevX2 = null, prevY = null;
  cur.forEach(function(s,i){
    var x = startX+i*(bw+gap);
    var top = Y(Math.max(s.from,s.to)), bot = Y(Math.min(s.from,s.to));
    if (prevX2 !== null){
      var conn = se('line');
      sa(conn,{x1:prevX2,y1:prevY,x2:x,y2:prevY,class:'conn'});
      svg.appendChild(conn);
    }
    var rect = se('rect');
    sa(rect,{x:x,y:top,width:bw,height:Math.max(bot-top,1),rx:3,class:s.kind});
    svg.appendChild(rect);
    var lab = se('text');
    sa(lab,{x:x+bw/2,y:Math.max(top-5,padT+fs),'text-anchor':'middle',class:'val','font-size':fs});
    lab.textContent = i===0 || i===cur.length-1 ? Math.round(s.to) : (s.to>=s.from?'+':'')+Math.round(s.to-s.from);
    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 = LABELS[i];
    svg.appendChild(cat);
    prevX2 = x+bw; prevY = Y(s.to);
  });
}

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]; return { from:s.from+(to.from-s.from)*e, to:s.to+(to.to-s.to)*e, kind:to.kind }; });
    draw();
    if (p<1) requestAnimationFrame(step); else cur = target;
  }
  requestAnimationFrame(step);
}

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

Bars don't start at zero — each one floats from wherever the running total left off. Increases rise, decreases fall, and the next bar always continues from where the last one ended, so you can visually decompose what filled the gap between a start value and an end value. Classic uses: a revenue bridge (last year → this year, broken into contributing items), or a profit/loss breakdown.

The start bar and the ending total bar are the only "real" bars rooted at zero — everything else floats. Color those two differently so it's never ambiguous which bar is an absolute value and which are deltas. Increases and decreases also get their own colors.

Thin connector lines between bars make the "previous bar's end = next bar's start" relationship explicit. Drop them and the bars start looking unrelated, and the flow gets lost.

When to use

Use it to explain the gap between a start and end value as a sequence of contributing increases and decreases.