Spiral Timeline Chart
A time series wound into a spiral, one turn per year, so each month lines up with the same month in other years. The spiral winds outwards from the centre and each month’s bar grows from it as the line passes, shaded by size, while the year counts up in the middle. Point at or tap a bar to read it.
How to use it
- Paste the snippet just before
</head>. - Add
data-spiral-timeline-chartto a container with a height, withdata-valueslisting the figures in order, one per month, anddata-startthe first year.
Options
Add any of these attributes to the same element.
data-start | The first year, shown in the middle and at the start of each turn. |
data-per-turn | Points per turn. Default 12, for months. |
data-labels | Comma-separated names round the edge, or false. Default the month initials. |
data-color / data-low-color | Bar colours for the biggest and smallest figures. Default deep purple and pale lilac. |
data-prefix / data-suffix | Text around the figures, e.g. k. |
data-duration | Time for the whole spiral to wind out. Default 4200 ms. |
data-loop | Fade and wind out again every few seconds. |
Spirals show seasons well: busy months stack up on the same side, turn after turn.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spiral Timeline Chart: a time series wound into a spiral, one turn per
// year, so the same month lines up from year to year. The spiral winds
// outwards from the centre and each month's bar grows out from it as the
// line passes, while the year counts up in the middle. Point at (or tap)
// a bar to read it.
// Usage:
// <div data-spiral-timeline-chart data-start="2021"
// data-values="12,15,19,24,30,34,36,33,27,20,15,12,14,17,…"
// style="height:280px"></div>
// data-values holds the figures in order, one per month by default.
// Optional: data-start (the first year, default none), data-per-turn
// (points per turn, default 12), data-labels (comma-separated names round
// the edge, default the month initials when per-turn is 12, or "false"),
// data-color / data-low-color (bar colours for the biggest and smallest
// figures, default deep purple and pale lilac), data-prefix /
// data-suffix (around the figures), data-duration (ms for the whole
// spiral, default 4200), data-loop (fade and wind again).
var DEFAULTS = { duration: 4200, hold: 3000, color: '#7c3aed', low: '#e9d5ff' };
var MONTHS = 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(',');
var NS = 'http://www.w3.org/2000/svg';
var css =
'.sptc{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
'.sptc svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.sptc-guide{fill:none;stroke:currentColor;stroke-opacity:.18;stroke-width:1.2}' +
'.sptc-trail{fill:none;stroke-width:1.6;stroke-linecap:round}' +
'.sptc-bar{transition:opacity .2s}' +
'.sptc-mon{fill:currentColor;fill-opacity:.5;text-anchor:middle}' +
'.sptc-yr{fill:currentColor;fill-opacity:.7;font-weight:700;text-anchor:middle;paint-order:stroke;stroke:#fff;stroke-width:3px;stroke-linejoin:round}' +
'.sptc-mid{fill:currentColor;font-weight:800;text-anchor:middle}' +
'.sptc-sub{fill:currentColor;fill-opacity:.6;text-anchor:middle}' +
'.sptc-hit .sptc-bar{opacity:.35}' +
'.sptc-hit .sptc-bar.sptc-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 outBack(t) { var s = 1.6; t -= 1; return t * t * ((s + 1) * t + s) + 1; }
function rgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function setup(el) {
var d = el.dataset;
var vs = (d.values || '').split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
if (!vs.length) return null;
var per = Math.max(2, parseInt(d.perTurn, 10) || 12);
var labels = d.labels === 'false' ? [] : d.labels ? d.labels.split(',') : per === 12 ? 'J,F,M,A,M,J,J,A,S,O,N,D'.split(',') : [];
var lo = Math.min.apply(null, vs), hi = Math.max.apply(null, vs);
var pre = d.prefix || '', suf = d.suffix || '';
var dec = vs.some(function (v) { return v % 1; }) ? 1 : 0;
var start = d.start !== undefined ? parseInt(d.start, 10) : NaN;
var c = {
el: el, vs: vs, per: per, labels: labels, lo: Math.min(0, lo), min: lo, hi: hi || 1, start: start, p: 0, fade: 1, on: -1,
hiC: rgb(d.color || DEFAULTS.color), loC: rgb(d.lowColor || DEFAULTS.low),
fmt: function (v) { return pre + v.toFixed(dec) + suf; }
};
c.when = function (i) {
var turn = Math.floor(i / per), m = i % per;
var mn = per === 12 ? MONTHS[m] : '#' + (m + 1);
return isNaN(start) ? mn + ' (turn ' + (turn + 1) + ')' : mn + ' ' + (start + turn);
};
el.classList.add('sptc');
el.setAttribute('role', 'img');
var turns = Math.ceil(vs.length / per), summary = [];
for (var t = 0; t < turns; t++) {
var part = vs.slice(t * per, t * per + per);
summary.push((isNaN(start) ? 'Turn ' + (t + 1) : start + t) + ': ' + part.map(c.fmt).join(', '));
}
el.setAttribute('aria-label', 'Spiral timeline chart, one turn per ' + (per === 12 ? 'year' : per + ' points') + '. ' + summary.join('. ') + '.');
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') hover(c, -1); });
document.addEventListener('pointerdown', function (e) { if (c.on >= 0 && !el.contains(e.target)) hover(c, -1); });
el._sptc = c;
layout(c);
return c;
}
function sector(cx, cy, r0, r1, a0, a1) {
var c0 = Math.cos(a0), s0 = Math.sin(a0), c1 = Math.cos(a1), s1 = Math.sin(a1);
return 'M' + (cx + r0 * c0) + ',' + (cy + r0 * s0) + 'L' + (cx + r1 * c0) + ',' + (cy + r1 * s0) +
'A' + r1 + ',' + r1 + ' 0 0 1 ' + (cx + r1 * c1) + ',' + (cy + r1 * s1) +
'L' + (cx + r0 * c1) + ',' + (cy + r0 * s1) + 'A' + r0 + ',' + r0 + ' 0 0 0 ' + (cx + r0 * c0) + ',' + (cy + r0 * s0) + 'Z';
}
function layout(c) {
var el = c.el, W = el.clientWidth, H = el.clientHeight;
if (!W || !H) return;
c.W = W; c.H = H;
var S = Math.min(W, H), cx = W / 2, cy = H / 2;
var pad = c.labels.length ? 16 : 4;
var Rmax = S / 2 - pad;
var N = c.vs.length, per = c.per;
var r0 = Rmax * 0.26;
var spacing = (Rmax - r0) / (N / per + 0.95);
c.cx = cx; c.cy = cy;
c.rad = function (t) { return r0 + spacing * t / per; };
c.ang = function (t) { return (t + 0.5) / per * Math.PI * 2 - Math.PI / 2; };
c.pt = function (t) { var r = c.rad(t), a = c.ang(t); return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; };
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
var g = '';
for (var t = -0.5; t <= N - 0.5 + 1e-6; t += 0.1) { var q = c.pt(t); g += (g ? 'L' : 'M') + q[0].toFixed(1) + ',' + q[1].toFixed(1); }
mk('path', { class: 'sptc-guide', d: g }, svg);
c.trail = mk('path', { class: 'sptc-trail', stroke: 'rgb(' + c.hiC.join(',') + ')' }, svg);
var half = Math.PI / per * 0.74;
var bars = mk('g', {}, svg);
c.svg = svg;
c.bars = c.vs.map(function (v, i) {
var rIn = c.rad(i) + 1.5, len = Math.max(1.5, (v - c.lo) / (c.hi - c.lo) * spacing * 0.86);
var f = c.hi > c.min ? (v - c.min) / (c.hi - c.min) : 1;
var col = 'rgb(' + c.loC.map(function (x, j) { return Math.round(x + (c.hiC[j] - x) * f); }).join(',') + ')';
var p = mk('path', { class: 'sptc-bar', fill: col }, bars);
p.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') hover(c, i); });
p.addEventListener('click', function () { hover(c, c.on === i ? -1 : i); });
return { el: p, rIn: rIn, len: len, a: c.ang(i), half: half };
});
var fs = Math.max(9, Math.min(12, S / 26));
c.labels.forEach(function (l, m) {
var a = (m + 0.5) / per * Math.PI * 2 - Math.PI / 2, r = Rmax + pad * 0.55;
mk('text', { class: 'sptc-mon', x: cx + r * Math.cos(a), y: cy + r * Math.sin(a), dy: '.35em', 'font-size': fs - 1 }, svg).textContent = l;
});
// year names at twelve o'clock, where each turn begins
c.years = [];
if (!isNaN(c.start) && spacing >= 15) {
for (var k = 0; k * per < N; k++) {
var y = cy - c.rad(k * per - 0.5) - spacing * 0.08;
var tx = mk('text', { class: 'sptc-yr', x: cx, y: y, 'font-size': Math.min(10, spacing * 0.55) }, svg);
tx.textContent = c.start + k;
c.years.push({ el: tx, at: k * per });
}
}
c.mid = mk('text', { class: 'sptc-mid', x: cx, y: cy, dy: '.35em', 'font-size': Math.max(11, Math.min(22, r0 * 0.5)) }, svg);
c.sub = mk('text', { class: 'sptc-sub', x: cx, y: cy + Math.min(22, r0 * 0.5) * 0.95, dy: '.35em', 'font-size': Math.max(8, Math.min(11, r0 * 0.26)) }, svg);
el.innerHTML = '';
el.appendChild(svg);
render(c);
}
function render(c) {
if (!c.bars) return;
var N = c.vs.length, p = c.p * N;
c.svg.style.opacity = c.fade;
c.bars.forEach(function (b, i) {
var k = clamp01((p - i) / 2.2);
var e = k >= 1 ? 1 : outBack(k);
b.el.style.display = k > 0 ? '' : 'none';
if (k > 0) b.el.setAttribute('d', sector(c.cx, c.cy, b.rIn, b.rIn + b.len * e, b.a - b.half, b.a + b.half));
});
var end = Math.min(p, N) - 0.5, tr = '';
for (var t = -0.5; t < end; t += 0.1) { var q = c.pt(t); tr += (tr ? 'L' : 'M') + q[0].toFixed(1) + ',' + q[1].toFixed(1); }
if (tr) { var h = c.pt(end); tr += 'L' + h[0] + ',' + h[1]; }
c.trail.setAttribute('d', tr);
c.trail.style.opacity = c.p >= 1 ? 0.3 : 0.6;
c.years.forEach(function (y) { y.el.style.opacity = clamp01(p - y.at + 0.5); });
if (c.on >= 0) {
c.mid.textContent = c.fmt(c.vs[c.on]);
c.sub.textContent = c.when(c.on);
return;
}
var idx = Math.max(0, Math.min(N - 1, Math.floor(p)));
c.mid.textContent = isNaN(c.start) ? (p > 0 ? c.fmt(c.vs[idx]) : '') : String(c.start + Math.floor(idx / c.per));
c.sub.textContent = isNaN(c.start) ? '' : (p > 0 ? c.fmt(c.vs[idx]) : '');
}
function hover(c, i) {
c.on = i;
if (!c.svg) return;
c.svg.classList.toggle('sptc-hit', i >= 0);
c.bars.forEach(function (b, k) { b.el.classList.toggle('sptc-on', k === i); });
render(c);
}
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.5)',
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;
if (c.visible) setTimeout(function () { play(c); }, 250); else c.waiting = true;
}
});
}, DEFAULTS.hold);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._sptc;
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._sptc;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-spiral-timeline-chart]').forEach(function (el) {
if (el._sptc) 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-spiral-timeline-chart data-start="2022" data-suffix="k"
data-values="12,15,19,24,30,34,36,33,27,20,15,12,14,17,22,27,33,38,40,37,30,23,17,14"
style="height: 280px"></div>