퍼널 차트

Funnel chart

연속된 단계를 거치며 줄어드는 값을 너비가 좁아지는 막대로 보여주는 차트. "어느 단계에서 가장 많이 새는가"에 답합니다.

다른 이름: Conversion funnel전환 퍼널
···
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}
.tick{fill:var(--muted);font-weight:600}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.f0{fill:color-mix(in srgb, var(--accent) 42%, var(--surface))}
.f1{fill:color-mix(in srgb, var(--accent) 61%, var(--surface))}
.f2{fill:color-mix(in srgb, var(--accent) 80%, var(--surface))}
.f3{fill:var(--accent)}
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 = ['방문','가입','장바구니','결제'];
function genVals(){
  var v = [100];
  for (var i=1;i<LABELS.length;i++) v.push(v[i-1]*R(0.46,0.82));
  return v;
}
var cur = genVals();
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=14, padR=14, padT=16, padB=6;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n = cur.length;
  var gap = ph*0.08;
  var rowH = (ph-gap*(n-1))/n;
  var fs = Math.max(8, Math.min(12, W/70));
  var maxV = cur[0];
  cur.forEach(function(v,i){
    var bw = Math.max(14,(v/maxV)*pw);
    var x = padL+(pw-bw)/2;
    var y = padT+i*(rowH+gap);
    var rect = se('rect');
    sa(rect,{x:x,y:y,width:bw,height:rowH,rx:4,class:'f'+i});
    svg.appendChild(rect);
    var lab = se('text');
    sa(lab,{x:W/2,y:y+rowH/2+fs*0.32,'text-anchor':'middle',fill:'var(--fg)','font-size':fs,'font-weight':700,stroke:'var(--surface)','stroke-width':3,'paint-order':'stroke'});
    lab.textContent = LABELS[i]+' '+Math.round(v);
    svg.appendChild(lab);
    if (i>0){
      var pct = se('text');
      sa(pct,{x:W/2,y:y-4,'text-anchor':'middle',class:'tick minor','font-size':fs-1});
      pct.textContent = Math.round(v/cur[i-1]*100)+'%';
      svg.appendChild(pct);
    }
  });
}

function animate(target){
  var from = cur.slice();
  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(v,i){ return v+(target[i]-v)*e; });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

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

전제가 하나 있습니다 — 단계가 실제로 순차적이어야 합니다(방문 → 가입 → 장바구니 → 결제처럼, 이전 단계를 거쳐야만 다음 단계에 갈 수 있는 과정). 순서를 마음대로 바꿔도 되는 범주라면 그냥 막대 차트입니다.

가장 중요한 숫자는 막대의 절대 너비가 아니라 단계 사이의 전환율(%)입니다 — 전체 대비 비율만 보면 "원래 이 정도는 빠져나가는 단계"인지 "이번에 유독 많이 샜다"인지 구분이 안 되므로, 인접 단계 간 비율을 반드시 함께 표기합니다.

색은 단계 순서를 그대로 드러내는 하나의 색상 농도 램프(오디널)를 씁니다 — 단계마다 색상 자체가 다르면 오히려 "이게 순서가 있는 흐름"이라는 사실이 묻힙니다.

언제 쓰나

방문→가입→결제처럼 실제로 순차적인 전환 과정에서 단계별 이탈을 보여줄 때.