불릿 차트

Bullet chart

실적 바 하나, 배경 품질 밴드, 목표 눈금선만으로 "지금 어디에 있고 그게 좋은가"를 한 줄에 압축하는 차트.

다른 이름: Bullet graph
···
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}
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 KPI = [
  { name:'매즈', bands:[40,75,100] },
  { name:'신긌가입', bands:[30,60,100] },
  { name:'만족도', bands:[50,80,100] }
];
var cur = KPI.map(function(){ return { actual:50, target:70 }; });
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=64,padR=16,padT=10,padB=10;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=KPI.length;
  var rowH=ph/n;
  var barH=Math.min(14,rowH*0.34);
  var fs=Math.max(8,Math.min(10,W/56));
  var bandShades=[0.16,0.28,0.42];
  KPI.forEach(function(k,i){
    var y=padT+rowH*(i+0.5);
    var lab=se('text');
    sa(lab,{ x:padL-8, y:y+3, 'text-anchor':'end', fill:'var(--muted)', 'font-size':fs, 'font-weight':600 });
    lab.textContent = k.name;
    svg.appendChild(lab);
    var prev=0;
    k.bands.forEach(function(b,bi){
      var x0=padL+(prev/100)*pw, x1=padL+(b/100)*pw;
      var band=se('rect');
      sa(band,{ x:x0, y:y-barH*1.15, width:x1-x0, height:barH*2.3, fill:'var(--muted)', 'fill-opacity':bandShades[bi] });
      svg.appendChild(band);
      prev=b;
    });
    var av=cur[i].actual;
    var bar=se('rect');
    sa(bar,{ x:padL, y:y-barH/2, width:(av/100)*pw, height:barH, fill:'var(--accent)' });
    svg.appendChild(bar);
    var tx=padL+(cur[i].target/100)*pw;
    var tick=se('line');
    sa(tick,{ x1:tx, y1:y-barH*1.3, x2:tx, y2:y+barH*1.3, stroke:'var(--fg)', 'stroke-width':2.4 });
    svg.appendChild(tick);
  });
}

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

function nextTarget(){
  return KPI.map(function(){ return { actual:R(15,98), target:R(55,92) }; });
}

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

스티븐 퓨(Stephen Few)가 자동차 속도계 모양의 게이지 차트를 대신하려고 고안했습니다. 게이지는 숫자 하나를 보여주는 데 원형 공간을 통째로 씁니다 — 그 정도 면적이면 KPI 여러 개를 나란히 쌓을 수 있는데, 게이지 하나가 그 자리를 다 차지해버립니다. 불릿 차트는 같은 정보를 가로로 얇은 막대 하나에 담아 압축합니다.

세 요소가 각자 역할을 나눕니다. 배경의 진하기 다른 띠(밴드)는 "나쁨/보통/좋음" 같은 정성적 구간을 범례 없이 알려주고, 그 위의 두꺼운 막대는 실제 실적값을, 세로 눈금선 하나는 비교 대상(목표치, 전년 동기 등)을 나타냅니다. 그래서 "실적 막대가 눈금선을 넘었는가"와 "그 지점이 밴드 중 어디에 속하는가"를 동시에 한 번에 읽을 수 있습니다.

밴드를 3개보다 많이 쓰는 게 흔한 실수입니다 — 그러면 색이 다시 범례를 요구하게 되어 애초에 범례 없이 읽히게 하려던 목적이 사라집니다. 실적 막대를 밴드만큼 두껍게 그리는 것도 실수입니다 — 밴드보다 얇아야 "배경(맥락)과 값(수치)"이라는 두 층이 시각적으로 구분됩니다.

언제 쓰나

실적을 목표·기준과 비교하는 KPI를 여러 개 좁은 공간에 나란히 쌓고 싶을 때. 숫자 하나만 강조하면 되면 KPI 카드가 더 간단합니다.