Sankey diagram

생키 다이어그램

Shows quantity flowing from one state to the next as ribbon thickness — the chart for "where is it leaking, and how much."

Also known as: Flow diagram흐름도
···
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}
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 COLOR = { S1:'var(--accent)', S2:'var(--viz-4)', M1:'var(--accent)', M2:'var(--viz-5)', M3:'var(--viz-4)', T1:'var(--accent)', T2:'var(--viz-5)' };
var LABELPOS = { S1:'left', S2:'left', M1:'top', M2:'top', M3:'top', T1:'right', T2:'right' };

function splitRandom(total, n){
  var cuts=[];
  for (var i=0;i<n-1;i++) cuts.push(R(0,total));
  cuts.sort(function(a,b){ return a-b; });
  var parts=[], prev=0;
  cuts.forEach(function(c){ parts.push(Math.max(c-prev,0.6)); prev=c; });
  parts.push(Math.max(total-prev,0.6));
  return parts;
}
function genFlow(){
  var s1=R(30,54), s2=R(30,54);
  var s1p=splitRandom(s1,2), s2p=splitRandom(s2,2);
  var m1=s1p[0], m2=s1p[1]+s2p[0], m3=s2p[1];
  var m2p=splitRandom(m2,2);
  var t1=m1+m2p[0], t2=m2p[1]+m3;
  return {
    nodeTotals:{S1:s1,S2:s2,M1:m1,M2:m2,M3:m3,T1:t1,T2:t2},
    links:[
      {s:'S1',t:'M1',v:s1p[0]},{s:'S1',t:'M2',v:s1p[1]},
      {s:'S2',t:'M2',v:s2p[0]},{s:'S2',t:'M3',v:s2p[1]},
      {s:'M1',t:'T1',v:m1},{s:'M2',t:'T1',v:m2p[0]},
      {s:'M2',t:'T2',v:m2p[1]},{s:'M3',t:'T2',v:m3}
    ]
  };
}
var cur = genFlow();
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 ribbon(x0,y0t,y0b,x1,y1t,y1b){
  var xm=(x0+x1)/2;
  return 'M'+x0+','+y0t+' C'+xm+','+y0t+' '+xm+','+y1t+' '+x1+','+y1t+
    ' L'+x1+','+y1b+' C'+xm+','+y1b+' '+xm+','+y0b+' '+x0+','+y0b+' Z';
}

function draw(){
  svg.innerHTML = '';
  var padL=26, padR=26, padT=14, padB=14;
  var pw=W-padL-padR, ph=H-padT-padB;
  var nodeW = Math.max(6, Math.min(11, W*0.022));
  var grand = cur.nodeTotals.S1+cur.nodeTotals.S2;
  var gap = Math.min(8, ph*0.05);
  var maxGaps = 2; // widest column (M: 3 nodes) needs 2 gaps — size to that so no column overflows ph
  var scale = (ph - gap*maxGaps) / grand;
  var fs = Math.max(8, Math.min(11, W/110));

  var cols = [ { x: padL, ids:['S1','S2'] }, { x: padL+pw/2-nodeW/2, ids:['M1','M2','M3'] }, { x: W-padR-nodeW, ids:['T1','T2'] } ];
  var nodes = {};
  cols.forEach(function(col){
    var heights = col.ids.map(function(id){ return cur.nodeTotals[id]*scale; });
    var stackH = heights.reduce(function(a,b){ return a+b; },0) + gap*(col.ids.length-1);
    var y = padT + (ph-stackH)/2;
    col.ids.forEach(function(id,i){
      nodes[id] = { x: col.x, y:y, h: heights[i], outCur:y, inCur:y };
      y += heights[i]+gap;
    });
  });

  cur.links.forEach(function(link){
    var s = nodes[link.s], t = nodes[link.t];
    var thick = Math.max(link.v*scale, 0.5);
    var y0t=s.outCur, y0b=s.outCur+thick; s.outCur=y0b;
    var y1t=t.inCur, y1b=t.inCur+thick; t.inCur=y1b;
    var path = se('path');
    sa(path,{ d: ribbon(s.x+nodeW,y0t,y0b,t.x,y1t,y1b), fill: COLOR[link.s], 'fill-opacity':0.3 });
    svg.appendChild(path);
  });

  Object.keys(nodes).forEach(function(id){
    var n = nodes[id];
    var rect = se('rect');
    sa(rect,{ x:n.x, y:n.y, width:nodeW, height:Math.max(n.h,1), rx:2, fill:COLOR[id] });
    svg.appendChild(rect);
    var pos = LABELPOS[id];
    var lx = pos==='left'? n.x-4 : pos==='right'? n.x+nodeW+4 : n.x+nodeW/2;
    var ly = pos==='top'? n.y-4 : n.y+n.h/2+fs*0.32;
    var anchor = pos==='left'? 'end' : pos==='right'? 'start' : 'middle';
    var t = se('text');
    sa(t,{ x:lx, y:ly, 'text-anchor':anchor, fill:'var(--fg)', 'font-size':fs, 'font-weight':700 });
    t.textContent = id;
    svg.appendChild(t);
  });
}

function animateTo(target){
  var from = cur;
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/800);
    var e = 1-Math.pow(1-p,3);
    var mid = { nodeTotals:{}, links: from.links.map(function(l,i){ return { s:l.s, t:l.t, v: l.v+(target.links[i].v-l.v)*e }; }) };
    Object.keys(from.nodeTotals).forEach(function(k){ mid.nodeTotals[k] = from.nodeTotals[k]+(target.nodeTotals[k]-from.nodeTotals[k])*e; });
    cur = mid;
    draw();
    if (p<1) requestAnimationFrame(step); else cur = target;
  }
  requestAnimationFrame(step);
}

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

Nodes (vertical bars) are each stage's total; ribbons are the quantity flowing between them. Since ribbon thickness *is* the value, never exaggerate or shrink it independent of the number. It fits data where one total splits across paths — conversion funnels (visit → signup → purchase), budget flow (income → spending categories), energy flow.

Ribbons are usually colored by their source node, so "where did this come from" reads instantly. Once there are too many nodes and crossings (spaghetti), the diagram stops working — keep each column to 5-6 nodes or fewer.

Reordering nodes vertically can reduce or increase how much ribbons cross — the values don't change, but readability does a lot, so minimizing crossings is part of the layout job, not an afterthought.

When to use

Use it to show a total splitting across stages or paths — funnels, budgets, energy flow.