← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-series-highlight to a container with a height, with one child per line: its text is the name and data-values its figures.

Options

Add any of these attributes to the same element.

data-labelsComma-separated labels along the bottom.
data-colorsComma-separated colours, one per line. Default purple, pink, blue…
data-colorOn a line: its colour.
data-suffixText after the end figures, e.g. °.
data-legendfalse hides the legend and puts names at the line ends.
data-autoplayStep through the lines by itself until someone points at one.
1 · Snippet
<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>
2 · Your HTML
<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>