HTML 6 lines
<div class="heat">
<div class="scene">
<div class="world"></div>
</div>
<output></output>
</div>
CSS 110 lines
.heat {
display: grid;
justify-items: center;
gap: 18px;
font-family: system-ui, sans-serif;
}
.scene {
perspective: 800px;
padding: 50px 30px 10px;
pointer-events: none; /* the floor is tilted back: only the blocks take the pointer */
}
/* 5 columns and 4 rows: a 34px pitch, 26px blocks, 10px margin */
.world {
position: relative;
width: 182px;
height: 148px;
border: 1px solid rgb(139 108 255 / 0.4);
border-radius: 10px;
background: #1c1d3d;
transform-style: preserve-3d;
transform: rotateX(58deg) rotateZ(36deg);
animation: sway 9s ease-in-out infinite alternate;
pointer-events: none; /* only the blocks are hit */
}
@keyframes sway {
to { transform: rotateX(58deg) rotateZ(50deg); }
}
/* the day names, printed on the floor along the edge that faces you */
.world em {
position: absolute;
top: 140px;
left: calc(10px + var(--x) * 34px);
width: 26px;
color: #949bc0;
font: 700 8px/10px system-ui, sans-serif;
font-style: normal;
text-align: center;
}
.cell {
/* cold teal → hot pink with the value */
--c: color-mix(in srgb, #ff4d9d calc(var(--v) * 100%), #2ee6d6);
position: absolute;
top: calc(10px + var(--y) * 34px);
left: calc(10px + var(--x) * 34px);
width: 26px;
height: 26px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--c) 45%, transparent);
border-radius: 3px;
background: color-mix(in srgb, var(--c) 14%, transparent);
outline: none;
transform-style: preserve-3d;
pointer-events: auto;
cursor: pointer;
}
/* the roof, lifted by the value */
.cell i {
position: absolute;
inset: -1px;
background: color-mix(in srgb, var(--c) 68%, #141830);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 85%, #fff);
transform-style: preserve-3d;
transform: translateZ(calc(var(--v) * 70px));
}
.cell i::before,
.cell i::after {
content: '';
position: absolute;
}
/* the wall hanging from the roof's bottom edge */
.cell i::before {
top: 100%;
left: 0;
width: 100%;
height: calc(var(--v) * 70px);
background: color-mix(in srgb, var(--c) 46%, #141830);
transform-origin: top;
transform: rotateX(-90deg);
}
/* the wall hanging from its right edge, a shade darker */
.cell i::after {
top: 0;
left: 100%;
width: calc(var(--v) * 70px);
height: 100%;
background: color-mix(in srgb, var(--c) 30%, #141830);
transform-origin: left;
transform: rotateY(90deg);
}
/* pointed at: the roof lights up; the cell itself never moves */
.cell:hover i,
.cell:focus-visible i {
background: color-mix(in srgb, var(--c) 55%, #fff);
box-shadow: inset 0 0 0 1px #fff, 0 0 16px var(--c);
}
output {
color: #949bc0;
font-size: 12px;
}
JS 49 lines
// The data, as an API would send it back
const COMMITS = {
"days": ["Mon", "Tue", "Wed", "Thu", "Fri"],
"weeks": [
[3, 8, 5, 12, 7],
[6, 14, 9, 4, 10],
[2, 7, 16, 11, 5],
[9, 12, 6, 15, 13]
]
};
const world = document.querySelector('.world');
const out = document.querySelector('.heat output');
const max = Math.max(...COMMITS.weeks.flat());
// One block per value: its place in the grid, and its size as a fraction of the largest value
COMMITS.weeks.forEach((week, y) =>
week.forEach((value, x) => {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'cell';
cell.style.cssText = `--x:${x}; --y:${y}; --v:${value / max}`;
cell.setAttribute('aria-label', `${COMMITS.days[x]}, week ${y + 1}: ${value} commits`);
cell.append(document.createElement('i'));
world.append(cell);
}),
);
COMMITS.days.forEach((day, x) => {
const em = document.createElement('em');
em.style.setProperty('--x', x);
em.textContent = day;
world.append(em);
});
// The dock says what the pointed-at block holds, and the total otherwise
const total = COMMITS.weeks.flat().reduce((a, b) => a + b, 0);
const idle = `${total} commits in ${COMMITS.weeks.length} weeks`;
out.textContent = idle;
const show = (e) => {
const cell = e.target.closest('.cell');
out.textContent = cell ? cell.getAttribute('aria-label') : idle;
};
const reset = (e) => {
if (!e.relatedTarget?.closest?.('.cell')) out.textContent = idle;
};
world.addEventListener('pointerover', show);
world.addEventListener('focusin', show);
world.addEventListener('pointerout', reset);
world.addEventListener('focusout', reset);
Sass source 139 lines
// A heatmap drawn from JSON: one block per value on an isometric floor. JS writes --v per cell
// (value ÷ the largest value, 0–1); CSS makes it a height and a colour. Each block is the city's
// construction: the element is the roof, lifted by translateZ; ::before and ::after are the two
// walls that face the camera, hanging down from its +y and +x edges.
$cell: 34px; // grid pitch
$lot: 26px; // block footprint
$pad: 10px; // floor margin
$cols: 5;
$rows: 4;
$hmax: 70px; // the tallest block
.d-heatmap {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
width: 100%;
height: 100%;
padding: 10px 14px 14px;
// the floor is tilted back, so blocks sit behind the plane of the flat boxes around it (this
// one and the view): they must not catch the pointer, only the blocks do
pointer-events: none;
&__view {
display: grid;
place-items: center;
min-height: 0;
pointer-events: none;
}
// The floor. It ignores the pointer, so only the cells (real buttons) are hit.
&__world {
position: relative;
width: $pad * 2 + $cell * $cols - ($cell - $lot);
height: $pad * 2 + $cell * $rows - ($cell - $lot);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 12%, var(--surface-solid));
transform-style: preserve-3d;
transform: translateY(22px) rotateX(58deg) rotateZ(36deg);
animation: d-heatmap-sway 9s ease-in-out infinite alternate;
pointer-events: none;
// the day names, printed on the floor along the edge that faces you
em {
position: absolute;
top: $pad + $cell * $rows - ($cell - $lot) + 2px;
left: calc(#{$pad} + var(--x) * #{$cell});
width: $lot;
color: var(--muted);
font: 700 8px/10px system-ui, sans-serif;
font-style: normal;
text-align: center;
}
}
&__cell {
// cold teal → hot pink with the value
--c: color-mix(in srgb, var(--hot) calc(var(--v) * 100%), var(--accent-2));
position: absolute;
top: calc(#{$pad} + var(--y) * #{$cell});
left: calc(#{$pad} + var(--x) * #{$cell});
width: $lot;
height: $lot;
padding: 0;
border: 1px solid color-mix(in srgb, var(--c) 45%, transparent);
border-radius: 3px;
background: color-mix(in srgb, var(--c) 14%, transparent);
outline: none;
transform-style: preserve-3d;
pointer-events: auto;
cursor: pointer;
// the roof
i {
position: absolute;
inset: -1px;
background: color-mix(in srgb, var(--c) 68%, var(--surface-solid));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 85%, #fff);
transform-style: preserve-3d;
transform: translateZ(calc(var(--v) * #{$hmax}));
&::before,
&::after {
content: '';
position: absolute;
}
// +y wall: hangs from the roof's bottom edge
&::before {
top: 100%;
left: 0;
width: 100%;
height: calc(var(--v) * #{$hmax});
background: color-mix(in srgb, var(--c) 46%, var(--surface-solid));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 60%, transparent);
transform-origin: top;
transform: rotateX(-90deg);
}
// +x wall: hangs from its right edge, a shade darker
&::after {
top: 0;
left: 100%;
width: calc(var(--v) * #{$hmax});
height: 100%;
background: color-mix(in srgb, var(--c) 30%, var(--surface-solid));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 50%, transparent);
transform-origin: left;
transform: rotateY(90deg);
}
}
// pointed at: the roof lights up (the cell itself never moves)
&:hover i,
&:focus-visible i {
background: color-mix(in srgb, var(--c) 55%, #fff);
box-shadow:
inset 0 0 0 1px #fff,
0 0 16px var(--c);
}
}
&__dock {
display: grid;
justify-items: center;
font-size: 12px;
output {
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
}
@keyframes d-heatmap-sway {
to {
transform: translateY(22px) rotateX(58deg) rotateZ(50deg);
}
}