Treemap Build
One rectangle splits in two, then each half splits again, each piece sliding aside to reveal the next, until every item has a rectangle whose area matches its value. Then the labels fade in.
RentFoodTravelBillsFunSavingsHealthGiftsBooks
How to use it
- Paste the snippet just before
</head>. - Add
data-treemap-buildto a container with a height, with one child per rectangle: its text is the label anddata-valueits size.
Options
Add any of these attributes to the same element.
data-color | On a rectangle: its colour. Default a purple, pink, blue… set. |
data-prefix / data-suffix | Text around the values, e.g. $. |
data-values | false shows the labels only. |
data-step | Time for each round of splitting. Default 480 ms. |
data-loop | Fold back into one rectangle and split again. |
Each split cuts across the longer side, so the pieces stay close to square. Labels too big for their rectangle are left off; screen readers get every item with its share as a list.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Treemap Build: one rectangle splits in two, then each half splits again,
// and again, each piece sliding aside to reveal the next, until every item
// has a rectangle whose area matches its value. Then the labels fade in.
// Usage:
// <div data-treemap-build style="height:260px">
// <span data-value="420">Rent</span>
// <span data-value="260">Food</span>
// <span data-value="90">Travel</span>
// </div>
// Each child is one rectangle: its text is the label, data-value its size.
// Optional: data-color on a rectangle (default a purple, pink, blue… set),
// data-prefix / data-suffix (around the values, e.g. "$"),
// data-values="false" (labels only), data-step (ms for each round of
// splitting, default 480), data-loop (fold back up and split again).
var DEFAULTS = { step: 480, hold: 3000 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6', '#8b5cf6', '#06b6d4'];
var css =
'.tmb{position:relative}' +
'.tmb-leaf{position:absolute;box-sizing:border-box;padding:2px}' +
'.tmb-leaf>div{position:relative;width:100%;height:100%;border-radius:6px;background:var(--tmb-c);color:#fff;overflow:hidden;' +
'box-shadow:inset 0 0 0 1px rgba(255,255,255,.15)}' +
'.tmb-text{position:absolute;left:8px;top:6px;right:6px;text-align:left;line-height:1.2;opacity:0}' +
'.tmb-text b{display:block;font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'.tmb-text small{display:block;font-size:12px;opacity:.85;font-variant-numeric:tabular-nums}' +
'.tmb-leaf.tmb-nov small,.tmb-leaf.tmb-none .tmb-text{display:none}' +
'.tmb-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
// Split items (sorted, biggest first) into two runs,
// cutting the rectangle across its longer side; repeat inside each part.
// Records, for every item, its rectangle at each depth of the splitting.
function partition(items, rect, depth, ratio) {
items.forEach(function (it) { it.rects[depth] = rect; });
if (items.length < 2) return depth;
var total = items.reduce(function (a, it) { return a + it.value; }, 0);
var wide = rect.w * ratio >= rect.h;
var k = bestCut(items, rect, ratio).k;
var a = items.slice(0, k), b = items.slice(k);
var share = a.reduce(function (s, it) { return s + it.value; }, 0) / (total || 1);
var ra = cut(rect, share, wide, true), rb = cut(rect, share, wide, false);
return Math.max(partition(a, ra, depth + 1, ratio), partition(b, rb, depth + 1, ratio));
}
// One side of a rectangle cut at share f, across its longer side.
function cut(rect, f, wide, first) {
if (wide) return first ? { x: rect.x, y: rect.y, w: rect.w * f, h: rect.h } : { x: rect.x + rect.w * f, y: rect.y, w: rect.w * (1 - f), h: rect.h };
return first ? { x: rect.x, y: rect.y, w: rect.w, h: rect.h * f } : { x: rect.x, y: rect.y + rect.h * f, w: rect.w, h: rect.h * (1 - f) };
}
// The cut whose finished pieces end up closest to square (summed over the
// pieces), trying every way of cutting for up to 10 items; beyond that,
// the cut that halves the total.
function bestCut(items, rect, ratio) {
var n = items.length;
if (n < 2) {
var w = rect.w * ratio, h = rect.h;
return { cost: Math.max(w / (h || 1e-6), h / (w || 1e-6)) };
}
var total = items.reduce(function (s, it) { return s + it.value; }, 0) || 1;
var wide = rect.w * ratio >= rect.h, run = 0, best = { cost: Infinity, k: 1 };
for (var i = 1; i < n; i++) {
run += items[i - 1].value;
var f = run / total, cost;
if (n > 10) cost = Math.abs(f - 0.5);
else cost = bestCut(items.slice(0, i), cut(rect, f, wide, true), ratio).cost + bestCut(items.slice(i), cut(rect, f, wide, false), ratio).cost;
if (cost < best.cost) best = { cost: cost, k: i };
}
return best;
}
// The rectangle an item has at a given depth (its own once it's been split off).
function rectAt(it, depth) {
var r = null;
for (var dd = 0; dd <= depth; dd++) if (it.rects[dd]) r = it.rects[dd];
return r;
}
function setup(el) {
var d = el.dataset;
var pre = d.prefix || '', suf = d.suffix || '';
var items = Array.prototype.slice.call(el.children).map(function (b, i) {
return { label: b.textContent.trim(), value: +b.getAttribute('data-value') || 0, color: b.getAttribute('data-color') || PALETTE[i % PALETTE.length] };
});
items.sort(function (a, b) { return b.value - a.value; });
var total = items.reduce(function (a, it) { return a + it.value; }, 0) || 1;
el.classList.add('tmb');
el.innerHTML = '<ul class="tmb-sr">' + items.map(function (it) {
return '<li>' + esc(it.label) + ': ' + esc(pre) + it.value.toLocaleString() + esc(suf) + ' (' + Math.round((it.value / total) * 100) + '%)</li>';
}).join('') + '</ul>';
items.forEach(function (it) {
var leaf = document.createElement('div');
leaf.className = 'tmb-leaf';
leaf.setAttribute('aria-hidden', 'true');
leaf.style.setProperty('--tmb-c', it.color);
leaf.innerHTML = '<div><div class="tmb-text"><b>' + esc(it.label) + '</b>' +
(d.values === 'false' ? '' : '<small>' + esc(pre) + it.value.toLocaleString() + esc(suf) + '</small>') + '</div></div>';
el.appendChild(leaf);
it.el = leaf;
});
var c = { el: el, items: items, leaves: items.map(function (it) { return it.el; }), texts: Array.prototype.slice.call(el.querySelectorAll('.tmb-text')) };
measure(c);
el._tmb = c;
return c;
}
function measure(c) {
c.w = c.el.clientWidth;
c.h = c.el.clientHeight;
c.items.forEach(function (it) { it.rects = []; });
c.depth = partition(c.items, { x: 0, y: 0, w: 100, h: 100 }, 0, c.w / (c.h || 1));
// Hide labels that won't fit.
c.items.forEach(function (it) {
var r = it.rects[it.rects.length - 1], w = (r.w / 100) * c.w, h = (r.h / 100) * c.h;
it.el.classList.toggle('tmb-none', w < 52 || h < 26);
it.el.classList.toggle('tmb-nov', h < 44);
});
}
function place(c, depth) {
c.items.forEach(function (it) {
var r = rectAt(it, depth);
anime.utils.set(it.el, { left: r.x + '%', top: r.y + '%', width: r.w + '%', height: r.h + '%' });
});
}
function play(c) {
var d = c.el.dataset;
var step = +d.step || DEFAULTS.step;
var tl = anime.createTimeline();
c.playing = true;
if (c.w !== c.el.clientWidth || c.h !== c.el.clientHeight) measure(c);
place(c, 0);
anime.utils.set(c.texts, { opacity: 0 });
tl.add(c.leaves, { opacity: [0, 1], scale: [0.92, 1], duration: 400, ease: 'out(3)' }, 0);
for (var dd = 1; dd <= c.depth; dd++) {
(function (dd) {
tl.add(c.leaves, {
left: function (el, i) { return rectAt(c.items[i], dd).x + '%'; },
top: function (el, i) { return rectAt(c.items[i], dd).y + '%'; },
width: function (el, i) { return rectAt(c.items[i], dd).w + '%'; },
height: function (el, i) { return rectAt(c.items[i], dd).h + '%'; },
duration: step, ease: 'inOut(3)'
}, 250 + dd * step * 0.85);
})(dd);
}
var end = 250 + (c.depth + 1) * step * 0.85;
tl.add(c.texts, { opacity: [0, 1], translateY: [6, 0], duration: 400, delay: anime.stagger(50), ease: 'out(2)' }, end);
tl.then(function () {
c.playing = false;
// The box changed size mid-split: snap to the new layout.
if (c.dirty) { c.dirty = false; measure(c); place(c, c.depth); }
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
anime.animate(c.texts, { opacity: 0, duration: 250 });
// Fold everything back into one rectangle.
anime.animate(c.leaves, {
left: '0%', top: '0%', width: '100%', height: '100%', opacity: 0,
duration: 700, delay: 150, ease: 'inOut(3)',
onComplete: function () { play(c); }
});
}, DEFAULTS.hold);
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._tmb);
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._tmb;
if (!c || (c.w === c.el.clientWidth && c.h === c.el.clientHeight)) return;
// Mid-split, catch up once the split finishes.
if (c.playing) { c.dirty = true; return; }
measure(c);
if (c.shown) place(c, c.depth);
});
});
document.querySelectorAll('[data-treemap-build]').forEach(function (el) {
if (el._tmb) return;
var c = setup(el);
ro.observe(el);
c.shown = true;
if (reduced()) { place(c, c.depth); anime.utils.set(c.texts, { opacity: 1 }); return; }
place(c, 0);
anime.utils.set(c.leaves, { opacity: 0 });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-treemap-build data-prefix="$" style="height: 280px">
<span data-value="1200">Rent</span>
<span data-value="520">Food</span>
<span data-value="340">Travel</span>
<span data-value="260">Bills</span>
</div>