HTML 10 lines
<div class="heat">
<div class="view">
<div class="scene">
<div class="world"></div>
</div>
</div>
<div class="controls">
<output class="caption"></output>
</div>
</div>
CSS 142 lines
/* the model box and the control zone stand in one stack */
.heat {
/* one base unit: every length of the grid is a multiple of it. The caption under it is in
plain vmin, because the control zone is the same object in every model. */
--u: 0.29vmin;
display: grid;
justify-items: center;
gap: 4vmin;
font-family: Inter, system-ui, sans-serif;
}
/* the model box */
.view {
display: grid;
place-items: center;
height: 50vmin;
}
.scene {
perspective: calc(800 * var(--u));
/* tilted back, the floor's near corner hangs lower than its box: the room under it lifts what
is drawn, not the box, into the middle of the model box, so the corner stays clear of the
caption by the band's gap */
padding-bottom: calc(44 * var(--u));
pointer-events: none; /* the floor is tilted back: only the blocks take the pointer */
}
/* 5 columns and 4 rows: a 34-unit pitch, 26-unit blocks, a 10-unit margin */
.world {
box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
position: relative;
width: calc(182 * var(--u));
height: calc(160 * var(--u)); /* 4 rows, and a strip along the near edge for the day names */
border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.4);
border-radius: calc(10 * var(--u));
background: #1c1d3d;
transform-style: preserve-3d;
transform: rotateX(48deg) rotateZ(36deg);
animation: sway 9s ease-in-out infinite alternate;
pointer-events: none; /* only the blocks are hit */
}
@keyframes sway {
to { transform: rotateX(48deg) rotateZ(50deg); }
}
/* the day names, printed on the floor along the edge that faces you: wholly on the dark floor,
so they read the same on either stage */
.world em {
position: absolute;
top: calc(143 * var(--u));
left: calc((10 + var(--x) * 34) * var(--u));
width: calc(26 * var(--u));
color: #949bc0;
font: 700 calc(10 * var(--u))/calc(12 * var(--u)) Inter, system-ui, sans-serif;
font-style: normal;
text-align: center;
}
/* the button IS the roof, lifted by the value: what you see on top of a block is what you point
at. A button left on the floor would be hidden under the taller blocks in front of it. */
.cell {
/* cold teal → hot pink with the value */
--c: color-mix(in srgb, #ff4d9d calc(var(--v) * 100%), #2ee6d6);
position: absolute;
top: calc((10 + var(--y) * 34) * var(--u));
left: calc((10 + var(--x) * 34) * var(--u));
width: calc(26 * var(--u));
height: calc(26 * var(--u));
padding: 0;
border: 0;
background: none;
outline: none;
transform-style: preserve-3d;
transform: translateZ(calc(var(--v) * 40 * var(--u)));
pointer-events: auto;
cursor: pointer;
}
/* the roof's face */
.cell i {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--c) 68%, #141830);
box-shadow: inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, var(--c) 85%, #fff);
transform-style: preserve-3d;
/* the light fades out, so a pointer passing over leaves a short trail, not a white flash */
transition: background-color 0.35s, box-shadow 0.35s;
}
.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) * 40 * var(--u));
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) * 40 * var(--u));
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 calc(1 * var(--u)) #fff, 0 0 calc(16 * var(--u)) var(--c);
transition-duration: 0.12s; /* and comes on quickly */
}
/* the control zone: the same object in every model that has one, as tall as what it holds. It
holds only the caption here, one line that is never empty and never wraps, so a new line
cannot move the grid. */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0.7;
}
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 .caption');
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);