리지라인 플롯

Ridgeline plot

여러 그룹의 분포 곡선을 살짝 겹치게 위아래로 쌓아, 그룹 사이 분포 모양의 차이를 산맥처럼 늘어놓는 차트.

다른 이름: JoyplotJoy Division plot
···
html
<div class="viz">
  <svg id="svg"></svg>
</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}
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 = ['20대','30대','40대','50대','60대'];
var COLORS = ['var(--accent)','var(--viz-4)','var(--accent-3)','var(--viz-5)','var(--accent-2)'];
var cur = CATS.map(function(){ return { mu:0.5, sigma:0.15, amp: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 gauss(f,p){ return p.amp*Math.exp(-((f-p.mu)*(f-p.mu))/(2*p.sigma*p.sigma)); }

function draw(){
  svg.innerHTML = '';
  var padL=34,padR=10,padT=10,padB=6;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n=CATS.length;
  var rowGap = ph/(n+0.6);
  var maxH = rowGap*2.4;
  var M=32;
  var fs = Math.max(8,Math.min(11,W/38));
  CATS.forEach(function(name,i){
    var baseline = padT + rowGap*(i+1);
    var d = 'M'+padL.toFixed(1)+','+baseline.toFixed(1);
    for (var k=0;k<=M;k++){
      var f = k/M;
      var v = gauss(f,cur[i]);
      var x = padL+f*pw, y = baseline - v*maxH;
      d += ' L'+x.toFixed(1)+','+y.toFixed(1);
    }
    d += ' L'+(padL+pw).toFixed(1)+','+baseline.toFixed(1)+' Z';
    var area = se('path');
    sa(area,{ d:d, fill:'var(--bg)', stroke:COLORS[i%5], 'stroke-width':1.8 });
    svg.appendChild(area);
    var lab = se('text');
    sa(lab,{ x:padL-6, y:baseline+2, 'text-anchor':'end', fill:'var(--muted)', 'font-size':fs, 'font-weight':600 });
    lab.textContent = name;
    svg.appendChild(lab);
  });
}

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

function nextTarget(){
  return CATS.map(function(){ return { mu:R(0.25,0.75), sigma:R(0.09,0.2), amp:R(0.4,0.95) }; });
}

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

여러 그룹의 분포를 한 번에 비교하고 싶을 때 박스플롯보다 더 많은 걸 보여줍니다 — 박스플롯이 중앙값·사분위수 다섯 숫자로 압축한다면, 리지라인은 봉우리가 하나인지 여러 개인지(다봉분포), 어느 쪽으로 치우쳤는지(왜도)까지 곡선의 모양으로 그대로 드러냅니다.

핵심은 "겹침"입니다. 각 곡선의 기준선 간격을 곡선의 최대 높이보다 좁게 잡아 일부러 겹치게 하고, 그 결과 뒤쪽(위) 곡선의 봉우리가 앞쪽(아래) 곡선에 가려지는 자연스러운 원근감이 생깁니다. 곡선 채우기는 배경색으로 불투명하게 칠해야 이 가림 효과가 제대로 작동합니다 — 반투명하게 칠하면 뒤엉킨 곡선들이 서로 비쳐서 오히려 지저분해집니다.

그룹이 10개를 넘으면 겹침이 과해져 뒤쪽 그룹의 모양이 거의 안 보이게 되므로, 그럴 땐 그룹을 접거나 스몰 멀티플로 나누는 게 낫습니다. 그룹 순서도 의미가 있어야 합니다 — 시간이나 크기처럼 자연스러운 순서가 없는 범주를 억지로 쌓으면 산맥처럼 보이는 모양 자체가 잘못된 인상(마치 추세가 있는 것처럼)을 줍니다.

언제 쓰나

여러 그룹의 분포 모양(봉우리 개수, 치우침)을 한 화면에서 비교하고 싶을 때. 그룹이 10개를 넘거나 정확한 숫자가 필요하면 박스·바이올린 플롯을 쓰세요.