평행 좌표

Parallel coordinates

여러 변수를 나란한 수직 축으로 늘어놓고, 한 항목의 값들을 축을 가로지르는 하나의 꺾은선으로 잇는 차트.

다른 이름: Parallel coordinates plot
···
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}
.axisline{stroke:var(--line);stroke-width:1}
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 AXES = ['가격','성능','디자인','내구성','A/S'];
var NITEM = 6;
var cur = [];
for (var i=0;i<NITEM;i++) cur.push(AXES.map(function(){ return 0.5; }));
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=14,padR=14,padT=14,padB=20;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=AXES.length;
  var fs = Math.max(7,Math.min(10,W/48));
  var xs = AXES.map(function(_,k){ return padL + pw*k/(n-1); });
  xs.forEach(function(x,k){
    var line = se('line');
    sa(line,{ x1:x,y1:padT,x2:x,y2:padT+ph,class:'axisline' });
    svg.appendChild(line);
    var lab = se('text');
    sa(lab,{ x:x,y:padT+ph+13,'text-anchor':'middle',fill:'var(--muted)','font-size':fs,'font-weight':600 });
    lab.textContent = AXES[k];
    svg.appendChild(lab);
  });
  for (var i=NITEM-1;i>=0;i--){
    var pts = cur[i].map(function(v,k){ return xs[k].toFixed(1)+','+(padT+(1-v)*ph).toFixed(1); });
    var d = 'M'+pts.join(' L');
    var isMain = i===0;
    var pl = se('path');
    sa(pl,{ d:d, fill:'none', stroke: isMain?'var(--accent)':'var(--muted)', 'stroke-width': isMain?2.6:1.1, 'stroke-opacity': isMain?1:0.32, 'stroke-linejoin':'round' });
    svg.appendChild(pl);
  }
}

function animate(target){
  var from = cur.map(function(r){ return r.slice(); });
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/850);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(row,i){ return row.map(function(v,k){ return v+(target[i][k]-v)*e; }); });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextTarget(){
  var t=[];
  for (var i=0;i<NITEM;i++) t.push(AXES.map(function(){ return R(0.08,0.94); }));
  return t;
}

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

산점도는 변수 2개까지만 x·y로 보여줄 수 있지만, 평행 좌표는 축을 옆으로 계속 추가할 수 있어 5개, 10개 변수도 한 화면에 담습니다. 한 항목(행)이 축마다 값을 찍고 그 점들을 이으면 선 하나가 되므로, "이 항목은 축 A에서 높고 축 B에서 낮다"는 패턴이 선의 기울기로 드러납니다.

축 순서는 데이터가 아니라 선택입니다 — 서로 상관관계가 있는 축을 나란히 두면 그 사이 선분들이 거의 평행하게 모여 상관을 시각적으로 확인할 수 있고, 무관한 순서로 두면 같은 데이터도 어수선한 스파게티로 보입니다. 축마다 스케일(단위)이 다르므로 각 축은 자신의 min/max로 따로 정규화해야 합니다 — 전역 스케일 하나를 쓰면 값이 작은 축은 선이 한쪽에 뭉개집니다.

선이 많아지면(수십~수백 개) 겹쳐서 뭉개지므로, 강조하려는 항목 한둘만 진하게 칠하고 나머지는 회색으로 낮춰 배경 삼아야 합니다. 첫인상이 어수선하다고 축을 지우기보다, 인터랙션(드래그로 범위를 골라 걸러내는 브러싱)을 붙이는 게 정공법입니다 — 이 데모처럼 정적인 자동 재생만으로는 탐색 기능의 절반만 보여주는 셈입니다.

언제 쓰나

변수가 4개 이상이고 항목 사이의 다차원 패턴이나 이상치를 탐색하고 싶을 때. 변수가 2개뿐이면 산점도가 더 정확합니다.