덤벨 차트

Dumbbell chart

한 범주의 두 값을 점 두 개와 그 사이를 잇는 선으로 표시해, 두 시점·두 그룹 사이의 변화량을 한눈에 보여주는 차트.

다른 이름: Connected dot plotBarbell chart
···
html
<div class="viz">
  <svg id="svg"></svg>
  <div class="legend" id="lg"></div>
</div>
css
:root{--viz-4:#e8a23a}
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
.legend{position:absolute;top:4px;right:6px;display:flex;gap:8px;font:600 9px/1 system-ui,sans-serif;color:var(--muted)}
.legend b{display:inline-flex;align-items:center;gap:3px;font-weight:600}
.legend i{width:7px;height:7px;border-radius:50%;display:inline-block}
js
var svg = document.getElementById('svg');
var lg = document.getElementById('lg');
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 CATS = ['서울','부산','대구','광주','대전'];
var GROUPS = [
  { name: '이전', color: 'var(--viz-4)' },
  { name: '이후', color: 'var(--accent)' }
];
lg.innerHTML = GROUPS.map(function(g){ return '<b><i style="background:'+g.color+'"></i>'+g.name+'</b>'; }).join('');

var cur = { before: CATS.map(function(){ return 40; }), after: CATS.map(function(){ return 60; }) };
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=34,padR=14,padT=10,padB=8;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=CATS.length;
  var rowH = ph/n;
  var fs = Math.max(8,Math.min(11,W/40));
  CATS.forEach(function(name,i){
    var y = padT+rowH*(i+0.5);
    var xa = padL+(cur.before[i]/100)*pw;
    var xb = padL+(cur.after[i]/100)*pw;
    var lab = se('text');
    sa(lab,{ x:padL-6, y:y+3, 'text-anchor':'end', fill:'var(--muted)', 'font-size':fs, 'font-weight':600 });
    lab.textContent = name;
    svg.appendChild(lab);
    var line = se('line');
    sa(line,{ x1:xa, y1:y, x2:xb, y2:y, stroke:'var(--line)', 'stroke-width':3, 'stroke-linecap':'round' });
    svg.appendChild(line);
    var da = se('circle');
    sa(da,{ cx:xa, cy:y, r:4.6, fill:'var(--viz-4)' });
    svg.appendChild(da);
    var db = se('circle');
    sa(db,{ cx:xb, cy:y, r:4.6, fill:'var(--accent)' });
    svg.appendChild(db);
  });
}

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

function nextTarget(){
  return { before: CATS.map(function(){ return R(15,85); }), after: CATS.map(function(){ return R(15,85); }) };
}

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

"이전과 이후가 얼마나, 어느 방향으로 달라졌는가"에 답하는 차트입니다. 같은 정보를 그룹 막대(같은 범주에 막대 두 개를 나란히)로도 그릴 수 있지만, 그룹 막대는 두 막대 끝의 높이 차이를 눈으로 빼야 변화량이 나오는 반면 덤벨은 그 차이가 이미 선의 길이로 그려져 있어 계산 없이 보입니다.

두 끝점은 반드시 다른 색으로 구분해야 합니다 — "이전" 점과 "이후" 점이 매 행마다 같은 규칙으로 칠해져야 위아래로 훑을 때 어느 쪽이 늘고 줄었는지 일관되게 읽힙니다. 값이 뒤집히는 행(이후가 이전보다 작아지는 경우)이 섞여 있어도 점 색은 그대로 유지하고 색이 아니라 점의 좌우 위치로 방향을 읽게 둡니다.

흔한 실수는 범주를 알파벳/입력 순서 그대로 두는 것입니다 — 변화량이나 최종값 기준으로 정렬하면 "가장 많이 변한 것"이 위나 아래로 몰려 패턴이 바로 보이는데, 정렬하지 않으면 같은 데이터도 무작위 나열처럼 보입니다. 두 값이 거의 같은 행은 점 두 개가 겹쳐 보이므로, 그럴 땐 점 크기를 줄이거나 살짝 어긋나게 그려야 합니다.

언제 쓰나

범주별로 두 시점이나 두 그룹의 값을 비교하며 변화의 방향과 크기를 강조하고 싶을 때. 세 개 이상의 값을 비교해야 하면 슬로프 차트나 평행 좌표를 쓰세요.