← Charts

Parallel Coordinates

A row of vertical axes, each with its own scale, and one line per record threading across them. The lines draw in one record at a time; then each is picked out in turn, with its figures tagged on every axis and its name underneath. Point at or tap a line to pick it yourself.

AriaBoltCometDriftEmberFlux

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-parallel-coordinates and data-axes (comma-separated axis names) to a container with a height, with one child per record: its text is the name and data-values one figure per axis.

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours, used in turn. Default purple, pink, blue…
data-colorOn a record: its colour.
data-flipComma-separated axis names whose scale runs downwards, e.g. a price where lower is better.
data-durationTime for each line to draw. Default 700 ms.
data-staggerGap between records. Default 260 ms.
data-autoplayOnce drawn, pick out each record in turn until someone points at one.
data-loopClear and draw again.

Each axis is scaled from its smallest to its largest figure, so the shape of a line shows where a record sits within each measure.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Parallel Coordinates: several vertical axes side by side, each with its
    // own scale, and one line per record threading across them. The lines
    // draw in one record at a time; then point at (or tap) a line to pick it
    // out and read its figures on every axis.
    // Usage:
    //   <div data-parallel-coordinates data-axes="Price,Speed,Range" style="height:260px">
    //     <span data-values="32,180,420">Model A</span>
    //     <span data-values="45,210,380">Model B</span>
    //   </div>
    // Each child is one record: its text is the name, data-values one figure
    // per axis.
    // Optional: data-colors (comma-separated, default purple, pink, blue…),
    // data-color on a record, data-flip (comma-separated axis names whose
    // scale runs top to bottom, e.g. where lower is better), data-duration
    // (ms for each line to draw, default 700), data-stagger (gap between
    // records, default 260), data-autoplay (pick out each record in turn
    // once drawn, until someone points at one), data-loop (clear and draw again).
    var DEFAULTS = { duration: 700, stagger: 260, hold: 1500 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.pco{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.pco svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.pco-axis{stroke:currentColor;stroke-opacity:.35;stroke-width:1.5}' +
      '.pco-head{fill:currentColor;font-weight:700;text-anchor:middle}' +
      '.pco-end{fill:currentColor;fill-opacity:.45;text-anchor:middle}' +
      '.pco-line{fill:none;stroke-width:2.2;stroke-linejoin:round;stroke-linecap:round;stroke-opacity:.8;transition:opacity .3s,stroke-width .3s}' +
      '.pco-hit{fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke;cursor:pointer}' +
      '.pco-pick .pco-line{opacity:.12}' +
      '.pco-pick .pco-line.pco-on{opacity:1;stroke-width:3.5;stroke-opacity:1}' +
      '.pco-tag{opacity:0;transition:opacity .3s;pointer-events:none}' +
      '.pco-pick .pco-tag.pco-on{opacity:1}' +
      '.pco-tag rect{rx:4px}' +
      '.pco-tag text{fill:#fff;font-weight:700;text-anchor:middle}' +
      '.pco-cap{font-weight:700;text-anchor:middle;opacity:0;transition:opacity .3s}' +
      '.pco-pick .pco-cap{opacity:1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function mk(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }
    function short(v) {
      var a = Math.abs(v);
      if (a >= 1e6) return +(v / 1e6).toFixed(1) + 'm';
      if (a >= 1e4) return +(v / 1e3).toFixed(1) + 'k';
      return String(+v.toFixed(2));
    }

    function setup(el) {
      var d = el.dataset;
      var cols = d.colors ? d.colors.split(',') : PALETTE;
      var axes = (d.axes || '').split(',').map(function (s) { return s.trim(); });
      var flip = (d.flip || '').split(',').map(function (s) { return s.trim(); });
      var recs = [];
      Array.prototype.forEach.call(el.children, function (r) {
        var raw = (r.getAttribute('data-values') || '').split(',').map(function (s) { return s.trim(); });
        var vs = raw.map(parseFloat);
        if (!vs.length || vs.some(isNaN)) return;
        recs.push({ name: r.textContent.trim(), vs: vs, raw: raw, color: (r.getAttribute('data-color') || cols[recs.length % cols.length]).trim() });
      });
      if (!recs.length) return null;
      var n = Math.min.apply(null, recs.map(function (r) { return r.vs.length; }));
      while (axes.length < n) axes.push('');
      var scales = [];
      for (var j = 0; j < n; j++) {
        var col = recs.map(function (r) { return r.vs[j]; });
        var lo = Math.min.apply(null, col), hi = Math.max.apply(null, col);
        if (hi === lo) { lo -= 1; hi += 1; }
        scales.push({ lo: lo, hi: hi, flip: flip.indexOf(axes[j]) >= 0 });
      }
      var c = {
        el: el, recs: recs, n: n, axes: axes.slice(0, n), scales: scales, pick: -1, auto: -1,
        autoplay: d.autoplay !== undefined && d.autoplay !== 'false',
        loop: d.loop !== undefined && d.loop !== 'false'
      };
      el.classList.add('pco');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Parallel coordinates chart across ' + c.axes.join(', ') + '. ' +
        recs.map(function (r) { return r.name + ': ' + r.raw.slice(0, n).map(function (v, j) { return (c.axes[j] ? c.axes[j] + ' ' : '') + v; }).join(', '); }).join('. ') + '.');
      el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') user(c, -1); });
      document.addEventListener('pointerdown', function (e) { if (c.userPick >= 0 && !el.contains(e.target)) user(c, -1); });
      el._pco = c;
      layout(c);
      return c;
    }

    function layout(c) {
      var el = c.el, W = el.clientWidth, H = el.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var fs = W < 380 ? 10 : 11.5;
      var top = 30, bot = H - 34;
      var side = Math.max(22, Math.min(46, W * 0.07));
      var x0 = side, x1 = W - side;
      var dx = (x1 - x0) / Math.max(1, c.n - 1);
      c.ax = function (j) { return x0 + dx * j; };
      c.ay = function (j, v) {
        var s = c.scales[j], f = (v - s.lo) / (s.hi - s.lo), a = top + 8, b = bot - 8;
        return s.flip ? a + f * (b - a) : b - f * (b - a);
      };
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      for (var j = 0; j < c.n; j++) {
        var x = c.ax(j), s = c.scales[j];
        mk('line', { class: 'pco-axis', x1: x, x2: x, y1: top, y2: bot }, svg);
        mk('text', { class: 'pco-head', x: x, y: 11, 'font-size': fs }, svg).textContent = c.axes[j];
        var tv = s.flip ? s.lo : s.hi, bv = s.flip ? s.hi : s.lo;
        mk('text', { class: 'pco-end', x: x, y: top - 6, 'font-size': fs - 1.5 }, svg).textContent = short(tv);
        mk('text', { class: 'pco-end', x: x, y: bot + 13, 'font-size': fs - 1.5 }, svg).textContent = short(bv);
      }
      var lines = mk('g', {}, svg), hits = mk('g', {}, svg), tags = mk('g', {}, svg);
      c.cap = mk('text', { class: 'pco-cap', x: W / 2, y: H - 3, 'font-size': fs + 1 }, svg);
      c.parts = c.recs.map(function (r, i) {
        var pts = [];
        for (var j = 0; j < c.n; j++) pts.push([c.ax(j), c.ay(j, r.vs[j])]);
        var dd = pts.map(function (p, k) { return (k ? 'L' : 'M') + p[0] + ',' + p[1]; }).join('');
        var line = mk('path', { class: 'pco-line', d: dd, stroke: r.color }, lines);
        var hit = mk('path', { class: 'pco-hit', d: dd }, hits);
        hit.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') user(c, i); });
        hit.addEventListener('click', function () { user(c, c.pick === i && c.userPick === i ? -1 : i); });
        var tg = mk('g', { class: 'pco-tag' }, tags);
        pts.forEach(function (p, j) {
          var label = r.raw[j], w = label.length * (fs - 1) * 0.62 + 10;
          var tx = Math.max(w / 2, Math.min(W - w / 2, p[0]));
          mk('rect', { x: tx - w / 2, y: p[1] - 9, width: w, height: 18, fill: r.color }, tg);
          mk('text', { x: tx, y: p[1], dy: '.35em', 'font-size': fs - 1 }, tg).textContent = label;
        });
        return { r: r, line: line, tag: tg, len: 0 };
      });
      el.innerHTML = '';
      el.appendChild(svg);
      c.svg = svg;
      c.parts.forEach(function (p) {
        p.len = p.line.getTotalLength();
        p.line.style.strokeDasharray = p.len;
        p.line.style.strokeDashoffset = c.drawn || reduced() ? 0 : p.len;
      });
      setPick(c, c.pick);
    }

    function setPick(c, i) {
      c.pick = i;
      if (!c.svg) return;
      c.svg.classList.toggle('pco-pick', i >= 0);
      c.parts.forEach(function (p, k) {
        p.line.classList.toggle('pco-on', k === i);
        p.tag.classList.toggle('pco-on', k === i);
      });
      if (i >= 0) {
        c.cap.textContent = c.recs[i].name;
        c.cap.setAttribute('fill', c.recs[i].color);
        // bring the picked line to the front
        c.parts[i].line.parentNode.appendChild(c.parts[i].line);
      }
    }

    function user(c, i) {
      c.userPick = i;
      c.userAt = Date.now();
      setPick(c, i);
    }

    function draw(c, done) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      setPick(c, c.userPick >= 0 ? c.userPick : -1);
      c.parts.forEach(function (p) { p.line.style.strokeDashoffset = p.len; });
      var lines = c.parts.map(function (p) { return p.line; });
      anime.animate(lines, {
        strokeDashoffset: function (t) { return [t.getTotalLength(), 0]; },
        duration: dur, delay: anime.stagger(stagger), ease: 'inOut(2)',
        onComplete: function () { c.drawn = true; done(); }
      });
    }

    // Draw, step through the records, then (looping) clear and draw again.
    function cycle(c) {
      if (!c.visible) { c.waiting = true; return; }
      draw(c, function () {
        var i = 0, steps = c.autoplay ? Math.min(c.recs.length, 5) : 0;
        function next() {
          if (!c.visible) { c.waiting = true; return; }
          var idle = !c.userAt || Date.now() - c.userAt > 5000;
          if (i < steps) {
            if (idle) setPick(c, i);
            i++;
            setTimeout(next, DEFAULTS.hold);
            return;
          }
          if (idle) setPick(c, -1);
          if (!c.loop) return;
          setTimeout(function () {
            if (c.userAt && Date.now() - c.userAt < 5000) { setTimeout(next, 1000); return; }
            anime.animate(c.parts.map(function (p) { return p.line; }), {
              opacity: [1, 0], duration: 450, ease: 'in(2)',
              onComplete: function () {
                c.parts.forEach(function (p) { p.line.style.opacity = ''; p.line.style.strokeDashoffset = p.len; });
                setTimeout(function () { cycle(c); }, 250);
              }
            });
          }, c.autoplay ? 400 : DEFAULTS.hold * 2);
        }
        setTimeout(next, c.autoplay ? 500 : 0);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._pco;
          c.visible = e.isIntersecting;
          if (!c.visible) return;
          if (!c.seen) { c.seen = true; cycle(c); }
          else if (c.waiting) { c.waiting = false; cycle(c); }
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._pco;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-parallel-coordinates]').forEach(function (el) {
        if (el._pco) return;
        var c = setup(el);
        if (!c) return;
        ro.observe(el);
        if (reduced()) return;
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-parallel-coordinates data-axes="Price,Speed,Range,Rating"
     data-flip="Price" style="height: 280px">
  <span data-values="24,112,260,4.1">Aria</span>
  <span data-values="45,155,280,4.6">Comet</span>
  <span data-values="38,130,400,4.3">Flux</span>
</div>