Metro Line Map
A metro map draws itself. Each coloured line snakes out from its first stop, rounding its corners like a printed map, and stations pop up with their names as the line reaches them. Where lines cross, the stop becomes a bold interchange ring.
Westgate
Mill Lane
Park
Central
Museum
Riverside
Eastfield
Southport
Elm Park
Ash Street
Kings Cross
Abbey
Airport
North Hill
Market
Docks
Harbour
How to use it
- Paste the snippet just before
</head>. - Add
data-metro-line-mapto a container and set the size of your map’s grid withdata-view, such as0 0 400 260. - Add a child for each line with
data-route(its corners asx,ypairs) anddata-color, then a child for each station withdata-station="x,y"and its name as text. - Optionally, put an inline SVG with the same
viewBoxfirst, for a river or parks behind the lines.
Options
Add any of these attributes to the same element.
data-view | The map’s grid, as an SVG viewBox. Default 0 0 400 260. |
data-route | On a line: its corners, in order, as x,y pairs separated by spaces. |
data-color | On a line: its colour. |
data-station | On a station: where it is, as x,y. It must sit on a line; on two or more it becomes an interchange. |
data-side | On a station: where its name goes, n, s, e, w, ne, nw, se or sw. Default s. |
data-width | Line thickness, in map units. Default 7. |
data-speed | How fast each line draws, in map units a second. Default 260. |
data-gap | Time between one line starting and the next. Default 650 ms. |
data-labels | Set to off to hide station names. |
data-loop | Fade out and draw the map again. |
Keep lines to horizontal, vertical and 45° steps for the classic look. Names are text over the map, sized by the container’s font-size, so they stay readable on a phone.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Metro Line Map: coloured lines draw themselves across a metro map one
// after another, and each station pops up with its name as the line
// reaches it. Stations where lines meet become interchanges.
// Usage:
// <div data-metro-line-map data-view="0 0 400 260">
// <span data-color="#e11d48" data-route="20,140 100,140 150,90 260,90"></span>
// <span data-station="20,140" data-side="s">Westgate</span>
// …
// </div>
// Coordinates are in the units of data-view (an SVG viewBox). A line is a
// child with data-route (its corners as "x,y" pairs) and data-color. A
// station is a child with data-station ("x,y") and its name as text;
// data-side (n, s, e, w, ne, nw, se or sw) puts the name on that side.
// Put an inline SVG with the same viewBox first for a backdrop (a river,
// parks).
// Optional: data-view (default "0 0 400 260"), data-speed (map units per
// second each line draws at, default 260), data-gap (ms between lines
// starting, default 650), data-width (line thickness in map units,
// default 7), data-labels="off" (no station names), data-loop (fade out
// and draw the map again).
var DEFAULTS = { view: '0 0 400 260', speed: 260, gap: 650, width: 7 };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.mtl{position:relative;display:block;line-height:0}' +
'.mtl>svg{display:block;width:100%;height:auto}' +
'.mtl-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.mtl-svg:first-child{position:static}' +
'.mtl-stn{transform-box:fill-box;transform-origin:center}' +
'.mtl-lab{position:absolute;line-height:1.15;font-weight:700;font-size:.78em;color:#241a35;white-space:nowrap;pointer-events:none;' +
'text-shadow:0 0 .2em #fff,0 0 .2em #fff,0 0 .35em #fff;opacity:0}' +
'.mtl-x{font-weight:800}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Where a label sits relative to its station, as [translate x %, translate y %, offset x em, offset y em].
var SIDES = {
n: [-50, -100, 0, -0.75], s: [-50, 0, 0, 0.75], e: [0, -50, 0.85, 0], w: [-100, -50, -0.85, 0],
ne: [0, -100, 0.55, -0.55], nw: [-100, -100, -0.55, -0.55], se: [0, 0, 0.55, 0.55], sw: [-100, 0, -0.55, 0.55],
};
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 pts(text) {
return (text || '').trim().split(/\s+/).map(function (p) { return p.split(',').map(Number); })
.filter(function (p) { return p.length === 2 && !isNaN(p[0]) && !isNaN(p[1]); });
}
// A route with rounded corners, like a printed metro map.
function routePath(p, r) {
var d = 'M' + p[0][0] + ' ' + p[0][1];
for (var i = 1; i < p.length - 1; i++) {
var a = p[i - 1], b = p[i], c = p[i + 1];
var l1 = Math.hypot(b[0] - a[0], b[1] - a[1]), l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
var k = Math.min(r, l1 / 2, l2 / 2);
d += 'L' + (b[0] - (b[0] - a[0]) / l1 * k).toFixed(2) + ' ' + (b[1] - (b[1] - a[1]) / l1 * k).toFixed(2) +
'Q' + b[0] + ' ' + b[1] + ' ' + (b[0] + (c[0] - b[0]) / l2 * k).toFixed(2) + ' ' + (b[1] + (c[1] - b[1]) / l2 * k).toFixed(2);
}
var z = p[p.length - 1];
return d + 'L' + z[0] + ' ' + z[1];
}
// How far along a route a point is (as a share of its length), or -1 if it isn't on it.
function along(p, q) {
var total = 0, at = -1, i, segs = [];
for (i = 1; i < p.length; i++) { segs.push(total); total += Math.hypot(p[i][0] - p[i - 1][0], p[i][1] - p[i - 1][1]); }
for (i = 1; i < p.length && at < 0; i++) {
var ax = p[i - 1][0], ay = p[i - 1][1], dx = p[i][0] - ax, dy = p[i][1] - ay, len2 = dx * dx + dy * dy;
var t = len2 ? Math.max(0, Math.min(1, ((q[0] - ax) * dx + (q[1] - ay) * dy) / len2)) : 0;
if (Math.hypot(ax + dx * t - q[0], ay + dy * t - q[1]) < 2.5) at = segs[i - 1] + t * Math.sqrt(len2);
}
return at < 0 ? -1 : at / total;
}
function setup(el) {
var d = el.dataset;
var s = (el._mtl = { lines: [], stations: [] });
el.classList.add('mtl');
var view = (d.view || DEFAULTS.view).split(/[\s,]+/).map(Number);
var width = +d.width || DEFAULTS.width;
var labels = d.labels !== 'off';
var kids = Array.from(el.children);
var backdrop = kids[0] && kids[0].tagName.toLowerCase() === 'svg' ? kids.shift() : null;
s.svg = mk('svg', { class: 'mtl-svg', viewBox: view.join(' '), 'aria-hidden': 'true' });
if (backdrop) el.insertBefore(s.svg, backdrop.nextSibling);
else el.insertBefore(s.svg, el.firstChild);
var gLines = mk('g', { fill: 'none', 'stroke-width': width, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, s.svg);
var gStations = mk('g', {}, s.svg);
var names = [];
kids.forEach(function (k) {
if (k.hasAttribute('data-route')) {
var p = pts(k.getAttribute('data-route'));
if (p.length < 2) return;
var color = k.getAttribute('data-color') || '#7c3aed';
var path = mk('path', { d: routePath(p, width * 1.8), stroke: color, pathLength: 1, 'stroke-dasharray': '1 2', 'stroke-dashoffset': 1.01 }, gLines);
s.lines.push({ pts: p, path: path, color: color, len: path.getTotalLength() });
k.remove();
} else if (k.hasAttribute('data-station')) {
var q = pts(k.getAttribute('data-station'))[0];
if (q) s.stations.push({ at: q, name: k.textContent.trim(), side: (k.getAttribute('data-side') || 's').toLowerCase() });
k.remove();
}
});
s.stations.forEach(function (st) {
st.on = [];
s.lines.forEach(function (ln, i) {
var f = along(ln.pts, st.at);
if (f >= 0) st.on.push({ line: i, f: f });
});
var x = st.on.length > 1;
var color = st.on.length ? s.lines[st.on[0].line].color : '#241a35';
st.dot = mk('circle', {
class: 'mtl-stn', cx: st.at[0], cy: st.at[1], r: x ? width * 0.95 : width * 0.55,
fill: '#fff', stroke: x ? '#241a35' : color, 'stroke-width': x ? width * 0.36 : width * 0.32, opacity: 0,
}, gStations);
if (labels && st.name) {
var lab = document.createElement('span');
var sd = SIDES[st.side] || SIDES.s;
lab.className = 'mtl-lab' + (x ? ' mtl-x' : '');
lab.textContent = st.name;
lab.setAttribute('aria-hidden', 'true');
lab.style.left = 'calc(' + ((st.at[0] - view[0]) / view[2]) * 100 + '% + ' + sd[2] + 'em)';
lab.style.top = 'calc(' + ((st.at[1] - view[1]) / view[3]) * 100 + '% + ' + sd[3] + 'em)';
lab.style.translate = sd[0] + '% ' + sd[1] + '%';
el.appendChild(lab);
st.lab = lab;
}
if (st.name) names.push(st.name);
});
if (!el.hasAttribute('aria-label')) {
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Metro map with ' + s.lines.length + ' lines' + (names.length ? ': ' + names.join(', ') : ''));
}
s.speed = +d.speed || DEFAULTS.speed;
s.gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
}
function play(el) {
var s = el._mtl, end = 0;
// When each line starts and how long it takes, so stations know when they're reached.
s.lines.forEach(function (ln, i) {
ln.t0 = 200 + i * s.gap;
ln.dur = Math.max(500, ln.len / s.speed * 1000);
anime.animate(ln.path, { strokeDashoffset: [1.01, 0], duration: ln.dur, delay: ln.t0, ease: 'inOut(1.4)' });
end = Math.max(end, ln.t0 + ln.dur);
});
s.stations.forEach(function (st) {
var t = Infinity;
st.on.forEach(function (o) {
var ln = s.lines[o.line];
// Undo the ease to find when the drawing head passes this share of the line.
var f = o.f, lo = 0, hi = 1;
for (var k = 0; k < 20; k++) { var mid = (lo + hi) / 2; if (easeIO(mid) < f) lo = mid; else hi = mid; }
t = Math.min(t, ln.t0 + lo * ln.dur);
});
if (!isFinite(t)) t = end;
st.dot.setAttribute('opacity', 1);
anime.animate(st.dot, { scale: [0, 1.5, 1], duration: 480, delay: t, ease: 'out(2)' });
if (st.lab) anime.animate(st.lab, { opacity: [0, 1], scale: [0.7, 1], duration: 380, delay: t + 80, ease: 'outBack(2)' });
});
if (el.dataset.loop === undefined) return;
setTimeout(function () {
var parts = [s.svg].concat(s.stations.map(function (st) { return st.lab; }).filter(Boolean));
anime.animate(parts, { opacity: 0, duration: 500, ease: 'in(2)', onComplete: function () {
s.lines.forEach(function (ln) { ln.path.setAttribute('stroke-dashoffset', 1.01); });
s.stations.forEach(function (st) { anime.utils.set(st.dot, { scale: 0 }); });
s.svg.style.opacity = 1;
setTimeout(function () { play(el); }, 300);
} });
}, end + 3000);
}
// anime's 'inOut(1.4)', used to time the stations.
function easeIO(t) { return t < 0.5 ? Math.pow(2 * t, 1.4) / 2 : 1 - Math.pow(2 - 2 * t, 1.4) / 2; }
function still(el) {
var s = el._mtl;
s.lines.forEach(function (ln) { ln.path.setAttribute('stroke-dashoffset', 0); });
s.stations.forEach(function (st) { st.dot.setAttribute('opacity', 1); if (st.lab) st.lab.style.opacity = 1; });
}
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-metro-line-map]').forEach(function (el) {
if (el._mtl) return;
setup(el);
if (reduced()) still(el);
else io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-metro-line-map data-view="0 0 400 260">
<span data-color="#e11d48" data-route="20,140 100,140 150,90 260,90"></span>
<span data-color="#2563eb" data-route="200,14 200,180 240,220 370,220"></span>
<span data-station="20,140">Westgate</span>
<span data-station="200,90" data-side="ne">Central</span>
<span data-station="370,220" data-side="n">Harbour</span>
</div>