Bump Chart
Rankings over time. The lines sweep from left to right together, curving over and under each other as places change, with each name riding at the head of its line. Point at a line, or tap it, to pick it out from the tangle.
FoxesOwlsBearsWolvesHawksLynx
How to use it
- Paste the snippet just before
</head>. - Add
data-bump-chartanddata-labelsto a container with a height, with one child per line: its text is the name anddata-ranksits place in each column, 1 at the top. - Or give each line
data-valuesinstead and the places are worked out for you, biggest first.
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 line: its colour. |
data-duration | Time for the sweep from first column to last. Default 2400 ms. |
data-loop | Fade out and sweep again every few seconds. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Bump Chart: rankings over time. Lines sweep from left to right, column
// by column, crossing over each other as places change, each with its
// name riding at the head. Point at (or tap) a line to pick it out.
// Usage:
// <div data-bump-chart data-labels="2021,2022,2023,2024" style="height:240px">
// <span data-ranks="1,1,2,3">Oslo</span>
// <span data-ranks="2,3,1,1">Bern</span>
// <span data-ranks="3,2,3,2">Riga</span>
// </div>
// Each child is one line: its text is the name, data-ranks its place in
// each column (1 is top). Or give data-values and the places are worked
// out for you, biggest first.
// Optional: data-colors (comma-separated, default purple, pink, blue…),
// data-color on a line, data-duration (ms for the sweep, default 2400),
// data-loop (fade and sweep again).
var DEFAULTS = { duration: 2400, hold: 2800 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var css =
'.bmp{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
'.bmp svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.bmp-col{stroke:currentColor;stroke-opacity:.08}' +
'.bmp-head{fill:currentColor;fill-opacity:.55;font-weight:600;text-anchor:middle}' +
'.bmp-rank{fill:currentColor;fill-opacity:.45;font-weight:700;text-anchor:end}' +
'.bmp-line{fill:none;stroke-linecap:round;stroke-linejoin:round}' +
'.bmp-dot{stroke:#fff;stroke-width:2}' +
'.bmp-name{font-weight:700}' +
'.bmp-row{transition:opacity .25s}' +
'.bmp-hit{fill:none;stroke:transparent;stroke-width:16;pointer-events:stroke;cursor:pointer}' +
'.bmp-pick .bmp-row{opacity:.18}' +
'.bmp-pick .bmp-row.bmp-on{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 clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
function setup(el) {
var d = el.dataset;
var cols = d.colors ? d.colors.split(',') : PALETTE;
var rows = [];
Array.prototype.forEach.call(el.children, function (r) {
var ranks = r.getAttribute('data-ranks'), vals = r.getAttribute('data-values');
var list = (ranks || vals || '').split(',').map(parseFloat);
if (!list.length || list.some(isNaN)) return;
rows.push({ name: r.textContent.trim(), list: list, byValue: !ranks, color: (r.getAttribute('data-color') || cols[rows.length % cols.length]).trim() });
});
if (!rows.length) return null;
var n = Math.min.apply(null, rows.map(function (r) { return r.list.length; }));
rows.forEach(function (r) { r.ranks = r.byValue ? [] : r.list.slice(0, n); });
for (var j = 0; j < n; j++) {
var withVals = rows.filter(function (r) { return r.byValue; });
withVals.slice().sort(function (a, b) { return b.list[j] - a.list[j]; }).forEach(function (r, i) { r.ranks[j] = i + 1; });
}
var labels = d.labels ? d.labels.split(',') : [];
var c = { el: el, rows: rows, n: n, labels: labels, p: 0, fade: 1, pick: -1, id: 'bmp-clip-' + (++uid) };
c.places = Math.max.apply(null, rows.map(function (r) { return Math.max.apply(null, r.ranks); }));
el.classList.add('bmp');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Bump chart of rankings' + (labels.length ? ' from ' + labels[0] + ' to ' + labels[n - 1] : '') + '. ' +
rows.map(function (r) { return r.name + ': ' + r.ranks.map(function (k, i) { return (labels[i] ? labels[i] + ' ' : '') + '#' + k; }).join(', '); }).join('. ') + '.');
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') pick(c, -1); });
document.addEventListener('pointerdown', function (e) { if (c.pick >= 0 && !el.contains(e.target)) pick(c, -1); });
el._bmp = c;
layout(c);
return c;
}
// Cubic with flat ends between (x0,y0) and (x1,y1): x at u.
function bx(u) { return 1.5 * u * (1 - u) + u * u * u; }
function by(u) { return 3 * u * u - 2 * u * u * u; }
// y on the segment at a given fraction f of its width.
function yAt(f, y0, y1) {
var lo = 0, hi = 1;
for (var k = 0; k < 18; k++) { var m = (lo + hi) / 2; if (bx(m) < f) lo = m; else hi = m; }
return y0 + (y1 - y0) * by((lo + hi) / 2);
}
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 < 360 ? 10.5 : 12;
var nameW = Math.max.apply(null, c.rows.map(function (r) { return r.name.length; })) * fs * 0.6 + 12;
var top = c.labels.length ? 26 : 10, bot = H - 10;
var x0 = 30, x1 = W - nameW - 8;
var gap = (bot - top) / Math.max(1, c.places - 1);
var sw = Math.max(3, Math.min(8, gap * 0.22));
c.headR = sw * 0.75 + 2.5;
c.x0 = x0; c.x1 = x1; c.dx = (x1 - x0) / Math.max(1, c.n - 1);
c.y = function (rank) { return c.places === 1 ? (top + bot) / 2 : top + (rank - 1) * gap; };
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
var defs = mk('defs', {}, svg);
var clip = mk('clipPath', { id: c.id }, defs);
c.clipRect = mk('rect', { x: -20, y: -20, height: H + 40, width: 0 }, clip);
for (var j = 0; j < c.n; j++) {
var cx = x0 + c.dx * j;
mk('line', { class: 'bmp-col', x1: cx, x2: cx, y1: top - 8, y2: bot + 6 }, svg);
if (c.labels[j] !== undefined && (c.dx > 34 || j % 2 === 0 || j === c.n - 1)) {
mk('text', { class: 'bmp-head', x: cx, y: 11, 'font-size': fs - 1 }, svg).textContent = c.labels[j];
}
}
for (var k = 1; k <= c.places; k++) {
mk('text', { class: 'bmp-rank', x: 16, y: c.y(k), dy: '.35em', 'font-size': fs - 1 }, svg).textContent = k;
}
var lines = mk('g', { 'clip-path': 'url(#' + c.id + ')' }, svg);
var heads = mk('g', {}, svg);
c.parts = c.rows.map(function (r, i) {
var dd = 'M' + x0 + ',' + c.y(r.ranks[0]);
for (var j = 1; j < c.n; j++) {
var xa = x0 + c.dx * (j - 1), xb = x0 + c.dx * j, xm = (xa + xb) / 2;
dd += 'C' + xm + ',' + c.y(r.ranks[j - 1]) + ' ' + xm + ',' + c.y(r.ranks[j]) + ' ' + xb + ',' + c.y(r.ranks[j]);
}
var g = mk('g', { class: 'bmp-row' }, lines);
mk('path', { class: 'bmp-line', d: dd, stroke: r.color, 'stroke-width': sw }, g);
var dots = r.ranks.map(function (k, j) {
return mk('circle', { class: 'bmp-dot', cx: x0 + c.dx * j, cy: c.y(k), r: sw * 0.75 + 1.5, fill: r.color }, g);
});
var hit = mk('path', { class: 'bmp-hit', d: dd }, lines);
hit.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') pick(c, i); });
hit.addEventListener('click', function () { pick(c, c.pick === i ? -1 : i); });
var hg = mk('g', { class: 'bmp-row' }, heads);
var hd = mk('circle', { r: sw * 0.75 + 2.5, fill: r.color, class: 'bmp-dot' }, hg);
var name = mk('text', { class: 'bmp-name', dy: '.35em', 'font-size': fs, fill: r.color }, hg);
name.textContent = r.name;
return { r: r, g: g, hg: hg, head: hd, name: name, dots: dots };
});
el.innerHTML = '';
el.appendChild(svg);
c.svg = svg;
pick(c, c.pick);
render(c);
}
function render(c) {
if (!c.parts) return;
var pos = c.p * (c.n - 1);
var j = Math.min(c.n - 2, Math.floor(pos)), f = pos - j;
if (c.n < 2) { j = 0; f = 0; }
var hx = c.x0 + c.dx * pos;
c.clipRect.setAttribute('width', hx + 20 + (c.p >= 1 ? 40 : 0));
c.svg.style.opacity = c.fade;
c.parts.forEach(function (p) {
var r = p.r;
var hy = c.n < 2 ? c.y(r.ranks[0]) : yAt(f, c.y(r.ranks[j]), c.y(r.ranks[j + 1]));
p.head.setAttribute('cx', hx);
p.head.setAttribute('cy', hy);
p.name.setAttribute('x', hx + c.headR + 4);
p.name.setAttribute('y', hy);
p.hg.style.opacity = c.p > 0 ? '' : 0;
});
}
function pick(c, i) {
c.pick = i;
if (!c.svg) return;
c.svg.classList.toggle('bmp-pick', i >= 0);
c.parts.forEach(function (p, k) {
p.g.classList.toggle('bmp-on', k === i);
p.hg.classList.toggle('bmp-on', k === i);
});
}
function play(c) {
var d = c.el.dataset;
c.p = 0; c.fade = 1;
render(c);
anime.animate(c, {
p: 1, duration: +d.duration || DEFAULTS.duration, ease: 'inOut(1.6)',
onUpdate: function () { render(c); },
onComplete: function () {
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
anime.animate(c, {
fade: 0, duration: 500, ease: 'in(2)',
onUpdate: function () { render(c); },
onComplete: function () { c.p = 0; render(c); c.fade = 1; again(c); }
});
}, DEFAULTS.hold);
}
});
}
// Wait until the chart is on screen before sweeping again.
function again(c) {
if (c.visible) setTimeout(function () { play(c); }, 250);
else c.waiting = true;
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._bmp;
c.visible = e.isIntersecting;
if (!c.visible) return;
if (!c.seen) { c.seen = true; play(c); }
else if (c.waiting) { c.waiting = false; play(c); }
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._bmp;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-bump-chart]').forEach(function (el) {
if (el._bmp) return;
var c = setup(el);
if (!c) return;
ro.observe(el);
if (reduced()) { c.p = 1; render(c); return; }
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-bump-chart data-labels="2021,2022,2023,2024" style="height: 240px">
<span data-ranks="1,1,2,3">Foxes</span>
<span data-ranks="2,3,1,1">Owls</span>
<span data-ranks="3,2,3,2">Bears</span>
</div>