코드 다이어그램

Chord diagram

여러 항목을 원 둘레에 배치하고, 항목 사이의 관계량을 원 안쪽을 가로지르는 리본으로 이어 모든 관계를 한 화면에 보여주는 차트.

다른 이름: Chord graph관계 다이어그램
···
html
<div class="viz">
  <svg id="svg"></svg>
  <div class="legend" id="lg"></div>
</div>
css
:root{--viz-4:#e8a23a;--viz-5:#4098d7}
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:2px;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); }
function sum(arr){ return arr.reduce(function(a,b){ return a+b; },0); }

var CATS = ['A팀','B팀','C팀','D팀'];
var COLORS = ['var(--accent)','var(--viz-4)','var(--accent-3)','var(--viz-5)'];
var N = CATS.length;
lg.innerHTML = CATS.map(function(c,i){ return '<b><i style="background:'+COLORS[i]+'"></i>'+c+'</b>'; }).join('');

function symMatrix(){
  var m=[]; for (var i=0;i<N;i++) m.push(new Array(N).fill(0));
  for (var i=0;i<N;i++) for (var j=i+1;j<N;j++){ var v=R(4,30); m[i][j]=v; m[j][i]=v; }
  return m;
}
var cur = symMatrix();
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 arcPath(cx,cy,r0,r1,a0,a1){
  var x0o=cx+Math.cos(a0)*r1, y0o=cy+Math.sin(a0)*r1;
  var x1o=cx+Math.cos(a1)*r1, y1o=cy+Math.sin(a1)*r1;
  var x1i=cx+Math.cos(a1)*r0, y1i=cy+Math.sin(a1)*r0;
  var x0i=cx+Math.cos(a0)*r0, y0i=cy+Math.sin(a0)*r0;
  var large = (a1-a0) > Math.PI ? 1 : 0;
  return 'M'+x0o+','+y0o+' A'+r1+','+r1+' 0 '+large+' 1 '+x1o+','+y1o+' L'+x1i+','+y1i+' A'+r0+','+r0+' 0 '+large+' 0 '+x0i+','+y0i+' Z';
}

function ribbonPath(cx,cy,r,i0,i1,j0,j1){
  var p1x=cx+Math.cos(i0)*r, p1y=cy+Math.sin(i0)*r;
  var p2x=cx+Math.cos(i1)*r, p2y=cy+Math.sin(i1)*r;
  var p3x=cx+Math.cos(j0)*r, p3y=cy+Math.sin(j0)*r;
  var p4x=cx+Math.cos(j1)*r, p4y=cy+Math.sin(j1)*r;
  var large1=(i1-i0)>Math.PI?1:0, large2=(j1-j0)>Math.PI?1:0;
  return 'M'+p1x.toFixed(1)+','+p1y.toFixed(1)
    +' A'+r+','+r+' 0 '+large1+' 1 '+p2x.toFixed(1)+','+p2y.toFixed(1)
    +' Q'+cx.toFixed(1)+','+cy.toFixed(1)+' '+p3x.toFixed(1)+','+p3y.toFixed(1)
    +' A'+r+','+r+' 0 '+large2+' 1 '+p4x.toFixed(1)+','+p4y.toFixed(1)
    +' Q'+cx.toFixed(1)+','+cy.toFixed(1)+' '+p1x.toFixed(1)+','+p1y.toFixed(1)+' Z';
}

function draw(){
  svg.innerHTML = '';
  var cx=W/2, cy=H/2;
  var rOuter = Math.max(28, Math.min(W,H)/2-14);
  var rInner = rOuter-8;
  var totals = cur.map(function(row){ return sum(row); });
  var grand = sum(totals);
  var gapFrac = 0.09;
  var usable = Math.PI*2*(1-gapFrac);
  var gap = (Math.PI*2*gapFrac)/N;
  var groupStart=[], groupSpan=[];
  var a=-Math.PI/2;
  for (var i=0;i<N;i++){
    var span = (totals[i]/grand)*usable;
    groupStart.push(a); groupSpan.push(span);
    a += span+gap;
  }
  var subStart = [];
  for (var i2=0;i2<N;i2++){
    subStart.push(new Array(N).fill(0));
    var pos = groupStart[i2];
    for (var j2=0;j2<N;j2++){
      if (i2===j2) continue;
      var w = totals[i2]>0 ? (cur[i2][j2]/totals[i2])*groupSpan[i2] : 0;
      subStart[i2][j2] = pos;
      pos += w;
    }
  }
  function subWidth(i,j){ return totals[i]>0 ? (cur[i][j]/totals[i])*groupSpan[i] : 0; }

  for (var gi=0; gi<N; gi++){
    var p = se('path');
    sa(p,{ d: arcPath(cx,cy,rInner,rOuter,groupStart[gi],groupStart[gi]+groupSpan[gi]), fill: COLORS[gi] });
    svg.appendChild(p);
  }
  for (var ri=0; ri<N; ri++){
    for (var rj=ri+1; rj<N; rj++){
      if (cur[ri][rj]<=0) continue;
      var i0=subStart[ri][rj], i1=i0+subWidth(ri,rj);
      var j0=subStart[rj][ri], j1=j0+subWidth(rj,ri);
      var pth = se('path');
      sa(pth,{ d: ribbonPath(cx,cy,rInner,i0,i1,j0,j1), fill: COLORS[ri], 'fill-opacity':0.42 });
      svg.appendChild(pth);
    }
  }
}

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

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

"이 항목들 사이에 어떤 관계가 서로 얼마나 있는가"라는, 네트워크 그래프와 같은 데이터(관계 행렬)를 다루지만 배치가 다릅니다. 각 항목은 원 둘레의 호(arc)를 하나씩 차지하고, 그 호의 길이는 그 항목이 관여한 관계의 총량을 나타냅니다. 두 항목 사이의 관계량은 원 안쪽을 가로지르는 리본의 너비로 그려지고, 리본의 양 끝은 각 항목의 호에서 그 관계가 차지하는 만큼의 조각입니다.

이 구조 때문에 리본 하나를 보면 두 가지를 동시에 알 수 있습니다 — 리본의 너비(관계량 자체)와, 그 리본이 상대 항목의 호에서 얼마나 큰 조각을 차지하는지(상대적 비중)입니다. 예를 들어 팀 A와 팀 B의 리본이 굵어도, B의 호 전체에서 그 리본이 작은 조각이라면 "A는 B에게 중요하지만 B에게는 A가 여러 관계 중 하나일 뿐"이라는 비대칭까지 읽을 수 있습니다.

항목이 8~10개를 넘으면 리본이 원 중심 부근에서 서로 겹치고 꼬여 매듭처럼 보이는 게 이 차트의 가장 큰 약점입니다 — 항목을 추려서 상위 N개만 남기거나, 그 이상은 인접 행렬(색으로 칠한 표)이 오히려 더 정확합니다. 실제 서비스에서는 항목 하나를 클릭하면 그 항목의 리본만 남기고 나머지를 흐리게 하는 인터랙션이 거의 필수인데, 이 데모는 정적 자동 재생이라 그 기능은 빠져 있습니다.

언제 쓰나

항목이 10개 이하이고 모든 쌍 사이의 관계량을 한 화면에서 보여주고 싶을 때. 항목이 많거나 정밀 비교가 필요하면 인접 행렬이나 네트워크 그래프를 쓰세요.