Map Route Draw
A route draws itself across a map from pin to pin. Each pin drops in with a springy bounce just as the line reaches it, and a little arrow rides the tip, turning at every corner. Great for delivery tracking, directions and travel stories.
How to use it
- Paste the snippet just before
</head>. - Add
data-map-route-drawto your map’s<svg>. - Mark the line with
data-route. Several routes draw together, so a wide white casing under the line works too. - Mark each pin with
data-pin, drawn with its tip at 0,0 and placed withtransform="translate(x y)". Optionally add adata-traveller, drawn pointing right, to ride the tip.
Options
Add any of these attributes to the same element.
data-duration | Time to draw the route. Default 2600 ms. |
data-delay | Wait before it starts. |
data-loop | Draw, hold, fade and draw again. |
data-hold | How long the finished route stays, when looping. Default 2200 ms. |
Pins drop in the order the route reaches them, wherever they are in your markup. It starts when scrolled into view. With reduced motion the route and pins simply show.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Map Route Draw: a route draws itself across a map from pin to pin. Each
// pin drops in as the line reaches it, and a marker can ride the tip of
// the line. Starts when scrolled into view.
// Usage:
// <svg data-map-route-draw viewBox="0 0 400 260">
// …your map…
// <path data-route d="M40 200 C…" fill="none" stroke="#a855f7" stroke-width="4"/>
// <g data-pin transform="translate(40 200)">…drawn with its tip at 0,0…</g>
// <g data-pin transform="translate(350 60)">…</g>
// <circle data-traveller r="6" fill="#fff"/>
// </svg>
// Several data-route paths draw together (e.g. a wide white casing under
// the line); the first one sets where the pins are. The traveller is drawn
// centred on 0,0, pointing right, and turns with the road.
// Optional: data-duration (ms to draw, default 2600), data-delay (ms),
// data-loop (draw, hold, fade and repeat), data-hold (ms it stays drawn
// when looping, default 2200).
var DEFAULTS = { duration: 2600, hold: 2200 };
var NS = 'http://www.w3.org/2000/svg';
function setup(svg) {
var routes = Array.from(svg.querySelectorAll('[data-route]'));
if (!routes.length) return null;
// Each pin's artwork goes in an inner group, so it can grow from its tip.
var pins = Array.from(svg.querySelectorAll('[data-pin]')).map(function (pin) {
var inner = document.createElementNS(NS, 'g');
while (pin.firstChild) inner.appendChild(pin.firstChild);
pin.appendChild(inner);
return { el: pin, inner: inner, at: 0, shown: false };
});
var o = { svg: svg, routes: routes, pins: pins, traveller: svg.querySelector('[data-traveller]'), state: { p: 0 } };
anime.utils.set(pins.map(function (p) { return p.inner; }), { scale: 0 });
if (o.traveller) o.traveller.style.opacity = 0;
render(o);
return o;
}
function measure(o) {
var main = o.routes[0];
o.lengths = o.routes.map(function (r) { return r.getTotalLength(); });
// Where along the route each pin sits: the closest of a few hundred samples.
var toSvg = o.svg.getScreenCTM().inverse();
var m = toSvg.multiply(main.getScreenCTM());
var samples = [];
for (var i = 0; i <= 300; i++) {
var pt = main.getPointAtLength((o.lengths[0] * i) / 300);
samples.push([m.a * pt.x + m.c * pt.y + m.e, m.b * pt.x + m.d * pt.y + m.f]);
}
o.pins.forEach(function (pin) {
var t = toSvg.multiply(pin.el.getScreenCTM());
var best = 0, dist = Infinity;
samples.forEach(function (s, i) {
var dd = (s[0] - t.e) * (s[0] - t.e) + (s[1] - t.f) * (s[1] - t.f);
if (dd < dist) { dist = dd; best = i; }
});
pin.at = best / 300;
});
o.matrix = m;
}
function render(o) {
var p = o.state.p;
o.routes.forEach(function (r, i) {
var len = o.lengths ? o.lengths[i] : 1e5;
r.style.strokeDasharray = len + ' ' + len;
r.style.strokeDashoffset = len * (1 - p);
});
// Drop each pin as the line gets to it.
o.pins.forEach(function (pin) {
if (pin.shown || !o.lengths || p < pin.at - 0.005) return;
pin.shown = true;
anime.animate(pin.inner, { scale: [0, 1], duration: 700, ease: 'outElastic(1, .55)' });
});
var tr = o.traveller;
if (tr && o.lengths) {
var main = o.routes[0], len = o.lengths[0], m = o.matrix;
var at = len * p;
var a = main.getPointAtLength(at);
// Face along the road: from a little behind the tip to a little ahead.
var back = main.getPointAtLength(Math.max(0, at - 2)), ahead = main.getPointAtLength(Math.min(len, at + 2));
var x = m.a * a.x + m.c * a.y + m.e, y = m.b * a.x + m.d * a.y + m.f;
var dx = ahead.x - back.x, dy = ahead.y - back.y;
var angle = (Math.atan2(m.b * dx + m.d * dy, m.a * dx + m.c * dy) * 180) / Math.PI;
tr.setAttribute('transform', 'translate(' + x + ' ' + y + ') rotate(' + angle + ')');
tr.style.opacity = p > 0 && p < 1 ? 1 : 0;
}
}
function run(o) {
var d = o.svg.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var hold = +d.hold || DEFAULTS.hold;
var draw = function () { render(o); };
measure(o);
var tl;
(function cycle(first) {
o.state.p = 0;
o.pins.forEach(function (pin) { pin.shown = false; anime.utils.set(pin.inner, { scale: 0, opacity: 1 }); });
o.routes.forEach(function (r) { r.style.opacity = ''; });
render(o);
tl = anime.createTimeline({ delay: first ? +d.delay || 0 : 400, onComplete: loop ? function () { cycle(false); } : null });
tl.add(o.state, { p: [0, 1], duration: duration, ease: 'inOut(1.5)', onUpdate: draw });
if (loop) {
tl.add(o.routes, { opacity: 0, duration: 600, ease: 'out(2)' }, '+=' + hold);
tl.add(o.pins.map(function (pin) { return pin.inner; }), { scale: 0.6, opacity: 0, duration: 500, ease: 'in(2)', delay: anime.stagger(80) }, '<<');
}
})(true);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) tl.play(); else tl.pause();
}).observe(o.svg);
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
run(e.target._mrd);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-map-route-draw]').forEach(function (svg) {
// With reduced motion the finished route shows; the traveller has nowhere to go.
if (reduced) svg.querySelectorAll('[data-traveller]').forEach(function (t) { t.style.display = 'none'; });
if (svg._mrd || reduced) return;
svg._mrd = setup(svg);
if (svg._mrd) io.observe(svg);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><svg data-map-route-draw viewBox="0 0 400 250">
<image href="map.png" width="400" height="250"/>
<path data-route d="M60 220 V185 H150 V105 H330 V44" fill="none" stroke="#7c3aed" stroke-width="5"/>
<g data-pin transform="translate(60 220)">
<path d="M0 0 C-2 -6 -9 -10 -9 -17 A9 9 0 1 1 9 -17 C9 -10 2 -6 0 0 Z" fill="#7c3aed"/>
</g>
<g data-pin transform="translate(330 44)">…</g>
<path data-traveller d="M-7 -6 L8 0 L-7 6 L-4 0 Z" fill="#fff" stroke="#7c3aed"/>
</svg>