Box Plot Draw
Box-and-whisker plots that draw themselves in. For each box the median line appears first, the box opens out from it to the quartiles, the whiskers stretch to the extremes, their caps snap on and any outliers pop in.
LondonParisBerlinMadridRome
How to use it
- Paste the snippet just before
</head>. - Add
data-box-plot-drawto a container with a height, with one child per box. Its text is the label. - Give each box its raw numbers in
data-values, or its five figures (min, lower quartile, median, upper quartile, max) indata-stats, plusdata-outliersif it has any.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated colours, one per box. Default purple, pink, blue… |
data-color | On a box: its colour. |
data-suffix | Text after the axis numbers, e.g. min. |
data-step | Time for each box to draw. Default 900 ms. |
data-stagger | Gap between boxes. Default 180 ms. |
data-loop | Fold the boxes away and draw again. |
With raw values the quartiles are worked out for you, and anything more than 1.5 box-heights beyond the box is drawn as an outlier.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Box Plot Draw: box-and-whisker plots that draw themselves in. For each
// box the median line appears first, the box opens out from it to the
// quartiles, the whiskers stretch out to the extremes, their caps snap on
// and any outliers pop in.
// Usage:
// <div data-box-plot-draw style="height:260px">
// <span data-values="12,15,18,19,21,22,24,31,48">Team A</span>
// <span data-stats="8,14,19,25,34" data-outliers="46">Team B</span>
// </div>
// Each child is one box: its text is the label. Give it the raw numbers
// in data-values (quartiles are worked out, and points beyond 1.5 × the
// box height become outliers), or the five numbers min, lower quartile,
// median, upper quartile, max in data-stats (plus data-outliers if any).
// Optional: data-colors="#a855f7,#ec4899,…" (one per box), data-color on a
// box, data-suffix (after the axis numbers), data-step (ms per box,
// default 900), data-stagger (gap between boxes, default 180),
// data-loop (fold the boxes away and draw again).
var DEFAULTS = { step: 900, stagger: 180, hold: 2800 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];
var NS = 'http://www.w3.org/2000/svg';
var css =
'.bpd{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
'.bpd svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.bpd-grid{stroke:currentColor;stroke-opacity:.08}' +
'.bpd-axis{stroke:currentColor;stroke-opacity:.3}' +
'.bpd-tick{fill:currentColor;fill-opacity:.55}' +
'.bpd-name{fill:currentColor;fill-opacity:.75;font-weight:600}' +
'.bpd-box{stroke-width:2}' +
'.bpd-med{stroke:currentColor;stroke-width:2.5;stroke-linecap:round}' +
'.bpd-wh{stroke-width:1.75;stroke-linecap:round}' +
'.bpd-out{stroke:#fff;stroke-width:1.5}';
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 nums(s) { return (s || '').split(/[\s,;]+/).filter(function (x) { return x !== '' && !isNaN(+x); }).map(Number); }
function quantile(sorted, q) {
var pos = (sorted.length - 1) * q, lo = Math.floor(pos), hi = Math.ceil(pos);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
function ease(x) { x = clamp01(x); return 1 - Math.pow(1 - x, 3); }
function back(x) { x = clamp01(x); var s = 1.7; x -= 1; return x * x * ((s + 1) * x + s) + 1; }
function round(x) { return Math.abs(x) >= 100 ? Math.round(x) : Math.round(x * 10) / 10; }
function stats(b) {
var raw = nums(b.getAttribute('data-values'));
if (raw.length) {
var s = raw.slice().sort(function (a, c) { return a - c; });
var q1 = quantile(s, 0.25), q3 = quantile(s, 0.75), iqr = q3 - q1;
var inside = s.filter(function (v) { return v >= q1 - 1.5 * iqr && v <= q3 + 1.5 * iqr; });
return { min: inside[0], q1: q1, med: quantile(s, 0.5), q3: q3, max: inside[inside.length - 1],
out: s.filter(function (v) { return v < q1 - 1.5 * iqr || v > q3 + 1.5 * iqr; }) };
}
var f = nums(b.getAttribute('data-stats'));
if (f.length < 5) return null;
return { min: f[0], q1: f[1], med: f[2], q3: f[3], max: f[4], out: nums(b.getAttribute('data-outliers')) };
}
function setup(el) {
var d = el.dataset;
var colors = d.colors ? d.colors.split(',') : PALETTE;
var boxes = [];
Array.prototype.forEach.call(el.children, function (b) {
var s = stats(b);
if (!s) return;
s.label = b.textContent.trim();
s.color = b.getAttribute('data-color') || colors[boxes.length % colors.length];
boxes.push(s);
});
var suf = d.suffix || '';
var c = { el: el, boxes: boxes, k: boxes.map(function () { return 0; }) };
el.classList.add('bpd');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Box plot. ' + boxes.map(function (b) {
return b.label + ': median ' + round(b.med) + suf + ', middle half ' + round(b.q1) + ' to ' + round(b.q3) + suf +
', range ' + round(b.min) + ' to ' + round(b.max) + suf + (b.out.length ? ', ' + b.out.length + ' outlier' + (b.out.length > 1 ? 's' : '') : '');
}).join('. ') + '.');
var all = [];
boxes.forEach(function (b) { all.push(b.min, b.max); all = all.concat(b.out); });
var lo = Math.min.apply(null, all), hi = Math.max.apply(null, all);
var raw = (hi - lo) / 4 || 1, p = Math.pow(10, Math.floor(Math.log10(raw)));
c.tick = [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
c.lo = Math.floor(lo / c.tick) * c.tick;
c.hi = Math.ceil(hi / c.tick) * c.tick;
if (c.hi === c.lo) c.hi = c.lo + c.tick;
el._bpd = c;
layout(c);
return c;
}
function layout(c) {
var W = c.el.clientWidth, H = c.el.clientHeight, d = c.el.dataset;
if (!W || !H) return;
c.W = W; c.H = H;
var suf = d.suffix || '';
var left = 6 + 6.5 * Math.max(String(round(c.hi)).length, String(round(c.lo)).length) + suf.length * 6;
var top = 8, bot = H - 22;
var y = function (v) { return bot - (v - c.lo) / (c.hi - c.lo) * (bot - top); };
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
for (var t = c.lo; t <= c.hi + 1e-9; t += c.tick) {
mk('line', { class: t === c.lo ? 'bpd-axis' : 'bpd-grid', x1: left, x2: W, y1: y(t), y2: y(t) }, svg);
mk('text', { class: 'bpd-tick', x: left - 6, y: y(t), dy: '.35em', 'text-anchor': 'end' }, svg).textContent = round(t) + suf;
}
var n = c.boxes.length, slot = (W - left) / n;
var bw = Math.min(56, slot * 0.46);
c.parts = c.boxes.map(function (b, i) {
var cx = left + slot * (i + 0.5);
var g = mk('g', {}, svg);
var p = { cx: cx, bw: bw, y: y, b: b };
p.whUp = mk('line', { class: 'bpd-wh', x1: cx, x2: cx, stroke: b.color }, g);
p.whDn = mk('line', { class: 'bpd-wh', x1: cx, x2: cx, stroke: b.color }, g);
p.capUp = mk('line', { class: 'bpd-wh', y1: y(b.max), y2: y(b.max), stroke: b.color }, g);
p.capDn = mk('line', { class: 'bpd-wh', y1: y(b.min), y2: y(b.min), stroke: b.color }, g);
p.box = mk('rect', { class: 'bpd-box', x: cx - bw / 2, width: bw, rx: 4, fill: b.color, 'fill-opacity': .22, stroke: b.color }, g);
p.med = mk('line', { class: 'bpd-med', y1: y(b.med), y2: y(b.med) }, g);
p.out = b.out.map(function (v, j) {
return mk('circle', { class: 'bpd-out', cx: cx + ((j % 3) - 1) * Math.min(6, bw / 6), cy: y(v), r: 0, fill: b.color }, g);
});
var name = mk('text', { class: 'bpd-name', x: cx, y: H - 5, 'text-anchor': 'middle' }, svg);
name.textContent = b.label;
return p;
});
c.el.innerHTML = '';
c.el.appendChild(svg);
render(c);
}
// Box i at k (0–1): median, then box, then whiskers, caps and outliers.
function draw(p, k) {
var b = p.b, y = p.y, cx = p.cx, hw = p.bw / 2;
var m = ease(k / 0.22), bx = back((k - 0.15) / 0.35), wh = ease((k - 0.45) / 0.3), cap = back((k - 0.7) / 0.2);
p.med.setAttribute('x1', cx - hw * m);
p.med.setAttribute('x2', cx + hw * m);
p.med.style.opacity = k > 0 ? 1 : 0;
var top = y(b.med) + (y(b.q3) - y(b.med)) * bx, bot = y(b.med) + (y(b.q1) - y(b.med)) * bx;
p.box.setAttribute('y', Math.min(top, bot));
p.box.setAttribute('height', Math.max(0, Math.abs(bot - top)));
p.box.style.opacity = bx > 0 ? 1 : 0;
p.whUp.setAttribute('y1', y(b.q3)); p.whUp.setAttribute('y2', y(b.q3) + (y(b.max) - y(b.q3)) * wh);
p.whDn.setAttribute('y1', y(b.q1)); p.whDn.setAttribute('y2', y(b.q1) + (y(b.min) - y(b.q1)) * wh);
p.whUp.style.opacity = p.whDn.style.opacity = wh > 0 ? 1 : 0;
var cw = hw * 0.55 * cap;
p.capUp.setAttribute('x1', cx - cw); p.capUp.setAttribute('x2', cx + cw);
p.capDn.setAttribute('x1', cx - cw); p.capDn.setAttribute('x2', cx + cw);
p.capUp.style.opacity = p.capDn.style.opacity = cap > 0 ? 1 : 0;
p.out.forEach(function (o, j) { o.setAttribute('r', Math.max(0, 4 * back((k - 0.8 - j * 0.04) / 0.2))); });
}
function render(c) {
if (!c.parts) return;
c.parts.forEach(function (p, i) { draw(p, c.k[i]); });
}
function play(c) {
var d = c.el.dataset;
var step = +d.step || DEFAULTS.step, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var st = c.boxes.map(function () { return { k: 0 }; });
c.k = c.k.map(function () { return 0; });
render(c);
var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
st.forEach(function (s, i) {
tl.add(s, { k: 1, duration: step * 1.6, ease: 'linear', onUpdate: function () { c.k[i] = s.k; } }, i * stagger);
});
if (d.loop !== undefined && d.loop !== 'false') {
tl.then(function () {
setTimeout(function () {
var o = { k: 1 };
anime.animate(o, {
k: 0, duration: 650, ease: 'in(2)',
onUpdate: function () { c.k = c.k.map(function () { return o.k; }); render(c); },
onComplete: function () { setTimeout(function () { play(c); }, 250); }
});
}, DEFAULTS.hold);
});
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._bpd);
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._bpd;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-box-plot-draw]').forEach(function (el) {
if (el._bpd) return;
var c = setup(el);
ro.observe(el);
if (reduced()) { c.k = c.k.map(function () { return 1; }); render(c); return; }
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-box-plot-draw style="height: 260px">
<span data-values="12,15,18,19,21,22,24,31,48">Team A</span>
<span data-stats="8,14,19,25,34" data-outliers="46">Team B</span>
</div>