UI Components Hard
Treemap
A proportional treemap that visualizes hierarchical data as nested rectangles. Implements a squarified layout algorithm, color-coded groups, hover tooltips, and animated entrance.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 900px;
margin: 0 auto;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.treemap-outer {
width: 100%;
height: 480px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
position: relative;
}
.tm-tile {
position: absolute;
overflow: hidden;
cursor: pointer;
transition: filter .15s;
border: 2px solid var(--bg);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 6px 8px;
border-radius: 4px;
animation: tmIn .4s ease both;
}
@keyframes tmIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.tm-tile:hover {
filter: brightness(1.15);
z-index: 2;
}
.tm-name {
font-size: 0.72rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tm-val {
font-size: 0.62rem;
color: rgba(255, 255, 255, 0.65);
}
.chart-tooltip {
position: fixed;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
pointer-events: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}const DATA = [
{
label: "Technology",
color: "#818cf8",
children: [
{ label: "NVDA", value: 320 },
{ label: "AAPL", value: 280 },
{ label: "MSFT", value: 240 },
{ label: "GOOGL", value: 200 },
],
},
{
label: "Finance",
color: "#34d399",
children: [
{ label: "JPM", value: 180 },
{ label: "BAC", value: 140 },
{ label: "GS", value: 120 },
],
},
{
label: "Healthcare",
color: "#f59e0b",
children: [
{ label: "JNJ", value: 160 },
{ label: "UNH", value: 130 },
{ label: "PFE", value: 90 },
],
},
{
label: "Energy",
color: "#f87171",
children: [
{ label: "XOM", value: 150 },
{ label: "CVX", value: 110 },
],
},
{
label: "Consumer",
color: "#a78bfa",
children: [
{ label: "AMZN", value: 200 },
{ label: "WMT", value: 130 },
],
},
];
const total = DATA.flatMap((g) => g.children).reduce((a, d) => a + d.value, 0);
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("treemapWrap");
function squarify(items, x, y, w, h) {
if (!items.length) return [];
const results = [];
const remaining = [...items];
while (remaining.length) {
const row = [];
let rowVal = 0;
const totalVal = remaining.reduce((a, d) => a + d.value, 0);
const isHoriz = w >= h;
const dim = isHoriz ? h : w;
for (let i = 0; i < remaining.length; i++) {
row.push(remaining[i]);
rowVal += remaining[i].value;
const rowArea = (rowVal / totalVal) * (w * h);
const rowLen = rowArea / dim;
const worst = row.reduce(
(a, d) =>
Math.max(
a,
Math.max(
(rowLen * (d.value / rowVal) * dim) / (rowLen || 1),
(rowLen || 1) / ((rowLen * (d.value / rowVal) * dim) / (rowLen || 1) || 1)
)
),
0
);
const nextVal = i + 1 < remaining.length ? remaining[i + 1].value : 0;
const nextWorst =
nextVal > 0
? Math.max(
worst,
Math.max(
(rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1),
(rowLen || 1) /
((rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1) || 1)
)
)
: Infinity;
if (nextWorst >= worst && i + 1 < remaining.length) continue;
// Lay out row
let cursor = isHoriz ? y : x;
const rowLen2 = ((rowVal / totalVal) * (w * h)) / dim;
row.forEach((d) => {
const size = (d.value / rowVal) * dim;
const rect = isHoriz
? { x, y: cursor, w: rowLen2, h: size }
: { x: cursor, y, w: size, h: rowLen2 };
results.push({ ...d, rect });
cursor += size;
});
remaining.splice(0, row.length);
if (isHoriz) {
x += rowLen2;
w -= rowLen2;
} else {
y += rowLen2;
h -= rowLen2;
}
break;
}
if (!row.length && remaining.length) {
const d = remaining.shift();
results.push({ ...d, rect: { x, y, w: w, h: h } });
}
}
return results;
}
function render() {
wrap.innerHTML = "";
const W = wrap.clientWidth,
H = wrap.clientHeight;
const all = DATA.flatMap((g) =>
g.children.map((c) => ({ ...c, group: g.label, color: g.color }))
);
const laid = squarify(all, 0, 0, W, H);
laid.forEach((d, i) => {
const { x, y, w, h } = d.rect;
if (w < 2 || h < 2) return;
const tile = document.createElement("div");
tile.className = "tm-tile";
tile.style.left = x + "px";
tile.style.top = y + "px";
tile.style.width = w + "px";
tile.style.height = h + "px";
tile.style.background = d.color;
tile.style.animationDelay = i * 0.02 + "s";
if (w > 40 && h > 30) {
tile.innerHTML = `<div class="tm-name">${d.label}</div><div class="tm-val">$${d.value}B</div>`;
}
tile.addEventListener("mouseenter", (e) => {
const pct = ((d.value / total) * 100).toFixed(1);
tooltip.innerHTML = `<strong>${d.label}</strong> · ${d.group}<br/>$${d.value}B | ${pct}%`;
tooltip.hidden = false;
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
tile.addEventListener("mousemove", (e) => {
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
tile.addEventListener("mouseleave", () => (tooltip.hidden = true));
wrap.appendChild(tile);
});
}
const ro = new ResizeObserver(render);
ro.observe(wrap);
render();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Treemap</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<h1 class="chart-title">Portfolio Allocation</h1>
<p class="chart-sub">By sector and asset</p>
</div>
<div class="treemap-outer" id="treemapWrap"></div>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useMemo } from "react";
const DATA = [
{
label: "Technology",
color: "#818cf8",
children: [
{ label: "NVDA", value: 320 },
{ label: "AAPL", value: 280 },
{ label: "MSFT", value: 240 },
{ label: "GOOGL", value: 200 },
],
},
{
label: "Finance",
color: "#34d399",
children: [
{ label: "JPM", value: 180 },
{ label: "BAC", value: 140 },
{ label: "GS", value: 120 },
],
},
{
label: "Healthcare",
color: "#f59e0b",
children: [
{ label: "JNJ", value: 160 },
{ label: "UNH", value: 130 },
{ label: "PFE", value: 90 },
],
},
{
label: "Energy",
color: "#f87171",
children: [
{ label: "XOM", value: 150 },
{ label: "CVX", value: 110 },
],
},
{
label: "Consumer",
color: "#a78bfa",
children: [
{ label: "AMZN", value: 200 },
{ label: "WMT", value: 130 },
],
},
];
const total = DATA.flatMap((g) => g.children).reduce((a, d) => a + d.value, 0);
type Item = {
label: string;
value: number;
color: string;
group: string;
rect: { x: number; y: number; w: number; h: number };
};
function squarify(
items: { label: string; value: number; color: string; group: string }[],
x: number,
y: number,
w: number,
h: number
): Item[] {
if (!items.length) return [];
const results: Item[] = [];
const remaining = [...items];
while (remaining.length) {
const row: typeof remaining = [];
let rowVal = 0;
const totalVal = remaining.reduce((a, d) => a + d.value, 0);
const isHoriz = w >= h;
const dim = isHoriz ? h : w;
for (let i = 0; i < remaining.length; i++) {
row.push(remaining[i]);
rowVal += remaining[i].value;
const rowArea = (rowVal / totalVal) * (w * h);
const rowLen = rowArea / dim;
const worst = row.reduce(
(a, d) =>
Math.max(
a,
Math.max(
(rowLen * (d.value / rowVal) * dim) / (rowLen || 1),
(rowLen || 1) / ((rowLen * (d.value / rowVal) * dim) / (rowLen || 1) || 1)
)
),
0
);
const nextVal = i + 1 < remaining.length ? remaining[i + 1].value : 0;
const nextWorst =
nextVal > 0
? Math.max(
worst,
Math.max(
(rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1),
(rowLen || 1) /
((rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1) || 1)
)
)
: Infinity;
if (nextWorst >= worst && i + 1 < remaining.length) continue;
let cursor = isHoriz ? y : x;
const rowLen2 = ((rowVal / totalVal) * (w * h)) / dim;
row.forEach((d) => {
const size = (d.value / rowVal) * dim;
const rect = isHoriz
? { x, y: cursor, w: rowLen2, h: size }
: { x: cursor, y, w: size, h: rowLen2 };
results.push({ ...d, rect });
cursor += size;
});
remaining.splice(0, row.length);
if (isHoriz) {
x += rowLen2;
w -= rowLen2;
} else {
y += rowLen2;
h -= rowLen2;
}
break;
}
if (!row.length && remaining.length) {
const d = remaining.shift()!;
results.push({ ...d, rect: { x, y, w, h } });
}
}
return results;
}
type Tooltip = { item: Item; x: number; y: number } | null;
export default function ChartTreemapRC() {
const wrapRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 600, h: 360 });
const [tooltip, setTooltip] = useState<Tooltip>(null);
useEffect(() => {
const ro = new ResizeObserver(() => {
if (!wrapRef.current) return;
const w = wrapRef.current.clientWidth - 4;
setDims({ w, h: Math.round(w * 0.6) });
});
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
const { w, h } = dims;
const all = DATA.flatMap((g) =>
g.children.map((c) => ({ ...c, group: g.label, color: g.color }))
);
const tiles = useMemo(() => squarify(all, 0, 0, w, h), [w, h]);
return (
<div className="min-h-screen bg-[#0d1117] p-6">
<div className="w-full max-w-[800px] mx-auto">
{/* Legend */}
<div className="flex gap-3 mb-3 flex-wrap">
{DATA.map((g) => (
<div key={g.label} className="flex items-center gap-1.5 text-[11px]">
<span className="w-2.5 h-2.5 rounded-sm" style={{ background: g.color }} />
<span className="text-[#8b949e]">{g.label}</span>
</div>
))}
</div>
<div
ref={wrapRef}
className="relative rounded-xl overflow-hidden border border-[#21262d]"
style={{ height: h }}
>
{tiles.map((d, i) => {
const { x, y, w: tw, h: th } = d.rect;
if (tw < 2 || th < 2) return null;
return (
<div
key={i}
className="absolute flex flex-col items-center justify-center overflow-hidden transition-opacity"
style={{
left: x,
top: y,
width: tw,
height: th,
background: d.color,
border: "1px solid #0d1117",
boxSizing: "border-box",
cursor: "pointer",
animation: `tmIn 0.3s ${i * 0.015}s ease both`,
opacity: 0,
}}
onMouseEnter={(e) => setTooltip({ item: d, x: e.clientX, y: e.clientY })}
onMouseMove={(e) =>
setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : null))
}
onMouseLeave={() => setTooltip(null)}
>
{tw > 40 && th > 30 && (
<>
<div className="text-white font-bold text-[11px] leading-none">{d.label}</div>
<div className="text-white/70 text-[10px] mt-0.5">${d.value}B</div>
</>
)}
</div>
);
})}
</div>
</div>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<div className="font-semibold text-[#e6edf3]">
{tooltip.item.label} ·{" "}
<span className="text-[#8b949e] font-normal">{tooltip.item.group}</span>
</div>
<div className="text-[#8b949e]">
${tooltip.item.value}B | {((tooltip.item.value / total) * 100).toFixed(1)}%
</div>
</div>
)}
<style>{`@keyframes tmIn{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:none}}`}</style>
</div>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const DATA = [
{
label: "Technology",
color: "#818cf8",
children: [
{ label: "NVDA", value: 320 },
{ label: "AAPL", value: 280 },
{ label: "MSFT", value: 240 },
{ label: "GOOGL", value: 200 },
],
},
{
label: "Finance",
color: "#34d399",
children: [
{ label: "JPM", value: 180 },
{ label: "BAC", value: 140 },
{ label: "GS", value: 120 },
],
},
{
label: "Healthcare",
color: "#f59e0b",
children: [
{ label: "JNJ", value: 160 },
{ label: "UNH", value: 130 },
{ label: "PFE", value: 90 },
],
},
{
label: "Energy",
color: "#f87171",
children: [
{ label: "XOM", value: 150 },
{ label: "CVX", value: 110 },
],
},
{
label: "Consumer",
color: "#a78bfa",
children: [
{ label: "AMZN", value: 200 },
{ label: "WMT", value: 130 },
],
},
];
const total = DATA.flatMap((g) => g.children).reduce((a, d) => a + d.value, 0);
function squarify(items, x, y, w, h) {
if (!items.length) return [];
const results = [];
const remaining = [...items];
while (remaining.length) {
const row = [];
let rowVal = 0;
const totalVal = remaining.reduce((a, d) => a + d.value, 0);
const isHoriz = w >= h;
const dim = isHoriz ? h : w;
for (let i = 0; i < remaining.length; i++) {
row.push(remaining[i]);
rowVal += remaining[i].value;
const rowArea = (rowVal / totalVal) * (w * h);
const rowLen = rowArea / dim;
const worst = row.reduce(
(a, d) =>
Math.max(
a,
Math.max(
(rowLen * (d.value / rowVal) * dim) / (rowLen || 1),
(rowLen || 1) / ((rowLen * (d.value / rowVal) * dim) / (rowLen || 1) || 1)
)
),
0
);
const nextVal = i + 1 < remaining.length ? remaining[i + 1].value : 0;
const nextWorst =
nextVal > 0
? Math.max(
worst,
Math.max(
(rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1),
(rowLen || 1) /
((rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1) || 1)
)
)
: Infinity;
if (nextWorst >= worst && i + 1 < remaining.length) continue;
let cursor = isHoriz ? y : x;
const rowLen2 = ((rowVal / totalVal) * (w * h)) / dim;
row.forEach((d) => {
const size = (d.value / rowVal) * dim;
const rect = isHoriz
? { x, y: cursor, w: rowLen2, h: size }
: { x: cursor, y, w: size, h: rowLen2 };
results.push({ ...d, rect });
cursor += size;
});
remaining.splice(0, row.length);
if (isHoriz) {
x += rowLen2;
w -= rowLen2;
} else {
y += rowLen2;
h -= rowLen2;
}
break;
}
if (!row.length && remaining.length) {
const d = remaining.shift();
results.push({ ...d, rect: { x, y, w, h } });
}
}
return results;
}
const wrapEl = ref(null);
const chartW = ref(600);
const chartH = ref(360);
const tooltip = ref(null);
let ro = null;
onMounted(() => {
ro = new ResizeObserver(() => {
if (!wrapEl.value) return;
chartW.value = wrapEl.value.clientWidth - 4;
chartH.value = Math.round(chartW.value * 0.6);
});
if (wrapEl.value) ro.observe(wrapEl.value);
});
onUnmounted(() => {
if (ro) ro.disconnect();
});
const all = DATA.flatMap((g) => g.children.map((c) => ({ ...c, group: g.label, color: g.color })));
const tiles = computed(() => squarify(all, 0, 0, chartW.value, chartH.value));
function tileEnter(e, d) {
tooltip.value = { item: d, x: e.clientX, y: e.clientY };
}
function tileMove(e) {
if (tooltip.value) tooltip.value = { ...tooltip.value, x: e.clientX, y: e.clientY };
}
</script>
<template>
<div class="page">
<div class="wrap">
<div class="legend">
<div v-for="g in DATA" :key="g.label" class="legend-item">
<span class="legend-swatch" :style="{ background: g.color }"></span>
<span class="legend-label">{{ g.label }}</span>
</div>
</div>
<div ref="wrapEl" class="treemap-wrap" :style="{ height: chartH + 'px' }">
<div v-for="(d, i) in tiles" :key="i"
class="tile"
:style="{
left: d.rect.x + 'px',
top: d.rect.y + 'px',
width: d.rect.w + 'px',
height: d.rect.h + 'px',
background: d.color,
animationDelay: (i * 0.015) + 's'
}"
@mouseenter="tileEnter($event, d)"
@mousemove="tileMove"
@mouseleave="tooltip = null">
<template v-if="d.rect.w > 40 && d.rect.h > 30">
<div class="tile-label">{{ d.label }}</div>
<div class="tile-value">${{ d.value }}B</div>
</template>
</div>
</div>
</div>
<div v-if="tooltip" class="tooltip-fixed"
:style="{ left: tooltip.x + 12 + 'px', top: tooltip.y - 40 + 'px' }">
<div class="tooltip-title">{{ tooltip.item.label }} · <span class="tooltip-group">{{ tooltip.item.group }}</span></div>
<div class="tooltip-sub">${{ tooltip.item.value }}B | {{ ((tooltip.item.value / total) * 100).toFixed(1) }}%</div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.legend { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 0.375rem; font-size: 11px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; }
.legend-label { color: #8b949e; }
.treemap-wrap { position: relative; border-radius: 0.75rem; overflow: hidden; border: 1px solid #21262d; }
.tile {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: pointer;
border: 1px solid #0d1117;
box-sizing: border-box;
opacity: 0;
animation: tmIn 0.3s ease both;
}
@keyframes tmIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: none; }
}
.tile-label { color: white; font-weight: 700; font-size: 11px; line-height: 1; }
.tile-value { color: rgba(255,255,255,0.7); font-size: 10px; margin-top: 2px; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
.tooltip-group { color: #8b949e; font-weight: 400; }
.tooltip-sub { color: #8b949e; }
</style><script>
import { onMount, onDestroy } from "svelte";
const DATA = [
{
label: "Technology",
color: "#818cf8",
children: [
{ label: "NVDA", value: 320 },
{ label: "AAPL", value: 280 },
{ label: "MSFT", value: 240 },
{ label: "GOOGL", value: 200 },
],
},
{
label: "Finance",
color: "#34d399",
children: [
{ label: "JPM", value: 180 },
{ label: "BAC", value: 140 },
{ label: "GS", value: 120 },
],
},
{
label: "Healthcare",
color: "#f59e0b",
children: [
{ label: "JNJ", value: 160 },
{ label: "UNH", value: 130 },
{ label: "PFE", value: 90 },
],
},
{
label: "Energy",
color: "#f87171",
children: [
{ label: "XOM", value: 150 },
{ label: "CVX", value: 110 },
],
},
{
label: "Consumer",
color: "#a78bfa",
children: [
{ label: "AMZN", value: 200 },
{ label: "WMT", value: 130 },
],
},
];
const total = DATA.flatMap((g) => g.children).reduce((a, d) => a + d.value, 0);
function squarify(items, x, y, w, h) {
if (!items.length) return [];
const results = [];
const remaining = [...items];
while (remaining.length) {
const row = [];
let rowVal = 0;
const totalVal = remaining.reduce((a, d) => a + d.value, 0);
const isHoriz = w >= h;
const dim = isHoriz ? h : w;
for (let i = 0; i < remaining.length; i++) {
row.push(remaining[i]);
rowVal += remaining[i].value;
const rowArea = (rowVal / totalVal) * (w * h);
const rowLen = rowArea / dim;
const worst = row.reduce(
(a, d) =>
Math.max(
a,
Math.max(
(rowLen * (d.value / rowVal) * dim) / (rowLen || 1),
(rowLen || 1) / ((rowLen * (d.value / rowVal) * dim) / (rowLen || 1) || 1)
)
),
0
);
const nextVal = i + 1 < remaining.length ? remaining[i + 1].value : 0;
const nextWorst =
nextVal > 0
? Math.max(
worst,
Math.max(
(rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1),
(rowLen || 1) /
((rowLen * (nextVal / (rowVal + nextVal)) * dim) / (rowLen || 1) || 1)
)
)
: Infinity;
if (nextWorst >= worst && i + 1 < remaining.length) continue;
let cursor = isHoriz ? y : x;
const rowLen2 = ((rowVal / totalVal) * (w * h)) / dim;
row.forEach((d) => {
const size = (d.value / rowVal) * dim;
const rect = isHoriz
? { x, y: cursor, w: rowLen2, h: size }
: { x: cursor, y, w: size, h: rowLen2 };
results.push({ ...d, rect });
cursor += size;
});
remaining.splice(0, row.length);
if (isHoriz) {
x += rowLen2;
w -= rowLen2;
} else {
y += rowLen2;
h -= rowLen2;
}
break;
}
if (!row.length && remaining.length) {
const d = remaining.shift();
results.push({ ...d, rect: { x, y, w, h } });
}
}
return results;
}
let wrapEl;
let chartW = 600;
let chartH = 360;
let tooltip = null;
let ro;
onMount(() => {
ro = new ResizeObserver(() => {
if (!wrapEl) return;
chartW = wrapEl.clientWidth - 4;
chartH = Math.round(chartW * 0.6);
});
if (wrapEl) ro.observe(wrapEl);
});
onDestroy(() => {
if (ro) ro.disconnect();
});
const all = DATA.flatMap((g) => g.children.map((c) => ({ ...c, group: g.label, color: g.color })));
$: tiles = squarify(all, 0, 0, chartW, chartH);
function tileEnter(e, d) {
tooltip = { item: d, x: e.clientX, y: e.clientY };
}
function tileMove(e) {
if (tooltip) tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 800px; margin: 0 auto; }
.legend { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 0.375rem; font-size: 11px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; }
.legend-label { color: #8b949e; }
.treemap-wrap { position: relative; border-radius: 0.75rem; overflow: hidden; border: 1px solid #21262d; }
.tile {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: pointer;
border: 1px solid #0d1117;
box-sizing: border-box;
opacity: 0;
animation: tmIn 0.3s ease both;
}
@keyframes tmIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: none; }
}
.tile-label { color: white; font-weight: 700; font-size: 11px; line-height: 1; }
.tile-value { color: rgba(255,255,255,0.7); font-size: 10px; margin-top: 2px; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
.tooltip-group { color: #8b949e; font-weight: 400; }
.tooltip-sub { color: #8b949e; }
</style>
<div class="page">
<div class="wrap">
<div class="legend">
{#each DATA as g}
<div class="legend-item">
<span class="legend-swatch" style="background:{g.color}"></span>
<span class="legend-label">{g.label}</span>
</div>
{/each}
</div>
<div class="treemap-wrap" bind:this={wrapEl} style="height:{chartH}px">
{#each tiles as d, i}
{#if d.rect.w >= 2 && d.rect.h >= 2}
<div class="tile"
style="left:{d.rect.x}px; top:{d.rect.y}px; width:{d.rect.w}px; height:{d.rect.h}px; background:{d.color}; animation-delay:{i * 0.015}s;"
on:mouseenter={(e) => tileEnter(e, d)}
on:mousemove={tileMove}
on:mouseleave={() => tooltip = null}>
{#if d.rect.w > 40 && d.rect.h > 30}
<div class="tile-label">{d.label}</div>
<div class="tile-value">${d.value}B</div>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{#if tooltip}
<div class="tooltip-fixed" style="left:{tooltip.x + 12}px; top:{tooltip.y - 40}px;">
<div class="tooltip-title">{tooltip.item.label} · <span class="tooltip-group">{tooltip.item.group}</span></div>
<div class="tooltip-sub">${tooltip.item.value}B | {((tooltip.item.value / total) * 100).toFixed(1)}%</div>
</div>
{/if}
</div>Features
- Squarified layout — minimizes tile aspect ratio for readability
- Hierarchical groups — group labels rendered over child tiles
- Hover tooltip — name, value, and % of total on hover
- Color-coded groups — consistent group hue with value-based lightness
- Animated entrance — tiles fade and scale in on mount
- Responsive — recalculates layout on container resize
How it works
- The squarified treemap algorithm recursively divides the container rectangle
- Each node’s area is proportional to its normalized value
- Tiles are CSS-positioned
<div>elements overlaid on the container - Group borders use a slightly lighter shade for clear hierarchy