Series Highlight
A line chart with several series. Point at a line, or its name in the legend, and it thickens and comes to the front while the rest fade back, so a busy chart can be read one line at a time. Tap on a phone.
LisbonMadridRomeParisLondon
How to use it
- Paste the snippet just before
</head>. - Add
data-series-highlightto a container with a height, with one child per line: its text is the name anddata-valuesits figures.
Options
Add any of these attributes to the same element.
data-labels | Comma-separated labels along the bottom. |
data-colors | Comma-separated colours, one per line. Default purple, pink, blue… |
data-color | On a line: its colour. |
data-suffix | Text after the end figures, e.g. °. |
data-legend | false hides the legend and puts names at the line ends. |
data-autoplay | Step through the lines by itself until someone points at one. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Series Highlight: a chart with several lines. Point at one line, or its
// name in the legend, and it thickens and comes to the front while the
// others fade back, so a busy chart can be read one series at a time.
// Tap on a phone. With data-autoplay it steps through the series by
// itself until someone uses it.
// Usage:
// <div data-series-highlight data-labels="Jan,Feb,Mar,Apr" style="height:260px">
// <span data-values="12,18,15,26">London</span>
// <span data-values="8,10,14,13">Paris</span>
// </div>
// Each child is one line: its text is the name, data-values its values.
// Optional: data-labels (comma-separated x labels), data-colors (one per
// line, default purple, pink, blue…), data-color on a line, data-suffix
// (after the end values), data-legend="false" (no legend), data-autoplay
// (step through the lines until a real pointer arrives).
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
var NS = 'http://www.w3.org/2000/svg';
var STEP = 1700;
var css =
'.srh{position:relative;display:flex;flex-direction:column;font-size:11px;font-variant-numeric:tabular-nums}' +
'.srh-legend{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 6px;margin-bottom:8px}' +
'.srh-key{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:12px;font-weight:600;color:inherit;background:transparent;border:0;border-radius:999px;padding:3px 9px;cursor:pointer;transition:opacity .3s,background .3s}' +
'.srh-key i{width:10px;height:10px;border-radius:50%;background:var(--srh-c)}' +
'.srh-key.srh-on{background:rgba(127,127,127,.14)}' +
'.srh-dim .srh-key:not(.srh-on){opacity:.4}' +
'.srh-plot{position:relative;flex:1;min-height:0;touch-action:manipulation}' +
'.srh svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.srh-grid{stroke:currentColor;stroke-opacity:.08}' +
'.srh-axis{stroke:currentColor;stroke-opacity:.3}' +
'.srh-tick{fill:currentColor;fill-opacity:.5}' +
'.srh-s{transition:opacity .35s}' +
'.srh-line{fill:none;stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round;transition:stroke-width .35s}' +
'.srh-hit{fill:none;stroke:transparent;stroke-width:16;pointer-events:stroke;cursor:pointer}' +
'.srh-end{stroke:#fff;stroke-width:2;transition:r .35s}' +
'.srh-lab{font-weight:700;transition:opacity .35s}' +
'.srh-lab tspan{fill:currentColor;fill-opacity:.65;font-weight:500}' +
'.srh-dim .srh-s{opacity:.16}' +
'.srh-dim .srh-s.srh-on{opacity:1}' +
'.srh-s.srh-on .srh-line{stroke-width:4}';
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 nice(hi) {
var raw = hi / 4, p = Math.pow(10, Math.floor(Math.log10(raw)));
return [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
}
function dodge(ys, gap, lo, hi) {
var idx = ys.map(function (y, i) { return i; }).sort(function (a, b) { return ys[a] - ys[b]; });
var out = ys.slice();
for (var pass = 0; pass < 20; pass++) {
var moved = false;
for (var j = 1; j < idx.length; j++) {
var a = idx[j - 1], b = idx[j], over = gap - (out[b] - out[a]);
if (over > 0.01) { out[a] -= over / 2; out[b] += over / 2; moved = true; }
}
idx.forEach(function (i) { out[i] = Math.max(lo, Math.min(hi, out[i])); });
if (!moved) break;
}
return out;
}
function setup(el) {
var d = el.dataset;
var colors = d.colors ? d.colors.split(',') : PALETTE;
var series = [];
Array.prototype.forEach.call(el.children, function (ch) {
var vs = (ch.getAttribute('data-values') || '').split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
if (vs.length < 2) return;
series.push({ name: ch.textContent.trim(), vs: vs, color: ch.getAttribute('data-color') || colors[series.length % colors.length] });
});
if (!series.length) return null;
var n = Math.min.apply(null, series.map(function (s) { return s.vs.length; }));
var hi = Math.max.apply(null, series.map(function (s) { return Math.max.apply(null, s.vs); })) || 1;
var step = nice(hi);
var thumb = !!el.closest('[aria-hidden="true"]');
var c = {
el: el, series: series, n: n, labels: d.labels ? d.labels.split(',') : [], step: step, suf: d.suffix || '',
max: Math.ceil(hi * 1.08 / step) * step, on: -1, auto: -1,
autoplay: d.autoplay !== undefined && d.autoplay !== 'false'
};
el.classList.add('srh');
el.innerHTML = '';
if (d.legend !== 'false') {
var lg = document.createElement('div');
lg.className = 'srh-legend';
el.appendChild(lg);
series.forEach(function (s, i) {
var k = document.createElement(thumb ? 'span' : 'button');
if (!thumb) { k.type = 'button'; k.setAttribute('aria-pressed', 'false'); }
k.className = 'srh-key';
k.style.setProperty('--srh-c', s.color);
k.innerHTML = '<i></i>';
k.appendChild(document.createTextNode(s.name));
k.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') user(c, i); });
k.addEventListener('click', function () { user(c, c.on === i && c.userOn ? -1 : i, true); });
lg.appendChild(k);
s.key = k;
});
lg.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') release(c); });
}
c.plot = document.createElement('div');
c.plot.className = 'srh-plot';
c.plot.setAttribute('role', 'img');
c.plot.setAttribute('aria-label', 'Line chart. ' + series.map(function (s) {
return s.name + ': ' + s.vs.slice(0, n).map(function (v, i) { return (c.labels[i] ? c.labels[i] + ' ' : '') + v + c.suf; }).join(', ');
}).join('. ') + '.');
el.appendChild(c.plot);
c.plot.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') release(c); });
c.plot.addEventListener('click', function (e) {
if (!e.target.closest || !e.target.closest('.srh-s')) user(c, -1, true);
});
el._srh = c;
layout(c);
return c;
}
function layout(c) {
var W = c.plot.clientWidth, H = c.plot.clientHeight;
if (!W || !H) return;
c.W = W; c.H = H;
var named = c.el.dataset.legend === 'false';
c.endText = function (s) { return (named ? s.name + ' ' : '') + s.vs[c.n - 1] + c.suf; };
var longest = Math.max.apply(null, c.series.map(function (s) { return c.endText(s).length; }));
var left = String(c.max).length * 6.4 + 10, right = W - (longest * 6.6 + 16), top = 8, base = H - (c.labels.length ? 20 : 6);
var x = function (i) { return left + i / (c.n - 1) * (right - left); };
var y = function (v) { return base - v / c.max * (base - top); };
var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
for (var t = 0; t <= c.max + 1e-9; t += c.step) {
if (t) mk('line', { class: 'srh-grid', x1: left, x2: right, y1: y(t), y2: y(t) }, svg);
mk('text', { class: 'srh-tick', x: left - 6, y: y(t), dy: '.35em', 'text-anchor': 'end' }, svg).textContent = +t.toFixed(2);
}
mk('line', { class: 'srh-axis', x1: left, x2: right, y1: base + .5, y2: base + .5 }, svg);
if (c.labels.length) {
var slot = (right - left) / (c.n - 1);
var longL = Math.max.apply(null, c.labels.map(function (l) { return l.length; }));
var every = Math.max(1, Math.ceil((longL * 6.4 + 8) / slot));
c.labels.slice(0, c.n).forEach(function (lab, i) {
if (i % every) return;
mk('text', { class: 'srh-tick', x: x(i), y: H - 4, 'text-anchor': 'middle' }, svg).textContent = lab;
});
}
c.g = mk('g', {}, svg);
var ends = dodge(c.series.map(function (s) { return y(s.vs[c.n - 1]); }), 13, top, base);
c.series.forEach(function (s, i) {
var d = 'M' + s.vs.slice(0, c.n).map(function (v, j) { return x(j).toFixed(1) + ',' + y(v).toFixed(1); }).join('L');
s.g = mk('g', { class: 'srh-s' }, c.g);
s.line = mk('path', { class: 'srh-line', d: d, stroke: s.color }, s.g);
var hit = mk('path', { class: 'srh-hit', d: d }, s.g);
s.dot = mk('circle', { class: 'srh-end', cx: right, cy: y(s.vs[c.n - 1]), r: 4, fill: s.color }, s.g);
s.lab = mk('text', { class: 'srh-lab', x: right + 9, y: ends[i], dy: '.35em', fill: s.color }, s.g);
s.lab.textContent = c.endText(s);
hit.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') user(c, i); });
hit.addEventListener('click', function (e) { e.stopPropagation(); user(c, c.on === i && c.userOn ? -1 : i, true); });
});
c.plot.innerHTML = '';
c.plot.appendChild(svg);
c.svg = svg;
apply(c);
}
function apply(c) {
c.el.classList.toggle('srh-dim', c.on >= 0);
c.series.forEach(function (s, i) {
var on = i === c.on;
if (s.g) {
s.g.classList.toggle('srh-on', on);
s.dot.setAttribute('r', on ? 6 : 4);
}
if (s.key) {
s.key.classList.toggle('srh-on', on);
if (s.key.tagName === 'BUTTON') s.key.setAttribute('aria-pressed', on ? 'true' : 'false');
}
});
// Bring the picked line to the front.
if (c.on >= 0 && c.series[c.on].g) c.g.appendChild(c.series[c.on].g);
}
function user(c, i, sticky) {
c.userOn = i >= 0;
c.lastUser = Date.now() + (sticky ? 4000 : 0);
c.hovering = !sticky && i >= 0;
c.on = i;
apply(c);
}
function release(c) {
if (!c.hovering) return;
c.hovering = false;
c.userOn = false;
c.lastUser = Date.now();
c.on = -1;
apply(c);
}
// Step: first line, second, …, then all lines together, and round again.
function tick(c) {
c.timer = 0;
if (!c.visible) return;
if (!c.hovering && (!c.lastUser || Date.now() - c.lastUser > 2500)) {
c.userOn = false;
c.auto = c.auto + 1 > c.series.length ? 0 : c.auto + 1;
c.on = c.auto === c.series.length ? -1 : c.auto;
apply(c);
}
c.timer = setTimeout(function () { tick(c); }, c.on === -1 ? STEP * 0.8 : STEP);
}
function drawIn(c) {
c.series.forEach(function (s, i) {
var len = s.line.getTotalLength();
s.line.style.strokeDasharray = len;
s.line.style.strokeDashoffset = len;
s.dot.style.opacity = 0; s.lab.style.opacity = 0;
anime.animate(s.line, {
strokeDashoffset: 0, duration: 1100, delay: i * 140, ease: 'inOut(2)',
onComplete: function () {
s.line.style.strokeDasharray = ''; s.line.style.strokeDashoffset = '';
anime.animate([s.dot, s.lab], { opacity: [0, 1], duration: 300 });
}
});
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._srh;
c.visible = e.isIntersecting;
if (!c.visible) return;
if (!c.drawn) { c.drawn = true; if (!reduced() && c.svg) drawIn(c); }
if (c.autoplay && !reduced() && !c.timer) c.timer = setTimeout(function () { tick(c); }, 1600 + c.series.length * 140);
});
}, { threshold: 0.3 });
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target.parentNode._srh;
if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
});
});
document.querySelectorAll('[data-series-highlight]').forEach(function (el) {
if (el._srh) return;
var c = setup(el);
if (!c) return;
ro.observe(c.plot);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-series-highlight data-labels="Jan,Feb,Mar,Apr" data-suffix="°"
style="height: 260px">
<span data-values="15,16,19,20">Lisbon</span>
<span data-values="10,12,16,18">Madrid</span>
<span data-values="7,8,12,16">Paris</span>
</div>