HTML 15 lines
<div class="plot" tabindex="0" role="img" aria-label="3D scatter plot of three clusters. Drag it, or use the arrow keys, to turn it">
<div class="view">
<div class="tilt">
<div class="spin">
<div class="cube">
<u></u><u></u><u></u><u></u><u></u><u></u>
<s></s><s></s><s></s>
<b class="label" style="--x:84;--y:70;--z:-70">x</b>
<b class="label" style="--x:-70;--y:-84;--z:-70">y</b>
<b class="label" style="--x:-70;--y:70;--z:84">z</b>
</div>
</div>
</div>
</div>
</div>
CSS 128 lines
/* the whole canvas is the drag surface; the plot itself is centred in it. No caption: the
site's badge already says "Drag". */
.plot {
/* one base unit: every length in the plot is a multiple of it */
--u: 0.25vmin;
position: fixed;
inset: 0;
display: grid;
place-items: center;
cursor: grab;
touch-action: none;
user-select: none;
}
.plot.is-drag {
cursor: grabbing;
}
/* the model box */
.view {
display: grid;
place-items: center;
height: 70vmin;
perspective: calc(700 * var(--u));
}
.tilt {
position: relative;
width: calc(140 * var(--u));
height: calc(140 * var(--u));
transform-style: preserve-3d;
transform: rotateX(var(--rx, -18deg));
}
.spin {
position: absolute;
inset: 0;
transform-style: preserve-3d;
animation: spin 28s linear infinite;
}
.cube {
--s: calc(140 * var(--u));
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: rotateY(var(--ry, 0deg));
}
/* wireframe walls with a faint grid */
.cube u {
position: absolute;
inset: 0;
/* in the text colour, so the frame reads on a light stage and a dark one */
--line: color-mix(in srgb, currentColor 7%, transparent);
border: calc(1.5 * var(--u)) solid color-mix(in srgb, currentColor 24%, transparent);
background:
linear-gradient(90deg, var(--line) calc(1.5 * var(--u)), transparent calc(1.5 * var(--u))) 0 0 / calc(35 * var(--u)) calc(35 * var(--u)),
linear-gradient(var(--line) calc(1.5 * var(--u)), transparent calc(1.5 * var(--u))) 0 0 / calc(35 * var(--u)) calc(35 * var(--u));
}
/* turn each face outward, then push it half a side from the centre */
.cube > :nth-child(1) { transform: rotateY(0deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(2) { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(4) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(5) { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(6) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
/* axes from the back-bottom-left corner */
.cube s {
position: absolute;
left: 0;
top: 100%;
width: calc(148 * var(--u));
height: calc(2.5 * var(--u));
background: color-mix(in srgb, currentColor 55%, transparent);
transform-origin: 0 50%;
transform: translateZ(calc(-70 * var(--u))); /* x */
}
.cube s:nth-of-type(2) { transform: translateZ(calc(-70 * var(--u))) rotateZ(-90deg); } /* y */
.cube s:nth-of-type(3) { transform: translateZ(calc(-70 * var(--u))) rotateY(-90deg); } /* z */
.cube b {
position: absolute;
top: calc(50% - calc(7 * var(--u)));
left: calc(50% - calc(7 * var(--u)));
width: calc(14 * var(--u));
height: calc(14 * var(--u));
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff 0 8%, var(--c) 50%, color-mix(in srgb, var(--c) 45%, #000));
box-shadow: 0 0 calc(8 * var(--u)) var(--c);
/* --x, --y, --z are plain numbers in the plot's own units */
translate: calc(var(--x) * var(--u)) calc(var(--y) * var(--u)) calc(var(--z) * var(--u));
/* undo the cube's turn and the tilt (static)... */
transform: rotateY(calc(var(--ry, 0deg) * -1)) rotateX(calc(var(--rx, -18deg) * -1));
/* ...and the spin (animated, on its own property) */
animation: face 28s linear infinite;
}
.cube .label {
display: grid;
place-items: center;
background: none;
box-shadow: none;
color: inherit;
opacity: 0.65;
font: 700 calc(17 * var(--u))/1 'JetBrains Mono', ui-monospace, monospace;
}
.is-drag .spin,
.is-drag .cube b {
animation-play-state: paused;
}
@keyframes spin {
to { transform: rotateY(360deg); }
}
@keyframes face {
from { rotate: y 0deg; }
to { rotate: y -360deg; }
}
/* the plot covers the canvas, so a ring round it would be off the canvas: focus brings up the
box edges instead */
.plot:focus-visible { outline: none; }
.plot:focus-visible .cube u { border-color: color-mix(in srgb, currentColor 70%, transparent); }
JS 57 lines
const plot = document.querySelector('.plot');
const cube = plot.querySelector('.cube');
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
// three loose clusters: centre x, y, z, spread, colour
const clusters = [
[-28, -26, -18, 46, '#8b6cff'],
[32, 8, 30, 40, '#2ee6d6'],
[-6, 36, -34, 34, '#ff4d9d'],
];
const blob = (c, spread) => clamp(c + (Math.random() + Math.random() + Math.random() - 1.5) * spread, -62, 62);
for (let n = 0; n < 42; n++) {
const [cx, cy, cz, spread, colour] = clusters[n % 3];
const p = document.createElement('b');
// plain numbers: CSS multiplies them by the plot's unit, --u
p.style.setProperty('--x', blob(cx, spread).toFixed(0));
p.style.setProperty('--y', blob(cy, spread).toFixed(0));
p.style.setProperty('--z', blob(cz, spread).toFixed(0));
p.style.setProperty('--c', colour);
cube.append(p);
}
let rx = -18, ry = 0;
let last = null;
plot.addEventListener('pointerdown', (e) => {
last = { x: e.clientX, y: e.clientY };
plot.setPointerCapture(e.pointerId);
plot.classList.add('is-drag');
});
plot.addEventListener('pointermove', (e) => {
if (!last) return;
ry += (e.clientX - last.x) * 0.5;
rx = clamp(rx - (e.clientY - last.y) * 0.4, -80, 80);
last = { x: e.clientX, y: e.clientY };
plot.style.setProperty('--rx', rx + 'deg');
plot.style.setProperty('--ry', ry + 'deg');
});
const up = () => {
last = null;
plot.classList.remove('is-drag');
};
// the keyboard: each arrow key turns it 10 degrees
plot.addEventListener('keydown', (e) => {
const step = { ArrowLeft: [0, -10], ArrowRight: [0, 10], ArrowUp: [8, 0], ArrowDown: [-8, 0] }[e.key];
if (!step) return;
e.preventDefault();
rx = clamp(rx + step[0], -80, 80);
ry += step[1];
plot.style.setProperty('--rx', rx + 'deg');
plot.style.setProperty('--ry', ry + 'deg');
});
plot.addEventListener('pointerup', up);
plot.addEventListener('pointercancel', up);