Dumbbell chart

덤벨 차트

Draws a category’s two values as two dots joined by a line, so the gap between two points in time — or two groups — reads directly as the line’s length.

Also known as: 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);

This chart answers "how much did it change, and in which direction, between before and after." Grouped bars can carry the same information — two bars side by side per category — but reading the change means subtracting two bar heights by eye. A dumbbell already draws that difference as the line's length, no subtraction required.

The two endpoints must be consistently color-coded — "before" and "after" always the same color, row after row — so scanning down the chart reliably shows which direction each row moved. Even when a row reverses (the "after" value ends up smaller), keep the dot colors fixed and let their left-right position, not their color, carry the direction.

The common mistake is leaving categories in input or alphabetical order — sorting by the amount of change (or by the final value) pulls "the biggest movers" to one end and the pattern jumps out immediately; unsorted, the same data looks like a random list. Rows where the two values are nearly equal end up with overlapping dots, so shrink the dot size or nudge them apart slightly in that case.

When to use

Use it to compare two points in time or two groups per category and emphasize the direction and size of change. For three or more values, use a slope chart or parallel coordinates instead.