HTML 14 lines
<div class="market">
<div class="view">
<div class="scene">
<div class="plate"></div>
</div>
</div>
<div class="controls">
<output class="caption"></output>
<div class="row">
<button type="button" data-period="24h">24h</button>
<button type="button" data-period="7d">7d</button>
</div>
</div>
</div>
CSS 229 lines
.market {
/* one base unit: every length in the map is a multiple of it, so it is the same share of a
card, the editor, a full screen and a recording canvas. The control zone under it is in
plain vmin, because it is the same object in every model. */
--u: 0.3vmin;
display: grid;
justify-items: center;
gap: 4vmin; /* the band's gap between the model and the control zone */
font-family: Inter, system-ui, sans-serif;
}
/* the model box: the same height in every model that has controls */
.view {
display: grid;
place-items: center;
height: 50vmin;
}
.scene {
perspective: calc(800 * var(--u));
padding: calc(4 * var(--u)) calc(30 * var(--u)) calc(30 * var(--u)); /* more room under the plate keeps it clear of the caption */
pointer-events: none; /* the plate is tilted back: only the tiles take the pointer */
}
/* the layout's area is 184 × 136 units, plus a 6-unit margin */
.plate {
box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
position: relative;
width: calc(196 * var(--u));
height: calc(148 * var(--u));
border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.4);
border-radius: calc(9 * var(--u));
background: color-mix(in srgb, #8b6cff 10%, #141830);
transform-style: preserve-3d;
transform: translateY(calc(14 * var(--u))) rotateX(46deg) rotateZ(28deg);
pointer-events: none;
}
/* the footprint: --x / --y / --w / --h are % of the 184 × 136 area, with a 3-unit gap between tiles */
.tile {
box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
--hue: #2ee6d6;
/* grey for a small move, full colour for the largest */
--c: color-mix(in srgb, var(--hue) calc(30% + var(--v) * 70%), #949bc0);
/* a fine edge: under one unit shows as a hairline on sharp screens */
--edge: calc(0.6 * var(--u)) solid color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
position: absolute;
top: calc(calc(7.5 * var(--u)) + var(--y) * calc(1.36 * var(--u)));
left: calc(calc(7.5 * var(--u)) + var(--x) * calc(1.84 * var(--u)));
width: calc(var(--w) * calc(1.84 * var(--u)) - calc(3 * var(--u)));
height: calc(var(--h) * calc(1.36 * var(--u)) - calc(3 * var(--u)));
padding: 0;
border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 35%, transparent);
border-radius: calc(2 * var(--u));
background: color-mix(in srgb, var(--c) 12%, transparent);
color: #eceefb;
font: 700 calc(10 * var(--u))/1 Inter, system-ui, sans-serif;
outline: none;
transform-style: preserve-3d;
pointer-events: auto;
cursor: pointer;
}
.tile.down {
--hue: #ff4d9d;
}
/* the two walls you see, glass: each is 40 units (the tallest block), turned up from an edge of the
footprint and squashed to this block's height from that edge, so it always meets the roof */
.tile::before,
.tile::after {
box-sizing: content-box; /* sized to meet at the edges, borders outside: on any page */
content: '';
position: absolute;
border: var(--edge);
box-shadow: inset 0 0 calc(8 * var(--u)) color-mix(in srgb, var(--c) 30%, transparent);
transition: transform 0.7s cubic-bezier(0.25, 1, 0.5, 1) calc(var(--i) * 45ms);
}
.tile::before {
top: calc(100% + calc(1 * var(--u)) - calc(40 * var(--u)));
left: calc(-1 * var(--u));
width: calc(100% + calc(2 * var(--u)));
height: calc(40 * var(--u));
background: color-mix(in srgb, var(--c) 64%, transparent);
transform-origin: bottom;
transform: rotateX(-90deg) scaleY(calc((2 + var(--v) * 38) / 40));
}
.tile::after {
top: calc(-1 * var(--u));
left: calc(100% + calc(1 * var(--u)) - calc(40 * var(--u)));
width: calc(40 * var(--u));
height: calc(100% + calc(2 * var(--u)));
background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 58%, transparent);
transform-origin: right;
transform: rotateY(90deg) scaleX(calc((2 + var(--v) * 38) / 40));
}
/* the roof, lifted by the move; the labels sit on it, top left (the corner least hidden) */
.tile i {
position: absolute;
inset: calc(-1 * var(--u));
z-index: 0; /* its own stacking context: the glow below (z -1) stays under the text */
display: grid;
align-content: start;
justify-items: start;
gap: calc(2 * var(--u));
padding: calc(3 * var(--u)) calc(4 * var(--u));
border: var(--edge);
/* over the chart's own dark, not see-through: on the light stage the roof went pale under its
light words */
/* 72%, not 80%: the labels are #eceefb and the smallest are 8.1px, so they need 4.5:1. At 80%
the darkest tile gave exactly 4.50 -- the minimum, with nothing spare -- and changing the
family to Inter took it to 4.43, because the labels' text-shadow is what darkens the pixels
behind them and a lighter stroke lays down less of it. 72% gives 5.13 on that tile, which a
font can move without breaking. */
background: color-mix(in srgb, var(--c) 72%, #0b0d18);
box-shadow:
inset 0 0 calc(8 * var(--u)) color-mix(in srgb, var(--c) 30%, transparent),
0 0 calc(10 * var(--u)) color-mix(in srgb, var(--c) 22%, transparent);
font-style: normal;
text-shadow: 0 0 calc(3 * var(--u)) rgb(11 13 24 / 0.85);
transform: translateZ(calc(calc(2 * var(--u)) + var(--v) * calc(38 * var(--u))));
transition: transform 0.7s cubic-bezier(0.25, 1, 0.5, 1) calc(var(--i) * 45ms);
}
/* pointed at, focused or tapped: the roof fills in and glows, faded with opacity */
.tile i::after {
content: '';
position: absolute;
inset: calc(-0.6 * var(--u));
z-index: -1;
/* the tile's own colour, not a whitened one: whitened, the words on the pointed-at tile were 2.8:1 */
background: color-mix(in srgb, var(--c) 30%, transparent);
box-shadow: inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, var(--c) 30%, #fff), 0 0 calc(22 * var(--u)) color-mix(in srgb, var(--c) 75%, transparent);
opacity: 0;
transition: opacity 0.25s;
}
.tile.on i::after,
.tile:focus-visible i::after {
opacity: 1; /* the tile itself never moves */
}
.tile small {
color: #eceefb; /* full strength: at 0.8 on the pointed-at tile it was 3.2:1 */
font-size: calc(9 * var(--u));
}
.tile.sm i { padding: calc(2 * var(--u)); }
.tile.sm span { font-size: calc(9 * var(--u)); }
.tile.sm small { display: none; } /* the smallest tiles only have room for the name */
.tile.md span { font-size: calc(12 * var(--u)); }
/* A neighbour with a bigger move has a higher roof, and a higher roof is drawn in front. A label
two lines deep reaches down into where that roof comes across, and the second line — the number —
was cut in half by it. So the number stays only on the two biggest tiles, and there it sits on
the same line as the name, in the top strip no neighbour's roof reaches. */
.tile.md small { display: none; }
.tile.lg i { grid-auto-flow: column; align-items: baseline; gap: calc(5 * var(--u)); padding: calc(4 * var(--u)) calc(7 * var(--u)); }
.tile.lg span { font-size: calc(20 * var(--u)); }
.tile.lg small { font-size: calc(12 * var(--u)); }
/* the control zone: the same object, at the same size, in every model that has one — so it is
written in plain vmin and not in the map's own unit. The caption is on its own line above
the row, and its line box never changes height, so a new read-out cannot move the map. */
.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;
}
.controls .row {
display: flex;
gap: 2vmin;
}
.controls button,
.controls label {
height: 8vmin;
min-width: 8vmin;
padding: 0 3vmin;
box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
border: 0;
border-radius: 999px;
/* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
strength: no colour of the model's, which would vanish on one of the two stages */
background: rgb(140 150 220 / 0.2);
color: inherit;
font: 600 4vmin Inter, system-ui, sans-serif;
cursor: pointer;
transition: background-color 0.35s;
}
.controls button:hover,
.controls label:hover {
background-color: rgb(140 150 220 / 0.34);
}
.controls :focus-visible {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
/* The pill already chosen answers the pointer too.
Hover sets background-color; this rule sets the background SHORTHAND, so its gradient image
was painted straight over that colour. The one control that was already selected was the one
control that did nothing when you pointed at it. check-motion named it on six of these charts
('hover button#1 "24h": changes nothing on screen').
Darker, not lighter: the gradient above is as pale as it can be with white text still passing. */
.controls [aria-pressed='true']:hover {
background-image: linear-gradient(135deg, #5730dd, #b31859);
}
JS 134 lines
// The data, as an API would send it back. cap: market cap in $B; change: % move per period.
const MARKET = {
"periods": ["24h", "7d"],
"coins": [
{ "name": "BTC", "cap": 1200, "change": { "24h": 2.4, "7d": 6.8 } },
{ "name": "ETH", "cap": 420, "change": { "24h": -1.8, "7d": 4.2 } },
{ "name": "XRP", "cap": 180, "change": { "24h": 5.6, "7d": -3.5 } },
{ "name": "BNB", "cap": 140, "change": { "24h": 0.6, "7d": 2.1 } },
{ "name": "SOL", "cap": 120, "change": { "24h": -3.9, "7d": 11.4 } },
{ "name": "DOGE", "cap": 80, "change": { "24h": 8.2, "7d": -9.6 } },
{ "name": "TRX", "cap": 70, "change": { "24h": -0.4, "7d": 1.3 } },
{ "name": "ADA", "cap": 65, "change": { "24h": -2.7, "7d": -6.9 } },
{ "name": "LINK", "cap": 55, "change": { "24h": 3.1, "7d": -4.4 } }
]
};
const plate = document.querySelector('.plate');
const out = document.querySelector('.market output');
const buttons = document.querySelectorAll('.controls .row button');
const W = 184; // the plate's inner area in the map's own units: the layout is done in them,
const H = 136; // then written to CSS as % of it
const sum = (list) => list.reduce((s, d) => s + d.cap, 0);
// How far from square (1 = square) the worst tile of a row is, laid along a side `side` long
function worst(row, side, scale) {
const depth = (sum(row) * scale) / side; // the row's thickness
return Math.max(...row.map((d) => Math.max((d.cap * scale) / depth ** 2, depth ** 2 / (d.cap * scale))));
}
// Squarified treemap. `scale` is square units per unit of cap, so every tile's area is its cap. Items come
// biggest first. Lay a row of them along the SHORTER side of the space that is left, adding the
// next one while that makes the row's worst tile more square; then the row takes a strip off
// that space and the next row starts in what remains.
function treemap(items, x, y, w, h) {
const tiles = [];
const scale = (w * h) / sum(items);
let rest = [...items];
while (rest.length) {
const side = Math.min(w, h);
const row = [rest[0]];
while (row.length < rest.length && worst([...row, rest[row.length]], side, scale) <= worst(row, side, scale)) {
row.push(rest[row.length]);
}
const depth = (sum(row) * scale) / side;
let at = 0; // where the next tile starts along the row
for (const d of row) {
const len = (d.cap * scale) / depth;
tiles.push(w >= h ? { ...d, x, y: y + at, w: depth, h: len } : { ...d, x: x + at, y, w: len, h: depth });
at += len;
}
if (w >= h) {
x += depth;
w -= depth;
} else {
y += depth;
h -= depth;
}
rest = rest.slice(row.length);
}
return tiles;
}
const tiles = treemap([...MARKET.coins].sort((a, b) => b.cap - a.cap), 0, 0, W, H);
// the height scale is shared by both periods, so a week's bigger moves stand taller
const maxMove = Math.max(...MARKET.coins.flatMap((c) => Object.values(c.change).map(Math.abs)));
const pct = (v) => `${v > 0 ? '+' : v < 0 ? '\u2212' : ''}${Math.abs(v).toFixed(1)}%`;
// One button per tile, placed once; its roof <i> carries the name and the move
const els = tiles.map((t, i) => {
const tile = document.createElement('button');
tile.type = 'button';
// label size by footprint: the two biggest carry the name and the %, the rest only the name
tile.className = 'tile ' + (t.w >= 70 && t.h >= 46 ? 'lg' : t.w >= 38 && t.h >= 26 ? 'md' : 'sm');
tile.style.cssText = `--i:${i}; --x:${(t.x / W) * 100}; --y:${(t.y / H) * 100}; --w:${(t.w / W) * 100}; --h:${(t.h / H) * 100}`;
const roof = document.createElement('i');
const name = document.createElement('span');
name.textContent = t.name; // text, never HTML: data from an API is not trusted markup
roof.append(name, document.createElement('small'));
tile.append(roof);
plate.append(tile);
return tile;
});
let period;
function summary() {
const moves = MARKET.coins.map((c) => c.change[period]);
const up = moves.filter((v) => v > 0).length;
const big = MARKET.coins.reduce((a, c) => (Math.abs(c.change[period]) > Math.abs(a.change[period]) ? c : a));
return `${period} · ${up} up, ${moves.length - up} down · biggest ${big.name} ${pct(big.change[period])}`;
}
// The dock repeats what the pointed-at (or focused, or tapped) tile holds, and lights its roof
function light(tile) {
els.forEach((t) => t.classList.toggle('on', t === tile));
out.textContent = tile ? tile.getAttribute('aria-label') : summary();
}
// One period: each tile gets --v (its height, 0–1) and .down for a loss. CSS does the rest.
function show(p) {
period = p;
tiles.forEach((t, i) => {
const change = t.change[p];
els[i].style.setProperty('--v', Math.abs(change) / maxMove);
els[i].classList.toggle('down', change < 0);
els[i].setAttribute('aria-label', `${t.name} · $${t.cap}B · ${pct(change)}`);
els[i].querySelector('small').textContent = pct(change);
});
buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.period === p));
clearTimeout(grace);
light(null);
}
// Back to the summary only after a short grace period, so crossing the gap between two tiles
// changes the read-out straight from one tile to the next instead of blinking
let grace;
const point = (e) => {
const tile = e.target.closest('.tile');
if (!tile) return;
clearTimeout(grace);
light(tile);
};
const leave = (e) => {
if (e.relatedTarget?.closest?.('.tile')) return;
clearTimeout(grace);
grace = setTimeout(() => light(null), 150);
};
plate.addEventListener('pointerover', point);
plate.addEventListener('focusin', point);
plate.addEventListener('pointerout', leave);
plate.addEventListener('focusout', leave);
buttons.forEach((b) => b.addEventListener('click', () => show(b.dataset.period)));
show(MARKET.periods[0]);