Embedding Clusters
Points pop onto a 2D map one by one, grey and scattered, as data is embedded. Then they drift together into coloured clusters of similar things, soft halos glow up behind each group and a label fades in over it. Looping, the map clears and new points settle into a fresh arrangement.
How to use it
- Paste the snippet just before
</head>. - Add
data-embedding-clustersto an element with a height, with the cluster names indata-labels. The map is drawn on a canvas that fills it, behind any content.
Options
Add any of these attributes to the same element.
data-labels | Cluster names, separated by |. Default Sport|Food|Travel|Music. |
data-colors | Cluster colours, separated by |. Default purple, orange, blue, pink, green, gold. |
data-count | How many points. Default scales with the area, 40 to 240. |
data-grid="false" | Hide the faint grid and axes. |
data-loop | Clear the map and cluster new points again. |
data-hold | How long the clusters show before looping. Default 2800 ms. |
The canvas stops drawing when it’s scrolled out of view. With reduced motion turned on it shows the finished clusters.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Embedding Clusters: points pop onto a 2D map one by one as data is
// embedded, then drift together into coloured clusters of similar things,
// each with its label. With data-loop they scatter and settle into a new
// arrangement. Drawn on a canvas that fills the element, behind any content,
// and paused when off screen.
// Usage: <div data-embedding-clusters data-labels="Sport|Food|Travel|Music" style="height:320px"></div>
// Optional: data-labels (cluster names separated by |, default
// "Sport|Food|Travel|Music"), data-colors (colours separated by |), data-count
// (number of points; default scales with the area, 40 to 240), data-grid="false"
// (no background grid), data-loop (scatter and cluster again), data-hold (ms
// the clusters show before looping, default 2800).
var DEFAULTS = { labels: 'Sport|Food|Travel|Music', colors: '#7c3aed|#f97316|#0ea5e9|#db2777|#16a34a|#ca8a04', hold: 2800 };
var css =
'[data-embedding-clusters]{position:relative;isolation:isolate;overflow:hidden}' +
'.ebc-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function clamp01(t) { return t < 0 ? 0 : t > 1 ? 1 : t; }
function gauss() { return (Math.random() + Math.random() + Math.random() - 1.5) / 1.5; }
function rgb(hex) {
var h = hex.replace('#', '');
if (h.length === 3) h = h.replace(/(.)/g, '$1$1');
var n = parseInt(h, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function build(el) {
var d = el.dataset;
var labels = (d.labels || DEFAULTS.labels).split('|').map(function (s) { return s.trim(); });
var colors = (d.colors || DEFAULTS.colors).split('|').map(function (c) { return rgb(c.trim()); });
var k = labels.length;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var canvas = document.createElement('canvas');
canvas.className = 'ebc-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
if (!el.getAttribute('aria-label') && !el.textContent.trim()) {
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Points grouped into clusters: ' + labels.join(', '));
}
var ctx = canvas.getContext('2d');
var w = 0, h = 0, dpr = 1, pts = [], centres = [], cycleStart = 0, raf = 0, visible = false, still = reduced();
var grey = [148, 140, 168];
// Phase times within one cycle (ms).
var T_POP = 1600, T_WAIT = 300, T_MOVE = 1900, T_HOLD = hold, T_OUT = 1100;
var CYCLE = T_POP + T_WAIT + T_MOVE + T_HOLD + T_OUT;
function layout() {
// Cluster centres spaced round an ellipse, turned a random amount.
var turn = Math.random() * Math.PI * 2;
var rx = w * (k > 3 ? 0.3 : 0.27), ry = h * (k > 3 ? 0.27 : 0.24);
centres = labels.map(function (l, i) {
var a = turn + i / k * Math.PI * 2 + (Math.random() - 0.5) * 0.5;
return { x: w / 2 + Math.cos(a) * rx, y: h / 2 + Math.sin(a) * ry, label: l, color: colors[i % colors.length] };
});
var spread = Math.min(w, h) * (k > 4 ? 0.075 : 0.09);
pts.forEach(function (p) {
var c = centres[p.c];
p.tx = c.x + gauss() * spread * 1.4;
p.ty = c.y + gauss() * spread * 1.4;
});
}
function scatter() {
pts.forEach(function (p) {
p.sx = w * (0.06 + Math.random() * 0.88);
p.sy = h * (0.08 + Math.random() * 0.84);
});
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
var n = +d.count || Math.round(Math.min(240, Math.max(40, (w * h) / 1300)));
pts = [];
for (var i = 0; i < n; i++) {
pts.push({ c: i % k, pop: Math.random(), lag: Math.random() * 0.35, r: 2.2 + Math.random() * 1.6, ph: Math.random() * 6.28 });
}
scatter();
layout();
cycleStart = performance.now();
if (still || !visible) draw(still ? CYCLE - T_OUT - 1 : 0);
}
function grid() {
if (d.grid === 'false') return;
var step = Math.max(28, Math.min(w, h) / 7);
ctx.strokeStyle = 'rgba(120,110,150,.12)';
ctx.lineWidth = 1;
ctx.beginPath();
for (var x = (w / 2) % step; x < w; x += step) { ctx.moveTo(Math.round(x) + 0.5, 0); ctx.lineTo(Math.round(x) + 0.5, h); }
for (var y = (h / 2) % step; y < h; y += step) { ctx.moveTo(0, Math.round(y) + 0.5); ctx.lineTo(w, Math.round(y) + 0.5); }
ctx.stroke();
ctx.strokeStyle = 'rgba(120,110,150,.28)';
ctx.beginPath();
ctx.moveTo(Math.round(w / 2) + 0.5, 0); ctx.lineTo(Math.round(w / 2) + 0.5, h);
ctx.moveTo(0, Math.round(h / 2) + 0.5); ctx.lineTo(w, Math.round(h / 2) + 0.5);
ctx.stroke();
}
function label(c, a, t) {
if (a <= 0) return;
var fs = Math.max(11, Math.min(14, Math.min(w, h) / 22));
ctx.font = '700 ' + fs + 'px system-ui, -apple-system, Segoe UI, sans-serif';
var tw = ctx.measureText(c.label).width;
var bw = tw + fs * 1.3, bh = fs * 1.75;
var spread = Math.min(w, h) * 0.09 * 1.4;
var x = Math.max(4, Math.min(w - bw - 4, c.x - bw / 2));
var y = c.y - spread - bh - 2;
if (y < 4) y = c.y + spread + 4;
y += (1 - a) * 6;
ctx.globalAlpha = a;
ctx.fillStyle = 'rgba(255,255,255,.92)';
ctx.strokeStyle = 'rgba(' + c.color.join(',') + ',.55)';
ctx.lineWidth = 1.2;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, bh / 2); else ctx.rect(x, y, bw, bh);
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgb(' + c.color.join(',') + ')';
ctx.textBaseline = 'middle';
ctx.fillText(c.label, x + fs * 0.65, y + bh / 2 + 0.5);
ctx.globalAlpha = 1;
}
function draw(t) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
grid();
var now = performance.now() / 1000;
var tm = T_POP + T_WAIT, th = tm + T_MOVE, to = th + T_HOLD;
// Soft halos behind the clusters while they're formed.
var halo = t < tm ? 0 : t < th ? ease(clamp01((t - tm - T_MOVE * 0.5) / (T_MOVE * 0.5))) : t < to ? 1 : 1 - clamp01((t - to) / (T_OUT * 0.5));
if (halo > 0) centres.forEach(function (c) {
var r = Math.min(w, h) * 0.2;
var g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, r);
g.addColorStop(0, 'rgba(' + c.color.join(',') + ',' + (0.16 * halo) + ')');
g.addColorStop(1, 'rgba(' + c.color.join(',') + ',0)');
ctx.fillStyle = g;
ctx.fillRect(c.x - r, c.y - r, r * 2, r * 2);
});
pts.forEach(function (p) {
var a, m, x, y, col;
if (t < T_POP) { a = clamp01((t / T_POP - p.pop * 0.85) / 0.15); m = 0; }
else if (t < tm) { a = 1; m = 0; }
else if (t < th) { a = 1; m = ease(clamp01(((t - tm) / T_MOVE - p.lag) / (1 - 0.35))); }
else if (t < to) { a = 1; m = 1; }
else { m = 1; a = 1 - clamp01((t - to) / T_OUT - p.lag * 0.5); }
// A gentle idle wobble so the map never looks frozen.
var wob = still ? 0 : 1.2;
x = p.sx + (p.tx - p.sx) * m + Math.sin(now * 1.3 + p.ph) * wob;
y = p.sy + (p.ty - p.sy) * m + Math.cos(now * 1.1 + p.ph) * wob;
var c = centres[p.c].color;
col = [0, 1, 2].map(function (j) { return Math.round(grey[j] + (c[j] - grey[j]) * m); });
if (a <= 0) return;
var pop = t < T_POP ? 1 + (1 - a) * 1.4 : 1;
ctx.globalAlpha = a * (0.55 + 0.4 * m);
ctx.fillStyle = 'rgb(' + col.join(',') + ')';
ctx.beginPath();
ctx.arc(x, y, p.r * pop, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
var la = t < th - T_MOVE * 0.25 ? 0 : t < th + 300 ? ease(clamp01((t - (th - T_MOVE * 0.25)) / (T_MOVE * 0.25 + 300))) : t < to ? 1 : 1 - clamp01((t - to) / (T_OUT * 0.4));
centres.forEach(function (c, i) { label(c, clamp01(la * 1.3 - i * 0.1), t); });
}
function frame(now) {
raf = 0;
if (!visible) return;
var t = now - cycleStart;
if (t >= CYCLE) {
if (d.loop === undefined) t = CYCLE - T_OUT - 1; // stay clustered
else {
cycleStart = now;
t = 0;
// Scattered points become the next round's starting places.
scatter();
layout();
}
}
if (d.loop === undefined && t > CYCLE - T_OUT) t = CYCLE - T_OUT - 1;
draw(t);
raf = requestAnimationFrame(frame);
}
resize();
new ResizeObserver(function () {
if (el.clientWidth !== w || el.clientHeight !== h) resize();
}).observe(el);
if (!still) {
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[entries.length - 1].isIntersecting;
if (visible && !was) {
if (!el._ebcStarted) { el._ebcStarted = true; cycleStart = performance.now(); }
if (!raf) raf = requestAnimationFrame(frame);
}
}, { threshold: 0.1 }).observe(el);
}
}
function init() {
document.querySelectorAll('[data-embedding-clusters]').forEach(function (el) {
if (el._ebc) return;
el._ebc = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-embedding-clusters data-labels="Sport|Food|Travel|Music"
style="height: 320px"></div>