슬로프 차트

Slope chart

정확히 두 시점 사이의 값 변화를 기울어진 선 하나로 보여주는 차트. 오르는 선과 내리는 선이 한눈에 갈립니다.

다른 이름: Slopegraph기울기 차트
···
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}
.axis{stroke:var(--line);stroke-width:1}
.tick{fill:var(--muted);font-weight:600}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.bg-line{stroke:var(--muted);stroke-width:1.4;opacity:.38;fill:none}
.hl-line{stroke:var(--accent);stroke-width:2.6;fill:none}
.bg-dot{fill:var(--muted);opacity:.5}
.hl-dot{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 CATS = ['제품 A','제품 B','제품 C','제품 D','제품 E'];
var HL = 2;
function genVals(){ return CATS.map(function(){ return { a:R(18,88), b:R(18,88) }; }); }
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=54, padR=54, padT=16, padB=18;
  var pw=W-padL-padR, ph=H-padT-padB;
  var x0=padL, x1=W-padR;
  function Y(v){ return padT+ph-(v/100)*ph; }
  var fs = Math.max(8, Math.min(11, W/95));

  ['이전','이후'].forEach(function(lab,i){
    var t = se('text');
    sa(t,{x:i===0?x0:x1,y:H-4,'text-anchor':'middle',class:'tick','font-size':fs});
    t.textContent = lab;
    svg.appendChild(t);
  });

  cur.forEach(function(v,i){
    var isHL = i===HL;
    var y0=Y(v.a), y1=Y(v.b);
    var line = se('line');
    sa(line,{x1:x0,y1:y0,x2:x1,y2:y1,class:isHL?'hl-line':'bg-line'});
    svg.appendChild(line);
    [ [x0,y0], [x1,y1] ].forEach(function(p){
      var d = se('circle');
      sa(d,{cx:p[0],cy:p[1],r:isHL?3.4:2.2,class:isHL?'hl-dot':'bg-dot'});
      svg.appendChild(d);
    });
    if (isHL){
      var lab0 = se('text');
      sa(lab0,{x:x0-8,y:y0+fs*0.32,'text-anchor':'end',fill:'var(--fg)','font-size':fs,'font-weight':700});
      lab0.textContent = CATS[i];
      svg.appendChild(lab0);
      var lab1 = se('text');
      sa(lab1,{x:x1+8,y:y1+fs*0.32,'text-anchor':'start',fill:'var(--fg)','font-size':fs,'font-weight':700});
      lab1.textContent = Math.round(v.b);
      svg.appendChild(lab1);
    } else {
      var m = se('text');
      sa(m,{x:x1+8,y:y1+fs*0.32,'text-anchor':'start',class:'tick minor','font-size':fs-1});
      m.textContent = CATS[i];
      svg.appendChild(m);
    }
  });
}

function animate(target){
  var from = cur;
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/800);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(v,i){ var to=target[i]; return { a:v.a+(to.a-v.a)*e, b:v.b+(to.b-v.b)*e }; });
    draw();
    if (p<1) requestAnimationFrame(step); else cur = target;
  }
  requestAnimationFrame(step);
}

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

왼쪽에 시점 1, 오른쪽에 시점 2를 두고 같은 항목을 선 하나로 잇습니다. 선이 위로 가면 증가, 아래로 가면 감소, 기울기가 가파를수록 변화가 큽니다 — 막대 차트 두 개를 나란히 놓는 것보다 "무엇이 오르고 무엇이 내렸는지"가 훨씬 빨리 읽힙니다.

에드워드 터프티가 정리한 형식으로, 항목이 많아지면 선이 서로 겹치고 교차해서 알아보기 어려워집니다. 그래서 실무에서는 강조하고 싶은 항목 하나(또는 소수)만 색을 칠하고, 나머지는 회색으로 죽여서 배경 맥락으로만 남기는 방식을 씁니다.

시점이 3개 이상이면 슬로프 차트가 아니라 선 차트를 써야 합니다 — 이 형식은 딱 "두 시점 비교"에만 맞게 설계되어 있습니다.

언제 쓰나

정확히 두 시점 사이에서 여러 항목의 증감을 한눈에 비교하고, 그중 하나를 강조하고 싶을 때.