← Charts

Axis Rescale

Switch between figures of very different sizes and the y axis rescales smoothly to fit. Old gridlines slide away and fade, new ones slide in with their labels, and the line eases to its new shape, so you never lose track of what changed.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-axis-rescale to a container with a height, and put each set of figures in data-sets: commas between values, semicolons between sets.

Options

Add any of these attributes to the same element.

data-namesComma-separated names, one per set, shown as a title or on the buttons.
data-controlsAdd a button per set. Using one stops the loop.
data-labelsComma-separated labels along the bottom.
data-colorLine colour. Default #a855f7.
data-prefix / data-suffixText around the axis figures, e.g. $.
data-durationTime to rescale. Default 1000 ms.
data-loopStep through the sets by itself.
data-holdTime on each set when looping. Default 2000 ms.

Large axis figures are shortened to k and M. From a script: animacious.axisRescale(el, 2) shows the third set.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Axis Rescale: a line chart whose data changes between sets of very
    // different sizes. The y axis rescales smoothly: old gridlines slide away
    // and fade while new ones slide in, and the line eases to its new shape
    // so it always fits the chart.
    // Usage:
    //   <div data-axis-rescale data-names="Day,Week,Month"
    //        data-sets="12,18,15,26,22; 120,160,140,210,190; 900,1400,1100,2300,2000"
    //        style="height:240px"></div>
    // data-sets holds one comma-separated set of values per view, separated by
    // semicolons. Every set should have the same number of values.
    // Optional: data-names (one per set, shown as a title or as buttons),
    // data-labels (comma-separated x labels), data-controls (add a button per
    // set; using one stops the loop), data-color (default #a855f7),
    // data-prefix / data-suffix (around the axis figures), data-duration (ms
    // to rescale, default 1000), data-loop (step through the sets by itself),
    // data-hold (ms on each set when looping, default 2000).
    // Script use: animacious.axisRescale(el, index) shows set number index.
    var DEFAULTS = { duration: 1000, hold: 2000, color: '#a855f7' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.axr{position:relative;display:flex;flex-direction:column;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.axr-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:center;margin-bottom:8px;min-height:24px}' +
      '.axr-btn{font:inherit;font-size:12px;font-weight:600;padding:4px 11px;border-radius:999px;border:1px solid currentColor;border-color:rgba(127,127,127,.35);background:transparent;color:inherit;cursor:pointer;transition:background .2s,color .2s,border-color .2s}' +
      '.axr-btn[aria-pressed="true"]{background:var(--axr-c);border-color:var(--axr-c);color:#fff}' +
      '.axr-title{font-weight:700;font-size:13px}' +
      '.axr-plot{position:relative;flex:1;min-height:0}' +
      '.axr svg{position:absolute;inset:0;width:100%;height:100%;overflow:hidden}' +
      '.axr-grid{stroke:currentColor;stroke-opacity:.1}' +
      '.axr-tick{fill:currentColor;fill-opacity:.55;text-anchor:end}' +
      '.axr-xlab{fill:currentColor;fill-opacity:.55;text-anchor:middle}' +
      '.axr-axis{stroke:currentColor;stroke-opacity:.3}' +
      '.axr-line{fill:none;stroke-width:2.5;stroke-linejoin:round}' +
      '.axr-dot{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 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 compact(v) {
      var a = Math.abs(v);
      if (a >= 1e6) return +(v / 1e6).toFixed(1) + 'M';
      if (a >= 1e3) return +(v / 1e3).toFixed(1) + 'k';
      return +v.toFixed(2) + '';
    }

    function setup(el) {
      var d = el.dataset;
      var sets = (d.sets || '').split(';').map(function (s) {
        return s.split(',').map(parseFloat).filter(function (v) { return !isNaN(v); });
      }).filter(function (s) { return s.length > 1; });
      if (!sets.length) return null;
      var names = d.names ? d.names.split(',') : sets.map(function (s, i) { return 'Set ' + (i + 1); });
      var color = d.color || DEFAULTS.color;
      var thumb = !!el.closest('[aria-hidden="true"]');
      var c = {
        el: el, sets: sets, names: names, labels: d.labels ? d.labels.split(',') : [],
        n: sets[0].length, idx: 0, vals: sets[0].slice(), ticks: [], color: color,
        pre: d.prefix || '', suf: d.suffix || ''
      };
      c.max = c.targetMax = maxFor(sets[0]);
      el.classList.add('axr');
      el.style.setProperty('--axr-c', color);
      el.innerHTML = '';
      var bar = document.createElement('div');
      bar.className = 'axr-bar';
      el.appendChild(bar);
      var useBtns = d.controls !== undefined && d.controls !== 'false' && !thumb;
      if (useBtns) {
        bar.setAttribute('role', 'group');
        bar.setAttribute('aria-label', 'Choose data');
        c.btns = names.map(function (nm, i) {
          var b = document.createElement('button');
          b.type = 'button';
          b.className = 'axr-btn';
          b.textContent = nm;
          b.setAttribute('aria-pressed', i === 0 ? 'true' : 'false');
          b.addEventListener('click', function () { c.user = true; show(c, i); });
          bar.appendChild(b);
          return b;
        });
      } else {
        c.title = document.createElement('span');
        c.title.className = 'axr-title';
        c.title.textContent = names[0] || '';
        bar.appendChild(c.title);
      }
      c.plot = document.createElement('div');
      c.plot.className = 'axr-plot';
      c.plot.setAttribute('role', 'img');
      el.appendChild(c.plot);
      describe(c);
      c.ticks = tickValues(c, c.max).map(function (v) { return { v: v, o: 1 }; });
      el._axr = c;
      layout(c);
      return c;
    }

    function maxFor(vs) {
      var hi = Math.max.apply(null, vs) || 1, step = nice(hi);
      return Math.ceil(hi * 1.08 / step) * step;
    }
    function tickValues(c, max) {
      var step = nice(max * 0.8), out = [];
      for (var v = step; v <= max + 1e-9; v += step) out.push(+v.toPrecision(10));
      return out;
    }
    function describe(c) {
      c.plot.setAttribute('aria-label', 'Line chart' + (c.names[c.idx] ? ', ' + c.names[c.idx] : '') + ': ' +
        c.sets[c.idx].map(function (v, i) { return (c.labels[i] ? c.labels[i] + ' ' : '') + c.pre + v + c.suf; }).join(', ') + '.');
    }

    function layout(c) {
      var W = c.plot.clientWidth, H = c.plot.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var widest = 0;
      c.sets.forEach(function (s) {
        tickValues(c, maxFor(s)).forEach(function (v) { widest = Math.max(widest, (c.pre + compact(v) + c.suf).length); });
      });
      c.left = widest * 6.2 + 10;
      c.right = W - 8;
      c.top = 8;
      c.base = H - (c.labels.length ? 20 : 4);
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      c.gTicks = mk('g', {}, svg);
      mk('line', { class: 'axr-axis', x1: c.left, x2: c.right, y1: c.base + .5, y2: c.base + .5 }, svg);
      var slot = (c.right - c.left) / (c.n - 1);
      if (c.labels.length) {
        var longest = Math.max.apply(null, c.labels.map(function (l) { return l.length; }));
        var every = Math.max(1, Math.ceil((longest * 6.4 + 8) / slot));
        c.labels.forEach(function (lab, i) {
          if (i % every) return;
          var anchor = 'middle';
          mk('text', { class: 'axr-xlab', x: c.left + i * slot, y: H - 4, 'text-anchor': anchor }, svg).textContent = lab;
        });
      }
      var grad = 'axr-g' + Math.random().toString(36).slice(2, 7);
      var defs = mk('defs', {}, svg);
      var lg = mk('linearGradient', { id: grad, x1: 0, y1: 0, x2: 0, y2: 1 }, defs);
      mk('stop', { offset: 0, 'stop-color': c.color, 'stop-opacity': .28 }, lg);
      mk('stop', { offset: 1, 'stop-color': c.color, 'stop-opacity': 0 }, lg);
      c.area = mk('path', { fill: 'url(#' + grad + ')' }, svg);
      c.line = mk('path', { class: 'axr-line', stroke: c.color }, svg);
      c.dots = c.vals.map(function () { return mk('circle', { class: 'axr-dot', r: c.n > 14 ? 0 : 3.5, fill: c.color }, svg); });
      c.x = function (i) { return c.left + i * slot; };
      c.tickEls = {};
      c.plot.innerHTML = '';
      c.plot.appendChild(svg);
      c.svg = svg;
      render(c);
    }

    function y(c, v) { return c.base - v / c.max * (c.base - c.top); }

    function render(c) {
      if (!c.svg) return;
      var seen = {};
      c.ticks.forEach(function (t) {
        var key = String(t.v);
        seen[key] = true;
        var g = c.tickEls[key];
        if (!g) {
          g = c.tickEls[key] = mk('g', {}, c.gTicks);
          mk('line', { class: 'axr-grid', x1: c.left, x2: c.right, y1: 0, y2: 0 }, g);
          mk('text', { class: 'axr-tick', x: c.left - 6, y: 0, dy: '.35em' }, g).textContent = c.pre + compact(t.v) + c.suf;
        }
        var yy = y(c, t.v);
        // Lines sliding past the top fade as they leave.
        var edge = Math.max(0, Math.min(1, (yy - c.top + 6) / 14));
        g.setAttribute('transform', 'translate(0,' + yy.toFixed(1) + ')');
        g.style.opacity = t.o * edge;
      });
      Object.keys(c.tickEls).forEach(function (k) {
        if (!seen[k]) { c.gTicks.removeChild(c.tickEls[k]); delete c.tickEls[k]; }
      });
      var pts = c.vals.map(function (v, i) { return c.x(i).toFixed(1) + ',' + y(c, v).toFixed(1); });
      c.line.setAttribute('d', 'M' + pts.join('L'));
      c.area.setAttribute('d', 'M' + c.x(0) + ',' + c.base + 'L' + pts.join('L') + 'L' + c.x(c.n - 1) + ',' + c.base + 'Z');
      c.dots.forEach(function (dot, i) { dot.setAttribute('cx', c.x(i)); dot.setAttribute('cy', y(c, c.vals[i])); });
    }

    function show(c, i) {
      if (!c || !c.sets[i]) return;
      c.idx = i;
      if (c.btns) c.btns.forEach(function (b, j) { b.setAttribute('aria-pressed', j === i ? 'true' : 'false'); });
      if (c.title) c.title.textContent = c.names[i] || '';
      describe(c);
      var dur = +c.el.dataset.duration || DEFAULTS.duration;
      var from = { max: c.max, vals: c.vals.slice() };
      var toMax = maxFor(c.sets[i]), toVals = c.sets[i];
      // The union of the old and new gridlines: old ones fade out, new ones fade in.
      var oldT = {}, newT = {};
      c.ticks.forEach(function (t) { oldT[t.v] = t.o; });
      tickValues(c, toMax).forEach(function (v) { newT[v] = true; });
      var all = {};
      Object.keys(oldT).concat(Object.keys(newT)).forEach(function (k) { all[k] = true; });
      var list = Object.keys(all).map(function (k) { return { v: +k, from: oldT[k] || 0, to: newT[k] ? 1 : 0, o: oldT[k] || 0 }; });
      c.ticks = list;
      if (c.anim) c.anim.pause();
      if (reduced()) {
        c.max = toMax; c.vals = toVals.slice();
        c.ticks = list.filter(function (t) { return t.to; }).map(function (t) { t.o = 1; return t; });
        render(c);
        return;
      }
      var s = { k: 0 };
      c.anim = anime.animate(s, {
        k: 1, duration: dur, ease: 'inOut(3)',
        onUpdate: function () {
          var k = s.k;
          // Scale in the log domain so a jump of 100x feels as even as one of 2x.
          c.max = Math.exp(Math.log(from.max) + (Math.log(toMax) - Math.log(from.max)) * k);
          // Each point moves as a share of the chart's height, so the line
          // morphs between shapes and never leaves the chart.
          c.vals = from.vals.map(function (v, j) {
            var a = v / from.max, b = (toVals[j] || 0) / toMax;
            return (a + (b - a) * k) * c.max;
          });
          // Old gridlines fade quickly; new ones fade in once the scale is moving.
          list.forEach(function (t) {
            if (t.to && t.from) t.o = 1;
            else if (t.to) t.o = Math.max(0, Math.min(1, (k - 0.25) / 0.55));
            else t.o = t.from * Math.max(0, 1 - k / 0.4);
          });
          render(c);
        },
        onComplete: function () {
          c.ticks = list.filter(function (t) { return t.to; });
          render(c);
        }
      });
    }

    function loop(c) {
      var hold = +c.el.dataset.hold || DEFAULTS.hold;
      clearTimeout(c.timer);
      c.timer = setTimeout(function tick() {
        if (c.user) return;
        if (c.visible) show(c, (c.idx + 1) % c.sets.length);
        c.timer = setTimeout(tick, hold + (+c.el.dataset.duration || DEFAULTS.duration));
      }, hold);
    }

    window.animacious = window.animacious || {};
    window.animacious.axisRescale = function (el, i) { var c = el._axr; if (c) { c.user = true; show(c, i); } };

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._axr;
          c.visible = e.isIntersecting;
          if (e.isIntersecting && !c.started) {
            c.started = true;
            var d = e.target.dataset;
            if (d.loop !== undefined && d.loop !== 'false') loop(c);
          }
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target.parentNode._axr;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-axis-rescale]').forEach(function (el) {
        if (el._axr) return;
        var c = setup(el);
        if (!c) return;
        ro.observe(c.plot);
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-axis-rescale data-controls data-names="Day,Week,Month"
     data-sets="42,58,51,77,69; 380,520,610,470,720; 2400,3100,2900,4200,3800"
     style="height: 260px"></div>