Histogram with Curve
A histogram whose bars rise one after another, then a smooth bell curve fitted to the data draws itself over them and a dashed line drops in at the average. Give it your raw numbers and it does the binning for you.
How to use it
- Paste the snippet just before
</head>. - Add
data-histogram-with-curve="…values…"to an element with a height, listing your raw numbers. Leave it empty to plot a made-up sample.
Options
Add any of these attributes to the same element.
data-bins | Roughly how many bars. Default 14. |
data-color | Bar colour. Default #a855f7. |
data-curve-color | Curve colour. Default #ec4899. |
data-curve | false shows just the bars. |
data-mean | false hides the average line. |
data-suffix | Text after the axis numbers, e.g. ms. |
data-count | Size of the made-up sample. Default 400. |
data-duration | Time for all the bars to rise. Default 1200 ms. |
data-loop | Drop the bars and draw again. A made-up sample is redrawn fresh each time. |
The curve is a normal distribution with the data’s own average and spread, scaled to the bars.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Histogram with Curve: the bars of a histogram rise one after another,
// then a smooth bell curve fitted to the data draws itself over them and
// a dashed line drops in at the average.
// Usage: <div data-histogram-with-curve="12,15,15,18,…" style="height:240px"></div>
// The attribute holds the raw values (commas or spaces). Leave it empty to
// plot a made-up sample instead (data-count sets its size, default 400;
// with data-loop each round draws a fresh sample).
// Optional: data-bins (how many bars, default 14), data-color (bars,
// default #a855f7), data-curve-color (default #ec4899), data-curve="false"
// (bars only), data-mean="false" (no average line), data-suffix (after
// the axis numbers, e.g. "ms"), data-duration (ms for the bars to rise,
// default 1200), data-loop (drop the bars and draw again).
var DEFAULTS = { bins: 14, count: 400, duration: 1200, hold: 2600, color: '#a855f7', curve: '#ec4899' };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.hwc{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
'.hwc svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.hwc-axis{stroke:currentColor;stroke-opacity:.3}' +
'.hwc-grid{stroke:currentColor;stroke-opacity:.08}' +
'.hwc-tick{fill:currentColor;fill-opacity:.55}' +
'.hwc-curve{fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}' +
'.hwc-under{stroke:none}' +
'.hwc-mean{stroke:currentColor;stroke-opacity:.55;stroke-width:1.5;stroke-dasharray:4 4}' +
'.hwc-mlabel{fill:currentColor;font-weight:700;paint-order:stroke;stroke:#fff;stroke-opacity:.8;stroke-width:3px;stroke-linejoin:round}';
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;
}
// A believable sample: a bell shape with a slight lean.
function sample(n, seed) {
function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
var out = [];
for (var i = 0; i < n; i++) {
var g = Math.sqrt(-2 * Math.log(rnd() || 1e-9)) * Math.cos(2 * Math.PI * rnd());
out.push(Math.round((50 + g * 12 + (g > 0 ? g * 1.5 : 0)) * 10) / 10);
}
return out;
}
function niceStep(raw) {
var p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
// The round step nearest to the raw width.
return [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).sort(function (a, b) {
return Math.abs(Math.log(a / raw)) - Math.abs(Math.log(b / raw));
})[0];
}
// Bin the values and fit a normal curve.
function analyse(c) {
var v = c.values, bins = c.binCount;
var lo = Math.min.apply(null, v), hi = Math.max.apply(null, v);
if (hi === lo) { hi = lo + 1; }
var w = niceStep((hi - lo) / bins);
var start = Math.floor(lo / w) * w;
var n = Math.max(1, Math.ceil((hi - start) / w + 1e-9));
if (start + n * w <= hi) n++;
var counts = [];
for (var i = 0; i < n; i++) counts.push(0);
var sum = 0;
v.forEach(function (x) { counts[Math.min(n - 1, Math.floor((x - start) / w))]++; sum += x; });
var mean = sum / v.length, sq = 0;
v.forEach(function (x) { sq += (x - mean) * (x - mean); });
var sd = Math.sqrt(sq / v.length) || w;
// Room either side so the curve's tails can taper off.
var pad = Math.max(1, Math.round(n * 0.08));
c.h = { start: start, w: w, n: n, counts: counts, mean: mean, sd: sd, x0: start - pad * w, x1: start + (n + pad) * w };
var peak = v.length * w / (sd * Math.sqrt(2 * Math.PI));
c.h.top = Math.max(Math.max.apply(null, counts), peak) * 1.12;
}
function setup(el) {
var d = el.dataset;
var raw = (el.getAttribute('data-histogram-with-curve') || '').split(/[\s,;]+/).filter(function (s) { return s !== '' && !isNaN(+s); }).map(Number);
var c = { el: el, made: raw.length < 2, seed: 11, st: { bars: [], curve: 0, mean: 0 } };
c.count = +d.count || DEFAULTS.count;
c.values = c.made ? sample(c.count, c.seed) : raw;
c.binCount = +d.bins || DEFAULTS.bins;
el.classList.add('hwc');
el.setAttribute('role', 'img');
el._hwc = c;
analyse(c);
label(c);
layout(c);
return c;
}
function label(c) {
var h = c.h, suf = c.el.dataset.suffix || '';
c.el.setAttribute('aria-label', 'Histogram of ' + c.values.length + ' values. Average ' + round(h.mean) + suf +
', spread (standard deviation) ' + round(h.sd) + suf + '.');
}
function round(x) { return Math.abs(x) >= 100 ? Math.round(x) : Math.round(x * 10) / 10; }
function layout(c) {
var d = c.el.dataset, h = c.h;
var W = c.el.clientWidth, H = c.el.clientHeight;
if (!W || !H) return;
c.W = W; c.H = H;
var bot = H - 18, topY = 6;
c.sx = function (x) { return (x - h.x0) / (h.x1 - h.x0) * W; };
c.sy = function (y) { return bot - y / h.top * (bot - topY); };
c.bot = bot;
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
for (var g = 1; g <= 3; g++) mk('line', { class: 'hwc-grid', x1: 0, x2: W, y1: bot - g * (bot - topY) / 3.4, y2: bot - g * (bot - topY) / 3.4 }, svg);
var color = d.color || DEFAULTS.color, curveColor = d.curveColor || DEFAULTS.curve;
var bw = c.sx(h.w) - c.sx(0);
var gap = Math.min(2, bw * 0.15);
c.rects = h.counts.map(function (n, i) {
return mk('rect', { x: c.sx(h.start + i * h.w) + gap / 2, width: Math.max(1, bw - gap), y: bot, height: 0, rx: Math.min(3, bw / 4), fill: color, 'fill-opacity': .8 }, svg);
});
mk('line', { class: 'hwc-axis', x1: 0, x2: W, y1: bot + .5, y2: bot + .5 }, svg);
// Axis numbers: a round step, as many as fit.
var suf = d.suffix || '';
var every = Math.max(1, Math.ceil(46 / (bw * 2)));
var step = h.w * 2 * every;
for (var t = Math.ceil(h.start / step) * step; t <= h.start + h.n * h.w + 1e-9; t += step) {
var tx = c.sx(t);
if (tx < 10 || tx > W - 10) continue;
mk('text', { class: 'hwc-tick', x: tx, y: H - 3, 'text-anchor': 'middle' }, svg).textContent = round(t) + suf;
}
c.curvePath = null;
if (d.curve !== 'false') {
var pts = [], N = 80;
for (var k = 0; k <= N; k++) {
var x = h.x0 + (h.x1 - h.x0) * k / N;
var y = c.values.length * h.w / (h.sd * Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * Math.pow((x - h.mean) / h.sd, 2));
pts.push(c.sx(x).toFixed(1) + ' ' + c.sy(y).toFixed(1));
}
var dPath = 'M' + pts.join(' L');
c.under = mk('path', { class: 'hwc-under', d: dPath + ' L' + W + ' ' + bot + ' L0 ' + bot + 'Z', fill: curveColor, 'fill-opacity': 0 }, svg);
c.curvePath = mk('path', { class: 'hwc-curve', d: dPath, stroke: curveColor }, svg);
c.curveLen = c.curvePath.getTotalLength ? c.curvePath.getTotalLength() : 1000;
c.curvePath.style.strokeDasharray = c.curveLen + ' ' + c.curveLen;
}
c.meanEls = null;
if (d.mean !== 'false') {
var mx = c.sx(h.mean);
c.meanTop = topY + 14;
var ml = mk('line', { class: 'hwc-mean', x1: mx, x2: mx, y1: c.meanTop, y2: bot }, svg);
var anchor = mx < 50 ? 'start' : mx > W - 50 ? 'end' : 'middle';
var mt = mk('text', { class: 'hwc-mlabel', x: mx, y: topY + 8, 'text-anchor': anchor }, svg);
mt.textContent = 'Avg ' + round(h.mean) + suf;
c.meanEls = [ml, mt];
}
c.el.innerHTML = '';
c.el.appendChild(svg);
render(c);
}
function render(c) {
if (!c.rects) return;
var h = c.h, st = c.st;
c.rects.forEach(function (r, i) {
var k = st.bars[i] || 0;
var y = c.sy(h.counts[i] * k);
r.setAttribute('y', Math.min(y, c.bot));
r.setAttribute('height', Math.max(0, c.bot - y));
});
if (c.curvePath) {
c.curvePath.style.strokeDashoffset = c.curveLen * (1 - st.curve);
c.curvePath.style.opacity = st.curve > 0 ? 1 : 0;
c.under.setAttribute('fill-opacity', 0.1 * Math.max(0, st.curve * 2 - 1));
}
if (c.meanEls) {
c.meanEls[0].style.opacity = st.mean;
c.meanEls[1].style.opacity = st.mean;
c.meanEls[0].setAttribute('y1', c.bot - (c.bot - c.meanTop) * st.mean);
}
}
function finish(c) {
c.st.bars = c.h.counts.map(function () { return 1; });
c.st.curve = 1; c.st.mean = 1;
render(c);
}
function play(c) {
var d = c.el.dataset;
var dur = +d.duration || DEFAULTS.duration;
var n = c.h.n;
c.st.bars = c.h.counts.map(function () { return 0; });
c.st.curve = 0; c.st.mean = 0;
render(c);
var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
var each = dur * 0.55, gap = n > 1 ? (dur - each) / (n - 1) : 0;
var bars = c.h.counts.map(function () { return { k: 0 }; });
bars.forEach(function (b, i) {
tl.add(b, { k: 1, duration: each, ease: 'outBack(1.4)', onUpdate: function () { c.st.bars[i] = b.k; } }, i * gap);
});
tl.add(c.st, { curve: 1, duration: 1100, ease: 'inOut(2)' }, dur * 0.85);
tl.add(c.st, { mean: 1, duration: 500, ease: 'out(3)' }, dur * 0.85 + 800);
if (d.loop !== undefined && d.loop !== 'false') {
tl.then(function () {
c.timer = setTimeout(function () {
var out = { k: 1 }, from = c.st.bars.slice();
anime.animate(out, {
k: 0, duration: 450, ease: 'in(2)',
onUpdate: function () {
c.st.bars = from.map(function (v) { return v * out.k; });
c.st.curve = out.k; c.st.mean = out.k;
render(c);
},
onComplete: function () {
if (c.made) {
c.seed = (c.seed * 48271) % 2147483647;
c.values = sample(c.count, c.seed);
analyse(c);
label(c);
c.st.bars = [];
layout(c);
}
setTimeout(function () { play(c); }, 200);
}
});
}, 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._hwc);
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._hwc;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-histogram-with-curve]').forEach(function (el) {
if (el._hwc) return;
var c = setup(el);
ro.observe(el);
if (reduced()) { finish(c); return; }
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-histogram-with-curve="212,198,240,225,231,205,219,250,188,227"
data-suffix="ms" style="height: 240px"></div>