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
- Paste the snippet just before
</head>. - Add
data-parallel-coordinatesanddata-axes(comma-separated axis names) to a container with a height, with one child per record: its text is the name anddata-valuesone figure per axis.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated colours, used in turn. Default purple, pink, blue… |
data-color | On a record: its colour. |
data-flip | Comma-separated axis names whose scale runs downwards, e.g. a price where lower is better. |
data-duration | Time for each line to draw. Default 700 ms. |
data-stagger | Gap between records. Default 260 ms. |
data-autoplay | Once drawn, pick out each record in turn until someone points at one. |
data-loop | Clear 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.
<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><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>