← AI

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

  1. Paste the snippet just before </head>.
  2. Add data-embedding-clusters to an element with a height, with the cluster names in data-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-labelsCluster names, separated by |. Default Sport|Food|Travel|Music.
data-colorsCluster colours, separated by |. Default purple, orange, blue, pink, green, gold.
data-countHow many points. Default scales with the area, 40 to 240.
data-grid="false"Hide the faint grid and axes.
data-loopClear the map and cluster new points again.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-embedding-clusters data-labels="Sport|Food|Travel|Music"
     style="height: 320px"></div>