Bullet chart

불릿 차트

Compresses "where are we, and is that good" into one thin row — a single measure bar, background quality bands, and a target tick mark.

Also known as: 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 designed it to replace speedometer-style gauge charts. A gauge spends a whole circular area on a single number — room enough to stack several KPIs side by side instead, all eaten by one gauge. A bullet chart packs the same information into one thin horizontal bar.

Three elements each carry a separate job. Shaded background bands ("poor / satisfactory / good") communicate qualitative range without needing a legend; a thick bar on top of them shows the actual measure; a single vertical tick marks a comparison point (a target, last year's figure). That lets you read two things in one glance: whether the bar has crossed the tick, and which band it currently sits in.

Using more than three bands is the common mistake — more bands need a legend again, undoing the whole point of reading it without one. Drawing the measure bar as thick as the bands is another — it needs to stay visibly thinner so the background (context) and the bar (the actual number) read as two separate layers.

When to use

Use it to stack several KPIs, each compared to a target, into a narrow space. If only one number needs emphasis, a KPI card is simpler.