캘린더 히트맵

Calendar heatmap

날짜를 요일×주 단위 작은 격자로 늘어놓고, 하루의 값을 색의 진하기로 칠해 활동이 언제 몰리는지 보여주는 차트.

다른 이름: Contribution graph기여 그래프
···
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]); }

var COLS = 18, ROWSN = 7;
var cur = []; for (var i=0;i<COLS*ROWSN;i++) cur.push(0.12);
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 pad=10, legendH=16;
  var pw=W-pad*2, ph=H-pad*2-legendH;
  var gap=Math.max(1, Math.min(3,W/220));
  var cw=(pw-gap*(COLS-1))/COLS;
  var ch=(ph-gap*(ROWSN-1))/ROWSN;
  var size=Math.max(1,Math.min(cw,ch));
  for (var c=0;c<COLS;c++){
    for (var r=0;r<ROWSN;r++){
      var idx=c*ROWSN+r;
      var v=cur[idx];
      var rect=se('rect');
      sa(rect,{ x:pad+c*(size+gap), y:pad+r*(size+gap), width:size, height:size, rx:1.5, fill:'var(--accent)', 'fill-opacity': Math.max(0.08, v*0.95) });
      svg.appendChild(rect);
    }
  }
  var ly = pad+ph+legendH-2;
  var lt = se('text');
  sa(lt,{ x:pad, y:ly, fill:'var(--muted)', 'font-size':8, 'font-weight':600 });
  lt.textContent = '적음';
  svg.appendChild(lt);
  for (var k=0;k<5;k++){
    var sw=se('rect');
    sa(sw,{ x:pad+26+k*11, y:ly-8, width:8, height:8, rx:1.5, fill:'var(--accent)', 'fill-opacity':0.12+k*0.22 });
    svg.appendChild(sw);
  }
  var rt = se('text');
  sa(rt,{ x:pad+26+5*11+4, y:ly, fill:'var(--muted)', 'font-size':8, 'font-weight':600 });
  rt.textContent = '많음';
  svg.appendChild(rt);
}

function animate(target){
  var from = cur.slice();
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/700);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(v,i){ return v+(target[i]-v)*e; });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextTarget(){
  var t=[];
  for (var i=0;i<COLS*ROWSN;i++) t.push(Math.random()<0.14 ? 0.03 : Math.pow(Math.random(),1.6));
  return t;
}

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

깃허브의 커밋 잔디밭으로 널리 알려진 형태입니다. 선 차트가 "얼마나 늘었나"를 보여준다면 캘린더 히트맵은 "언제 몰렸나"에 답합니다 — 요일별 습관(주말에 뜸함), 주기적 패턴(월말 몰림), 특이하게 튄 하루를 격자 위 위치만으로 짚어낼 수 있습니다.

색은 반드시 단일 색조의 명도만 바꾸는 순차(sequential) 스케일을 써야 합니다 — 값의 크고 작음은 "얼마나 진한가"의 문제일 뿐 서로 다른 범주가 아니기 때문입니다. 무지개색처럼 색상 자체가 바뀌는 스케일을 쓰면 인접한 두 칸의 순서(어느 쪽이 더 큰가)를 색만 보고 즉시 판단할 수 없게 됩니다.

칸이 워낙 작다 보니(하나에 몇 픽셀) 정확한 값은 hover로 봐야 하는 게 정상입니다 — 격자 자체는 "대략 언제 활동이 많았나"라는 패턴 파악용이지 정밀한 수치 조회용이 아닙니다. 또한 월별 총량 같은 걸 비교할 땐 그달의 요일 수(28~31일)가 다르다는 걸 감안해야 합니다 — 보정 없이 칸 개수(진한 칸의 수)만 세면 날짜 수가 많은 달이 불리하게 혹은 유리하게 왜곡됩니다.

언제 쓰나

날짜별 활동이 요일·주기에 따라 언제 몰리는지 보여주고 싶을 때. 정확한 값 비교나 짧은 기간의 추세가 목적이면 선·막대 차트가 낫습니다.