마리메꼬 차트

Marimekko chart

누적 막대의 너비와 높이를 둘 다 값으로 쓰는 차트. 너비는 범주의 전체 규모, 높이는 그 안의 구성비를 동시에 보여줍니다.

다른 이름: Mekko chartMosaic plot모자이크 플롯
···
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;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: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); }
function sum(arr){ return arr.reduce(function(a,b){ return a+b; },0); }

var CATS = ['수도권','영남','호남','기타'];
var SERIES = [
  { name: '온라인', color: 'var(--accent)' },
  { name: '오프라인', color: 'var(--viz-4)' },
  { name: '제휴', color: 'var(--accent-3)' }
];
lg.innerHTML = SERIES.map(function(s){ return '<b><i style="background:'+s.color+'"></i>'+s.name+'</b>'; }).join('');

var cur = { weight: CATS.map(function(){ return 25; }), share: CATS.map(function(){ return SERIES.map(function(){ return 1; }); }) };
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=6,padR=6,padT=8,padB=20;
  var pw=W-padL-padR, ph=H-padT-padB;
  var gap=2;
  var totalW = sum(cur.weight);
  var fs = Math.max(7,Math.min(10,W/48));
  var x = padL;
  CATS.forEach(function(name,ci){
    var w = Math.max(2,(cur.weight[ci]/totalW)*(pw-gap*(CATS.length-1)));
    var shareRow = cur.share[ci];
    var rowSum = sum(shareRow);
    var y = padT+ph;
    shareRow.forEach(function(v,si){
      var segH = (v/rowSum)*ph;
      y -= segH;
      var rect = se('rect');
      sa(rect,{ x:x, y:y, width:w, height:Math.max(segH-1,0.5), fill:SERIES[si].color });
      svg.appendChild(rect);
    });
    var lab = se('text');
    sa(lab,{ x:x+w/2, y:padT+ph+13, 'text-anchor':'middle', fill:'var(--muted)', 'font-size':fs, 'font-weight':600 });
    lab.textContent = name;
    svg.appendChild(lab);
    x += w+gap;
  });
}

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

function nextTarget(){
  return {
    weight: CATS.map(function(){ return R(10,42); }),
    share: CATS.map(function(){ return SERIES.map(function(){ return R(6,85); }); })
  };
}

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

누적 막대 차트는 막대 너비를 다 똑같이 맞추지만, 마리메꼬는 그 너비마저 값으로 씁니다 — 열(칼럼)의 너비가 그 범주의 전체 규모(예: 지역별 매출)를, 열 안의 세그먼트 높이가 그 안의 구성비(예: 채널별 비중)를 나타냅니다. 그래서 "규모가 큰 범주가 어디인가"와 "그 범주 안에서 무엇이 비중 있는가"라는 두 질문에 한 그림으로 답합니다.

너비와 높이 둘 다 상대적 비율(퍼센트)이라는 점이 중요합니다 — 모든 열의 너비 합이 전체 폭 100%이고, 한 열 안의 세그먼트 높이 합이 그 열의 100%입니다. 그래서 실제 값(예: 매출 절대액)은 열의 "면적"으로 대략 가늠할 뿐 정밀 비교 대상은 아닙니다 — 정밀 비교가 목적이면 표를 같이 두세요.

칸이 좁아지면(너비가 작은 열) 안의 세그먼트 라벨이 다 안 들어갑니다. 열이 5~6개를 넘거나 세그먼트가 4개를 넘으면 좁은 칸에 색이 다닥다닥 붙어 경계를 읽기 어려워지므로, 그 이상은 상위 항목만 남기고 나머지를 "기타"로 묶는 편이 낫습니다.

언제 쓰나

범주별 전체 규모와 그 안의 구성비를 함께 보여주고 싶을 때. 정밀한 절대값 비교가 필요하면 표나 그룹 막대를 함께 쓰세요.