생키 다이어그램

Sankey diagram

한 상태에서 다음 상태로 흘러가는 양을 리본의 두께로 보여주는 차트. "어디서 얼마나 새는가"에 답합니다.

다른 이름: 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);

노드(세로 막대)는 각 단계의 총량, 리본(띠)은 그 사이를 흐르는 양입니다. 리본 두께가 곧 값이므로 두께를 과장하거나 축소하지 않는 게 핵심입니다. 전환 퍼널(방문→가입→결제)이나 예산 흐름(수입→지출 항목), 에너지 흐름처럼 "총량이 여러 경로로 갈라지는" 데이터에 잘 맞습니다.

색은 보통 출발 노드를 기준으로 리본을 칠합니다 — 그래야 "이 리본이 어디서 왔는지"가 한눈에 보입니다. 노드가 많아지고 교차가 심해지면(스파게티) 오히려 안 보이게 되므로, 열(단계)당 노드는 5~6개 이하로 유지하세요.

노드의 세로 순서를 바꾸면 교차가 줄거나 늘 수 있습니다 — 값 자체는 그대로여도 "읽기 쉬운 정도"가 크게 달라지므로, 교차가 최소가 되는 순서를 찾는 것도 레이아웃의 일부입니다.

언제 쓰나

하나의 총량이 여러 단계·경로로 갈라지는 흐름(퍼널, 예산, 에너지)을 보여줄 때.