선 차트

Line chart

연속된 시점의 값을 점으로 찍고 이어서 추세를 보여주는 차트. "어떻게 변해왔나"에 답합니다.

다른 이름: Line 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}
.axis{stroke:var(--line);stroke-width:1}
.tick{fill:var(--muted);font-weight:600}
.val{fill:var(--fg);font-weight:700}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.ln{fill:none;stroke:var(--accent);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
.dot{fill:var(--accent)}
.ring{fill:var(--surface)}
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 N = 9;
var cur = new Array(N).fill(50);
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=26, padR=14, padT=16, padB=20;
  var pw=W-padL-padR, ph=H-padT-padB;
  var fs = Math.max(8, Math.min(12, W/85));
  var pts = cur.map(function(v,i){
    return { x: padL + (pw*i)/(N-1), y: padT + ph - (v/100)*ph };
  });

  [0,100].forEach(function(v){
    var y = padT + ph - (v/100)*ph;
    var g = se('line');
    sa(g,{x1:padL,y1:y,x2:W-padR,y2:y,class:'axis' + (v===100?' minor':'')});
    svg.appendChild(g);
    var t = se('text');
    sa(t,{x:padL-5,y:y+3,'text-anchor':'end',class:'tick minor','font-size':fs-1});
    t.textContent = v;
    svg.appendChild(t);
  });

  var d = pts.map(function(p,i){ return (i===0?'M':'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1); }).join(' ');
  var path = se('path');
  sa(path,{d:d,class:'ln'});
  svg.appendChild(path);

  pts.forEach(function(p,i){
    if (i%2===0 || i===pts.length-1){
      var ring = se('circle');
      sa(ring,{cx:p.x,cy:p.y,r:4.5,class:'ring minor'});
      svg.appendChild(ring);
      var dot = se('circle');
      sa(dot,{cx:p.x,cy:p.y,r:2.6,class:'dot minor'});
      svg.appendChild(dot);
    }
  });

  var last = pts[pts.length-1];
  var ring2 = se('circle');
  sa(ring2,{cx:last.x,cy:last.y,r:5,class:'ring'});
  svg.appendChild(ring2);
  var dot2 = se('circle');
  sa(dot2,{cx:last.x,cy:last.y,r:3.2,class:'dot'});
  svg.appendChild(dot2);
  var lab = se('text');
  sa(lab,{x:last.x-6,y:last.y-8,'text-anchor':'end',class:'val','font-size':fs});
  lab.textContent = Math.round(cur[cur.length-1]);
  svg.appendChild(lab);
}

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

function nextTarget(){
  var v = 50, out = [];
  for (var i=0;i<N;i++){ v = Math.max(10,Math.min(92, v + R(-22,22))); out.push(v); }
  return out;
}

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

막대가 크기 비교라면 선은 추세입니다. 선의 기울기가 변화율을 나타내므로, 가로세로 비율을 심하게 늘리거나 줄이면 같은 데이터도 완만하거나 급격하게 보이게 조작할 수 있습니다(뱅킹 45도 관례를 참고하되, 대개는 세로 방향으로 평균 기울기가 45도 근처가 되게 비율을 잡습니다).

선은 2px 정도로 얇게, 끝점에는 값을 직접 라벨로 답니다 — 모든 점에 숫자를 붙이면 오히려 아무도 안 읽습니다. 시리즈가 3~4개를 넘어가면 선끼리 교차하며 스파게티가 되므로, 그럴 땐 한 시리즈만 강조하고 나머지를 회색으로 죽이거나 스몰 멀티플로 쪼갭니다.

y축을 0부터 시작할지는 막대와 다릅니다 — 선은 "변화의 모양"이 핵심이라 값 자체의 절대 크기가 중요하지 않다면 0에서 시작하지 않아도 괜찮습니다. 다만 그 사실을 라벨로 명시해야 합니다.

언제 쓰나

시간에 따른 연속적인 변화를 보여줄 때. 범주 비교가 목적이면 막대가 낫습니다.