← Diagrams & maps

Travel-time Rings

Show how far you can get from a spot on the map. A ripple goes out from the pin and rings spread one after another for 5, 10 and 15 minutes. They aren’t perfect circles: each wobbles a little and reaches further along the main roads, and a label settles on top of each one.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-travel-time-rings to a container with your map in it (an <img> or inline SVG).
  3. Put the centre where it belongs with data-x and data-y, from 0 to 100 across and down the map.

Options

Add any of these attributes to the same element.

data-x / data-yWhere the centre is, 0–100 from the top left. Default 50.
data-timesThe times, separated by commas. Default 5,10,15.
data-unitShown after each time. Default min.
data-colorsRing colours, inside to outside. Default #7c3aed,#a855f7,#d946ef.
data-roadsAngles the rings stretch along, in degrees: 0 is right, 90 is down. Default 20,160,265.
data-reachSize of the outer ring, as a share of the map. Default 0.9.
data-loopFade the rings and spread them again.

The rings are an illustration, not a real travel-time calculation: point data-roads along your map’s main roads so they look right.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Travel-time Rings: from a pin on a map, rings spread out one after
    // another to show how far you can get in 5, 10 and 15 minutes. Each ring
    // is a wobbly shape, reaching further along the main roads, and gets a
    // label as it settles.
    // Usage:
    //   <div data-travel-time-rings data-x="45" data-y="55">
    //     <img src="map.png" alt="Map of the neighbourhood">
    //   </div>
    // Optional: data-x / data-y (where the centre is, 0-100 across and down
    // the map, default 50), data-times (comma-separated, default 5,10,15),
    // data-unit (after each time, default "min"), data-colors (inner to
    // outer, default #7c3aed,#a855f7,#d946ef), data-roads (comma-separated
    // compass angles in degrees that the rings stretch along, e.g. 0,90 for
    // east and south; default 20,160,265), data-reach (outer ring size as a
    // share of the map, default 0.9), data-loop (fade and spread again).
    var DEFAULTS = { times: '5,10,15', unit: 'min', colors: '#7c3aed,#a855f7,#d946ef', roads: '20,160,265', reach: 0.9 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.ttr{position:relative;display:block;line-height:0;overflow:hidden;border-radius:.8em}' +
      '.ttr>img,.ttr>svg:first-child{display:block;width:100%;height:auto}' +
      '.ttr-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}' +
      '.ttr-ring{transform-origin:0 0}' +
      '.ttr-tag{position:absolute;translate:-50% -50%;line-height:1.2;padding:.2em .6em;border-radius:99px;color:#fff;font-weight:700;font-size:.82em;white-space:nowrap;box-shadow:0 .15em .5em rgba(30,10,60,.2);opacity:0}' +
      '.ttr-pin{position:absolute;width:1.1em;height:1.1em;margin:-.55em 0 0 -.55em;border-radius:50%;background:#fff;border:.28em solid var(--ttr-c);box-sizing:border-box;box-shadow:0 .1em .4em rgba(30,10,60,.35)}';
    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 closed smooth curve (Catmull-Rom turned into cubic Béziers).
    function smooth(pts) {
      var n = pts.length, d = 'M' + pts[0][0].toFixed(1) + ' ' + pts[0][1].toFixed(1);
      for (var i = 0; i < n; i++) {
        var p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n], p3 = pts[(i + 2) % n];
        d += 'C' + (p1[0] + (p2[0] - p0[0]) / 6).toFixed(1) + ' ' + (p1[1] + (p2[1] - p0[1]) / 6).toFixed(1) + ' ' +
          (p2[0] - (p3[0] - p1[0]) / 6).toFixed(1) + ' ' + (p2[1] - (p3[1] - p1[1]) / 6).toFixed(1) + ' ' +
          p2[0].toFixed(1) + ' ' + p2[1].toFixed(1);
      }
      return d + 'Z';
    }

    function setup(el) {
      var d = el.dataset;
      var s = (el._ttr = { rings: [] });
      el.classList.add('ttr');
      s.times = (d.times || DEFAULTS.times).split(',').map(function (t) { return t.trim(); });
      s.colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      s.roads = (d.roads || DEFAULTS.roads).split(',').map(function (a) { return +a * Math.PI / 180; });
      s.cx = (d.x !== undefined ? +d.x : 50) / 100;
      s.cy = (d.y !== undefined ? +d.y : 50) / 100;
      // A fixed wobble for this map, shared by every ring so they nest.
      s.wobble = [2, 3, 5].map(function (k) { return { k: k, a: 0.03 + Math.random() * 0.04, p: Math.random() * 6.28 }; });
      var color = function (i) { return s.colors[Math.min(i, s.colors.length - 1)]; };
      el.style.setProperty('--ttr-c', color(0));
      s.svg = mk('svg', { class: 'ttr-svg', 'aria-hidden': 'true' });
      s.wave = mk('circle', { fill: 'none', 'stroke-width': 2, opacity: 0 }, s.svg);
      for (var i = s.times.length - 1; i >= 0; i--) {
        var g = mk('g', {}, s.svg);
        var ring = mk('g', { class: 'ttr-ring' }, g);
        var path = mk('path', { fill: color(i), 'fill-opacity': 0.16, stroke: color(i), 'stroke-width': 2, 'stroke-linejoin': 'round' }, ring);
        var tag = document.createElement('span');
        tag.className = 'ttr-tag';
        tag.setAttribute('aria-hidden', 'true');
        tag.style.background = color(i);
        tag.textContent = s.times[i] + ' ' + (d.unit !== undefined ? d.unit : DEFAULTS.unit);
        s.rings[i] = { g: g, ring: ring, path: path, tag: tag };
      }
      el.appendChild(s.svg);
      s.rings.forEach(function (r) { el.appendChild(r.tag); });
      s.pin = document.createElement('span');
      s.pin.className = 'ttr-pin';
      s.pin.setAttribute('aria-hidden', 'true');
      el.appendChild(s.pin);
      var unit = d.unit !== undefined ? d.unit : DEFAULTS.unit;
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Map with rings showing how far you can travel in ' + s.times.join(', ') + ' ' + unit);
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
    }

    // Shape each ring for the current size: a circle that bulges along the
    // roads and wobbles a little, scaled so the outer ring fits the map.
    function layout(el) {
      var s = el._ttr, w = el.clientWidth, h = el.clientHeight;
      if (!w || !h) return;
      s.svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
      var cx = s.cx * w, cy = s.cy * h, n = s.times.length;
      var room = Math.min(w, h) / 2 * (+el.dataset.reach || DEFAULTS.reach);
      s.pin.style.left = cx + 'px';
      s.pin.style.top = cy + 'px';
      s.wave.setAttribute('cx', cx);
      s.wave.setAttribute('cy', cy);
      s.wave.setAttribute('stroke', s.colors[0]);
      s.rings.forEach(function (r, i) {
        var f = (i + 1) / n, base = room * (0.3 + 0.7 * f) / 1.25, pts = [], top = null;
        for (var a = 0; a < 72; a++) {
          var th = a / 72 * Math.PI * 2, m = 1;
          s.wobble.forEach(function (o) { m += o.a * f * Math.sin(o.k * th + o.p); });
          s.roads.forEach(function (road) {
            var dd = Math.atan2(Math.sin(th - road), Math.cos(th - road));
            m += 0.32 * (0.5 + f * 0.5) * Math.exp(-dd * dd / 0.05);
          });
          var rx = base * m * Math.min(1.35, w / h), ry = base * m;
          var p = [Math.cos(th) * rx, Math.sin(th) * ry];
          pts.push(p);
          if (!top || p[1] < top[1]) top = p;
        }
        r.g.setAttribute('transform', 'translate(' + cx + ' ' + cy + ')');
        r.path.setAttribute('d', smooth(pts));
        r.top = Math.abs(top[1]);
        r.tag.style.left = cx + 'px';
        r.tag.style.top = Math.max(10, cy - r.top) + 'px';
      });
      s.room = room;
    }

    function play(el) {
      var s = el._ttr, step = 1100;
      s.rings.forEach(function (r) { anime.utils.set(r.ring, { scale: 0, opacity: 1 }); anime.utils.set(r.tag, { opacity: 0 }); });
      var tl = anime.createTimeline();
      tl.add(s.pin, { scale: [0, 1.25, 1], duration: 600, ease: 'out(3)' }, 0);
      s.rings.forEach(function (r, i) {
        var at = 450 + i * step;
        tl.add(r.ring, { scale: [i ? 0.4 : 0, 1], duration: 1000, ease: 'outQuart' }, at)
          .add(r.path, { fillOpacity: [0.45, 0.16], duration: 1400, ease: 'out(2)' }, at)
          .add(r.tag, { opacity: [0, 1], scale: [0.6, 1], duration: 420, ease: 'outBack(2)' }, at + 600)
          .add(s.wave, { r: [0, s.room * 1.15], opacity: [0.7, 0], strokeWidth: [3, 1], duration: 1050, ease: 'out(2)' }, at);
      });
      if (el.dataset.loop === undefined) return;
      var end = 450 + s.rings.length * step + 2600;
      setTimeout(function () {
        anime.animate(s.rings.map(function (r) { return r.ring; }).concat(s.rings.map(function (r) { return r.tag; })), {
          opacity: 0, duration: 500, ease: 'in(2)',
        });
        setTimeout(function () { play(el); }, 700);
      }, end);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-travel-time-rings]').forEach(function (el) {
        if (el._ttr) return;
        setup(el);
        if (reduced()) el._ttr.rings.forEach(function (r) { r.tag.style.opacity = 1; });
        else {
          el._ttr.rings.forEach(function (r) { anime.utils.set(r.ring, { scale: 0 }); });
          io.observe(el);
        }
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-travel-time-rings data-x="30" data-y="55" data-roads="0,180,90">
  <img src="map.png" alt="Map of the neighbourhood">
</div>