와플 차트

Waffle chart

전체를 정사각형 100개로 나누고 그중 몇 개를 채워서 비율을 "세어서" 확인할 수 있게 보여주는 차트.

다른 이름: Square pie chartIsotype grid픽토그램 차트
···
html
<div class="viz">
  <svg id="svg"></svg>
  <div class="legend" id="lg"></div>
</div>
css
:root{--viz-4:#e8a23a}
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
.legend{position:absolute;bottom:2px;left:0;right:0;display:flex;gap:10px;justify-content:center;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); }

var CATS = [
  { name:'완료', color:'var(--accent)' },
  { name:'진행중', color:'var(--viz-4)' },
  { name:'대기', color:'var(--line)' }
];
var cur = [62,25,13];
var thresh = [62,87,100];
var W=300, H=200;

function computeThresh(){
  var c0=Math.round(cur[0]), c1=Math.round(cur[1]);
  var c2=Math.max(0,100-c0-c1);
  thresh = [c0, c0+c1, 100];
}
function updateLegend(){
  var c0=thresh[0], c1=thresh[1]-thresh[0], c2=100-thresh[1];
  var pcts=[c0,c1,c2];
  lg.innerHTML = CATS.map(function(c,i){ return '<b><i style="background:'+c.color+'"></i>'+c.name+' '+pcts[i]+'%</b>'; }).join('');
}

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(progress){
  svg.innerHTML = '';
  var pad=10, legendH=14;
  var pw=W-pad*2, ph=H-pad*2-legendH;
  var gap=Math.max(1,Math.min(3,W/140));
  var size=Math.min((pw-gap*9)/10,(ph-gap*9)/10);
  var gx=pad+(pw-(size*10+gap*9))/2, gy=pad+(ph-(size*10+gap*9))/2;
  for (var idx=0; idx<100; idx++){
    var row=Math.floor(idx/10), col=idx%10;
    var cat = idx<thresh[0] ? 0 : (idx<thresh[1] ? 1 : 2);
    var appear = Math.max(0,Math.min(1,(progress*115-idx)/15));
    if (appear<=0) continue;
    var rect = se('rect');
    sa(rect,{ x:gx+col*(size+gap), y:gy+row*(size+gap), width:size, height:size, rx:1.5, fill:CATS[cat].color, 'fill-opacity':appear });
    svg.appendChild(rect);
  }
}

function animateReveal(){
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/1100);
    draw(p);
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextTarget(){
  var a=R(35,70), b=R(10,Math.min(40,95-a));
  return [Math.round(a),Math.round(b)];
}

new ResizeObserver(function(){ measure(); draw(1); }).observe(svg);
measure();
computeThresh(); updateLegend();
animateReveal();
setInterval(function(){
  var t = nextTarget();
  cur = [t[0],t[1]];
  computeThresh(); updateLegend();
  animateReveal();
}, 3600);

파이·도넛 차트와 같은 질문("전체 중 이게 몇 %인가")에 답하지만 인코딩이 다릅니다. 파이는 각도라는 연속적인 양으로 비율을 보여줘서 두 조각의 크기 차이를 가늠으로 판단해야 하는 반면, 와플은 정사각형 100개짜리 격자를 쓰기 때문에 "62%"가 정확히 62칸이 채워졌다는 뜻이 되어 원한다면 실제로 세어서 확인할 수 있습니다 — 각도·면적 판단이라는 부정확한 지각 작업을 세기라는 훨씬 쉬운 작업으로 바꿔주는 셈입니다.

그래서 격자는 반드시 정확히 10×10, 100칸이어야 합니다. 90칸이나 120칸처럼 다른 총량을 쓰면 한 칸이 1%가 아니게 되어 "세어서 확인한다"는 이 차트의 장점 자체가 사라지고 다시 눈어림으로 되돌아갑니다.

여러 범주를 이어서 채울 때는(완료/진행중/대기처럼) 칸을 채우는 순서를 항상 일정하게(예: 왼쪽 위부터 가로로) 두어야 합니다 — 매번 다른 순서로 채우면 "몇 번째 칸까지"라는 감각이 데이터마다 흔들려서 비교가 어려워집니다. 범주가 5개를 넘으면 칸 하나하나의 색을 구분하기 어려워지므로 파이 차트와 같은 한계를 그대로 물려받습니다.

언제 쓰나

전체 중 몇 %인지를 각도·면적 판단 없이 직관적인 개수로 보여주고 싶을 때. 여러 값을 정밀하게 비교해야 하면 막대 차트가 낫습니다.