산점도

Scatter plot

두 연속형 변수를 x·y 좌표에 점으로 찍어 상관관계와 군집을 드러내는 차트.

다른 이름: Scatter chartXY 차트
···
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}
.axis{stroke:var(--line);stroke-width:1}
.tick{fill:var(--muted);font-weight:600}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.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:50%;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); }

var GROUPS = [
  { name: '그룹 A', color: 'var(--accent)', n: 22, cx: 0.35, cy: 0.62, spread: 0.16 },
  { name: '그룹 B', color: 'var(--viz-4)', n: 16, cx: 0.68, cy: 0.32, spread: 0.14 }
];
lg.innerHTML = GROUPS.map(function(g){ return '<b><i style="background:'+g.color+'"></i>'+g.name+'</b>'; }).join('');

var pts = [];
GROUPS.forEach(function(g,gi){
  for (var i=0;i<g.n;i++) pts.push({ g: gi, x: g.cx, y: g.cy, tx: g.cx, ty: g.cy });
});

function sample(g){ return { x: Math.max(0.03,Math.min(0.97, g.cx+R(-1,1)*g.spread)), y: Math.max(0.03,Math.min(0.97, g.cy+R(-1,1)*g.spread)) }; }
pts.forEach(function(p){ var s = sample(GROUPS[p.g]); p.x=s.x; p.y=s.y; p.tx=s.x; p.ty=s.y; });

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=22, padR=12, padT=14, padB=20;
  var pw=W-padL-padR, ph=H-padT-padB;
  var ax = se('line');
  sa(ax,{x1:padL,y1:H-padB,x2:W-padR,y2:H-padB,class:'axis'});
  svg.appendChild(ax);
  var ay = se('line');
  sa(ay,{x1:padL,y1:padT,x2:padL,y2:H-padB,class:'axis'});
  svg.appendChild(ay);
  pts.forEach(function(p){
    var c = se('circle');
    sa(c,{cx:padL+p.x*pw, cy:padT+(1-p.y)*ph, r:3.4, fill:GROUPS[p.g].color, 'fill-opacity':0.78});
    svg.appendChild(c);
  });
}

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

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
setInterval(function(){
  pts.forEach(function(p){ var s = sample(GROUPS[p.g]); p.tx=s.x; p.ty=s.y; });
  animate();
}, 3200);

"이 두 값은 서로 관계가 있는가"에 답하는 차트입니다. 점이 오른쪽 위로 갈수록 퍼져 있으면 양의 상관, 왼쪽 위로 가면 음의 상관, 특정 모양 없이 흩어져 있으면 상관이 약하다는 뜻입니다. 다만 상관관계가 보인다고 인과관계를 증명하는 건 아닙니다.

점이 많아지면 겹침(overplotting)이 문제가 됩니다 — 점 하나하나가 아니라 밀도가 중요해지므로 투명도를 낮추거나, 아주 많다면 히트맵·2D 히스토그램으로 바꿉니다. 그룹을 색으로 나눌 땐 2~3개 정도가 한계입니다 — 그 이상이면 점들이 겹치는 곳에서 색을 구분하기 어려워집니다.

hover 타겟은 점 자체보다 커야 합니다. 8px짜리 점 위에 정확히 마우스를 올리라고 요구하면 아무도 못 맞춥니다.

언제 쓰나

두 연속형 변수 사이의 관계나 군집을 탐색할 때. 점이 수백 개를 넘으면 밀도 인코딩(히트맵)으로 바꾸세요.