등고선 플롯

Contour plot

지형도의 등고선처럼, 값이 같은 지점을 이어 2차원 평면 위에서 값이 어떻게 오르내리는지 보여주는 차트.

다른 이름: Isoline plotLevel curves
···
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}
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 GX=30, GY=18;
var W=300, H=200;
var BLOBS_CUR = [
  { x:0.3, y:0.35, amp:1, s:0.22 },
  { x:0.68, y:0.4, amp:0.85, s:0.2 },
  { x:0.5, y:0.75, amp:0.7, s:0.18 }
];

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 fieldAt(x,y,blobs){
  var v=0;
  for (var i=0;i<blobs.length;i++){
    var b=blobs[i], dx=x-b.x, dy=y-b.y;
    v += b.amp*Math.exp(-(dx*dx+dy*dy)/(2*b.s*b.s));
  }
  return v;
}

function lerpPt(x0,y0,v0,x1,y1,v1,th){
  var t = (v1===v0) ? 0.5 : (th-v0)/(v1-v0);
  t = Math.max(0,Math.min(1,t));
  return { x: x0+(x1-x0)*t, y: y0+(y1-y0)*t };
}

function marchingSquares(field, th, toPx){
  var segs=[];
  for (var j=0;j<GY;j++){
    for (var i=0;i<GX;i++){
      var v00=field[j][i], v10=field[j][i+1], v11=field[j+1][i+1], v01=field[j+1][i];
      var c00=v00>=th, c10=v10>=th, c11=v11>=th, c01=v01>=th;
      var p00=toPx(i,j), p10=toPx(i+1,j), p11=toPx(i+1,j+1), p01=toPx(i,j+1);
      var edges=[];
      if (c00!==c10) edges.push({ side:'t', pt: lerpPt(p00.x,p00.y,v00,p10.x,p10.y,v10,th) });
      if (c10!==c11) edges.push({ side:'r', pt: lerpPt(p10.x,p10.y,v10,p11.x,p11.y,v11,th) });
      if (c01!==c11) edges.push({ side:'b', pt: lerpPt(p01.x,p01.y,v01,p11.x,p11.y,v11,th) });
      if (c00!==c01) edges.push({ side:'l', pt: lerpPt(p00.x,p00.y,v00,p01.x,p01.y,v01,th) });
      if (edges.length===2){ segs.push([edges[0].pt, edges[1].pt]); }
      else if (edges.length===4){
        var center=(v00+v10+v11+v01)/4;
        var bySide={}; edges.forEach(function(e){ bySide[e.side]=e.pt; });
        if (center>=th){ segs.push([bySide.t,bySide.l]); segs.push([bySide.r,bySide.b]); }
        else { segs.push([bySide.t,bySide.r]); segs.push([bySide.l,bySide.b]); }
      }
    }
  }
  return segs;
}

function draw(){
  svg.innerHTML = '';
  var padL=6,padT=6, pw=W-12, ph=H-12;
  var field=[];
  for (var j=0;j<=GY;j++){
    var row=[];
    for (var i=0;i<=GX;i++) row.push(fieldAt(i/GX, j/GY, BLOBS_CUR));
    field.push(row);
  }
  function toPx(gx,gy){ return { x: padL+(gx/GX)*pw, y: padT+(gy/GY)*ph }; }
  var LEVELS=[0.15,0.3,0.45,0.6,0.75,0.9];
  LEVELS.forEach(function(th,li){
    var segs = marchingSquares(field, th, toPx);
    if (!segs.length) return;
    var d='';
    segs.forEach(function(s){ d += 'M'+s[0].x.toFixed(1)+','+s[0].y.toFixed(1)+'L'+s[1].x.toFixed(1)+','+s[1].y.toFixed(1)+' '; });
    var p = se('path');
    sa(p,{ d:d, stroke:'var(--accent)', 'stroke-width':1.4, fill:'none', 'stroke-opacity': 0.35+0.55*(li/(LEVELS.length-1)) });
    svg.appendChild(p);
  });
}

function animate(target){
  var from = BLOBS_CUR.map(function(b){ return { x:b.x, y:b.y, amp:b.amp, s:b.s }; });
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/900);
    var e = 1-Math.pow(1-p,3);
    BLOBS_CUR = from.map(function(b,i){
      return { x:b.x+(target[i].x-b.x)*e, y:b.y+(target[i].y-b.y)*e, amp:b.amp+(target[i].amp-b.amp)*e, s:b.s+(target[i].s-b.s)*e };
    });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextBlobs(){
  return [
    { x:R(0.15,0.4), y:R(0.15,0.45), amp:R(0.7,1.1), s:R(0.16,0.24) },
    { x:R(0.55,0.85), y:R(0.2,0.55), amp:R(0.6,1), s:R(0.14,0.22) },
    { x:R(0.3,0.7), y:R(0.6,0.88), amp:R(0.5,0.95), s:R(0.14,0.22) }
  ];
}

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

산점도나 히트맵이 점 하나하나·칸 하나하나로 값을 보여준다면, 등고선은 "값이 같은 지점들"을 선으로 이어 지형 자체의 굴곡을 보여줍니다. 등고선 사이의 간격이 좁을수록 그 구간에서 값이 가파르게 변한다는 뜻이고, 넓을수록 완만하다는 뜻입니다 — 지형도에서 등고선이 촘촘하면 절벽, 성글면 평지인 것과 같습니다.

이 데모가 쓰는 알고리즘은 마칭 스퀘어(marching squares)입니다. 평면을 작은 격자 칸으로 나누고, 칸의 네 모서리 값이 기준값(threshold)보다 큰지 작은지를 비교합니다 — 네 모서리 중 부호가 바뀌는 변(edge)마다 그 경계를 선형 보간해 정확한 통과 지점을 찾고, 그 지점들을 이어 그 칸을 지나는 등고선 조각을 그립니다. 이걸 모든 칸, 여러 기준값에 대해 반복하면 등고선 전체가 완성됩니다.

격자가 너무 성기면(칸이 큼) 등고선이 매끄러운 곡선이 아니라 각진 다각형처럼 보이고, 너무 촘촘하면 계산량만 늘고 눈에 보이는 차이는 거의 없습니다. 기준값 간격을 일정하지 않게(예: 낮은 구간만 촘촘하게) 잡는 것도 흔한 실수입니다 — 그러면 선의 밀집도가 실제 기울기가 아니라 "어디를 강조하고 싶었는지"를 반영하게 되어 지도를 왜곡합니다.

언제 쓰나

2차원 평면에서 값이 어디서 가파르게, 어디서 완만하게 변하는지 보여주고 싶을 때. 개별 지점의 정확한 값이 중요하면 히트맵이나 산점도를 쓰세요.