트리맵

Treemap

값을 사각형의 면적으로 인코딩해 화면을 빈틈없이 채우는 차트. 계층 구조를 가진 부분-전체 데이터에 씁니다.

다른 이름: Squarified treemap사각형 트리맵
···
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 LABELS = ['A','B','C','D','E','F','G'];
var COLORS = ['var(--accent)','var(--viz-4)','var(--accent-3)','var(--viz-5)'];
var vals = LABELS.map(function(){ return R(8,42); });
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 worstRatio(row, side){
  var sum=0, max=-Infinity, min=Infinity;
  row.forEach(function(v){ sum+=v; if (v>max) max=v; if (v<min) min=v; });
  var s2=side*side, sum2=sum*sum;
  return Math.max((s2*max)/sum2, sum2/(s2*min));
}
function squarify(list, x, y, w, h, out){
  if (!list.length) return;
  if (list.length===1){ out.push({ item:list[0], x:x, y:y, w:w, h:h }); return; }
  var side = Math.min(w,h);
  var row = [list[0].area];
  var i = 1;
  while (i<list.length){
    var next = row.concat([list[i].area]);
    if (worstRatio(next,side) <= worstRatio(row,side)) { row=next; i++; } else break;
  }
  var rowSum = row.reduce(function(a,b){ return a+b; },0);
  if (w>=h){
    var colW = rowSum/h, yy=y;
    for (var k=0;k<row.length;k++){ var rh=row[k]/colW; out.push({item:list[k],x:x,y:yy,w:colW,h:rh}); yy+=rh; }
    squarify(list.slice(row.length), x+colW, y, w-colW, h, out);
  } else {
    var rowH = rowSum/w, xx=x;
    for (var k2=0;k2<row.length;k2++){ var rw=row[k2]/rowH; out.push({item:list[k2],x:xx,y:y,w:rw,h:rowH}); xx+=rw; }
    squarify(list.slice(row.length), x, y+rowH, w, h-rowH, out);
  }
}

function computeRects(valsArr){
  var padL=6, padR=6, padT=6, padB=6;
  var pw = W-padL-padR, ph = H-padT-padB;
  var total = valsArr.reduce(function(a,b){ return a+b; },0);
  var items = LABELS.map(function(l,i){ return { label:l, color:COLORS[i%4], area:(valsArr[i]/total)*pw*ph }; })
    .sort(function(a,b){ return b.area-a.area; });
  var out = [];
  squarify(items, padL, padT, pw, ph, out);
  var map = {};
  out.forEach(function(rc){ map[rc.item.label] = { x:rc.x, y:rc.y, w:rc.w, h:rc.h, color:rc.item.color, label:rc.item.label }; });
  return map;
}

function drawFrom(map){
  svg.innerHTML = '';
  var fs = Math.max(8, Math.min(13, W/70));
  LABELS.forEach(function(l){
    var rc = map[l];
    var rect = se('rect');
    sa(rect,{ x:rc.x+1, y:rc.y+1, width:Math.max(rc.w-2,0), height:Math.max(rc.h-2,0), rx:3, fill:rc.color });
    svg.appendChild(rect);
    if (rc.w>26 && rc.h>17){
      var t = se('text');
      sa(t,{ x:rc.x+6, y:rc.y+fs+4, fill:'#fff', 'font-size':fs, 'font-weight':700 });
      t.textContent = l;
      svg.appendChild(t);
    }
  });
}

var curMap = computeRects(vals);
function draw(){ curMap = computeRects(vals); drawFrom(curMap); }

function animateTo(target){
  var fromMap = curMap;
  var toMap = computeRects(target);
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/800);
    var e = 1-Math.pow(1-p,3);
    var mid = {};
    LABELS.forEach(function(l){
      var f=fromMap[l], to=toMap[l];
      mid[l] = { x:f.x+(to.x-f.x)*e, y:f.y+(to.y-f.y)*e, w:f.w+(to.w-f.w)*e, h:f.h+(to.h-f.h)*e, color:to.color, label:l };
    });
    curMap = mid;
    drawFrom(mid);
    if (p<1) requestAnimationFrame(step); else { vals = target; curMap = toMap; }
  }
  requestAnimationFrame(step);
}

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
setInterval(function(){
  animateTo(LABELS.map(function(){ return R(6,44); }));
}, 3800);

파이 차트와 목적은 같지만(부분-전체) 화면을 100% 활용하고, 항목이 많아도(수십 개) 자리가 남습니다. 각 사각형의 면적이 값에 비례하도록 배치하는 알고리즘이 "스퀘어파이(squarify)"입니다 — 사각형이 너무 길쭉해지지 않도록(가로세로비가 1:1에 가깝도록) 한 줄씩 채워 나갑니다.

문제는 면적 비교가 길이 비교보다 부정확하다는 점입니다 — 막대 차트라면 한눈에 순서를 알 수 있는 값도 트리맵에서는 "어느 게 더 큰지" 눈으로 가늠하기 어렵습니다. 그래서 정밀 비교가 목적이면 막대가 맞고, 트리맵은 "전체 구조를 한 화면에 욱여넣고 큰 덩어리를 찾는" 용도로 씁니다.

라벨은 사각형이 작으면 넣지 않습니다. 억지로 줄여넣거나 overflow:hidden으로 잘라내면 글자가 깨져 보이는 것보다 못합니다 — 들어갈 공간이 없으면 그 항목은 범례나 툴팁에만 이름을 남기세요.

언제 쓰나

항목이 많은(10개 이상) 계층형 부분-전체 데이터를 한 화면에 담고 싶을 때.