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
- Paste the snippet just before
</head>. - Add
data-travel-time-ringsto a container with your map in it (an<img>or inline SVG). - Put the centre where it belongs with
data-xanddata-y, from 0 to 100 across and down the map.
Options
Add any of these attributes to the same element.
data-x / data-y | Where the centre is, 0–100 from the top left. Default 50. |
data-times | The times, separated by commas. Default 5,10,15. |
data-unit | Shown after each time. Default min. |
data-colors | Ring colours, inside to outside. Default #7c3aed,#a855f7,#d946ef. |
data-roads | Angles the rings stretch along, in degrees: 0 is right, 90 is down. Default 20,160,265. |
data-reach | Size of the outer ring, as a share of the map. Default 0.9. |
data-loop | Fade 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.
<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><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>