Elevation Profile
A route’s height profile draws itself in from start to finish, with the total climb, descent and distance above it and a flag on the summit. Then a marker rides along the line, the ridden part darkening behind it, while a tag shows the height and distance. Point at the chart, or drag along it on a phone, to move the marker yourself.
How to use it
- Paste the snippet just before
</head>. - Add
data-elevation-profileto an empty element. It fills the width at 5:2, or give it a height. - List the heights along the route in
data-heights, in metres at equal steps, and its length indata-distance.
Options
Add any of these attributes to the same element.
data-heights | Heights at equal steps along the route, separated by commas. Default: a made-up hill climb. |
data-distance | Length of the route, in km. Default 20. |
data-units | Set to imperial to label heights in feet and distance in miles (give them in those units). |
data-color | Colour of the line. Default #7c3aed. |
data-duration | Time to draw the profile. Default 1600 ms. |
data-ride | Time for the marker to ride the whole route. Default 7000 ms. |
data-loop | Draw the profile again after every two rides. |
The climb and descent add up every rise and fall between your heights. The marker pauses while someone points at the chart and carries on a couple of seconds after they let go.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Elevation Profile: a route's height profile draws itself in from left
// to right, the summit gets a flag, and a marker rides along the line with
// a readout of height and distance. Point at (or drag along) the profile
// to move the marker yourself.
// Usage: <div data-elevation-profile data-heights="120,180,340,520,410,690,880,760,530,300" data-distance="24"></div>
// data-heights are heights (in metres) at equal steps along the route.
// Optional: data-distance (route length in km, default 20), data-color
// (line colour, default #7c3aed), data-duration (ms to draw the profile,
// default 1600), data-ride (ms for the marker to ride the route, default
// 7000), data-units ("imperial" for feet and miles; heights and distance
// are then given in feet and miles), data-loop (draw the profile again
// every few rides).
var DEFAULTS = {
heights: '220,260,310,290,380,520,610,580,700,860,940,1020,960,1130,1240,1180,990,880,920,760,610,540,430,380',
distance: 20, color: '#7c3aed', duration: 1600, ride: 7000,
};
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var css =
'.elp{position:relative;display:block;width:100%;aspect-ratio:5/2;min-height:13em;line-height:1.2;touch-action:pan-y;user-select:none}' +
'.elp-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.elp-ax{position:absolute;font-size:.74em;color:#9b91ad;font-variant-numeric:tabular-nums;white-space:nowrap;opacity:0}' +
'.elp-stats{position:absolute;left:0;top:0;display:flex;gap:.9em;font-size:.78em;font-weight:700;color:#4b4060;opacity:0;white-space:nowrap}' +
'.elp-stats span{display:inline-flex;align-items:center;gap:.25em}' +
'.elp-stats i{font-style:normal;color:var(--elp-c)}' +
'.elp-chip{position:absolute;left:0;top:0;padding:.25em .6em;border-radius:.5em;background:#1f1530;color:#fff;font-size:.74em;font-weight:700;white-space:nowrap;font-variant-numeric:tabular-nums;pointer-events:none;opacity:0;box-shadow:0 .2em .6em rgba(30,10,60,.25)}' +
'.elp-chip small{font-weight:500;opacity:.7;margin-left:.4em;font-size:1em}' +
'.elp-peak{position:absolute;left:0;top:0;font-size:.7em;font-weight:800;color:var(--elp-c);white-space:nowrap;translate:-50% -100%;opacity:0;pointer-events:none}';
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 niceStep(range) {
var steps = [10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000];
for (var i = 0; i < steps.length; i++) if (range / steps[i] <= 4) return steps[i];
return 10000;
}
function fmt(n) { return Math.round(n).toLocaleString('en-GB'); }
function setup(el) {
var d = el.dataset;
var s = (el._elp = { id: 'elp-' + (++uid), pos: 0, built: false });
el.classList.add('elp');
s.color = d.color || DEFAULTS.color;
el.style.setProperty('--elp-c', s.color);
s.h = (d.heights || DEFAULTS.heights).split(',').map(Number).filter(function (v) { return !isNaN(v); });
if (s.h.length < 2) s.h = [0, 0];
s.dist = +d.distance || DEFAULTS.distance;
s.imp = d.units === 'imperial';
s.hu = s.imp ? ' ft' : ' m';
s.du = s.imp ? ' mi' : ' km';
var up = 0, down = 0;
for (var i = 1; i < s.h.length; i++) {
var dh = s.h[i] - s.h[i - 1];
if (dh > 0) up += dh; else down -= dh;
}
s.stats = document.createElement('div');
s.stats.className = 'elp-stats';
s.stats.innerHTML = '<span><i>↑</i>' + fmt(up) + s.hu + '</span><span><i>↓</i>' + fmt(down) + s.hu + '</span><span><i>↔</i>' +
s.dist.toLocaleString('en-GB') + s.du + '</span>';
s.chip = document.createElement('div');
s.chip.className = 'elp-chip';
s.chip.setAttribute('aria-hidden', 'true');
s.peakLab = document.createElement('div');
s.peakLab.className = 'elp-peak';
s.peakLab.setAttribute('aria-hidden', 'true');
el.appendChild(s.stats);
if (!el.hasAttribute('aria-label')) {
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Elevation profile: ' + s.dist + s.du + ', climbing ' + fmt(up) + s.hu +
', highest point ' + fmt(Math.max.apply(null, s.h)) + s.hu);
}
build(el);
el.appendChild(s.peakLab);
el.appendChild(s.chip);
if (!el.closest('[aria-hidden="true"]')) {
var drag = function (e) {
if (!s.ready) return;
var r = el.getBoundingClientRect();
s.user = true;
clearTimeout(s.resume);
if (s.rideAnim) s.rideAnim.pause();
moveTo(el, (e.clientX - r.left - s.x0) / (s.x1 - s.x0));
};
el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) drag(e); });
el.addEventListener('pointerdown', drag);
var leave = function () {
if (!s.user) return;
clearTimeout(s.resume);
s.resume = setTimeout(function () { s.user = false; ride(el); }, 2500);
};
el.addEventListener('pointerleave', leave);
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') leave(); });
}
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(function () {
if (Math.abs(el.clientWidth - s.W) < 1 && Math.abs(el.clientHeight - s.H) < 1) return;
build(el);
if (s.ready) { finish(el); moveTo(el, s.pos); }
}).observe(el);
}
}
// Lay the chart out at the element's current size, in pixels.
function build(el) {
var s = el._elp, fs = parseFloat(getComputedStyle(el).fontSize);
var W = el.clientWidth, H = el.clientHeight;
s.W = W; s.H = H;
if (s.svg) s.svg.remove();
el.querySelectorAll('.elp-ax').forEach(function (a) { a.remove(); });
var lo = Math.min.apply(null, s.h), hi = Math.max.apply(null, s.h);
var step = niceStep(Math.max(1, hi - lo));
var y0v = Math.floor(lo / step) * step, y1v = Math.ceil(hi / step) * step;
if (y1v - y0v < step * 2) y1v = y0v + step * 2;
var x0 = fs * 3.3, x1 = W - fs * 0.8, top = fs * 3.4, bot = H - fs * 1.7;
s.x0 = x0; s.x1 = x1; s.top = top; s.bot = bot;
function Y(v) { return bot - (v - y0v) / (y1v - y0v) * (bot - top); }
// A smooth curve through the heights, sampled finely enough to draw as lines.
var n = s.h.length, pts = [];
for (var i = 0; i < n - 1; i++) {
var p0 = s.h[Math.max(0, i - 1)], p1 = s.h[i], p2 = s.h[i + 1], p3 = s.h[Math.min(n - 1, i + 2)];
for (var k = 0; k < 12; k++) {
var t = k / 12, t2 = t * t, t3 = t2 * t;
var v = 0.5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
pts.push([x0 + (i + t) / (n - 1) * (x1 - x0), v]);
}
}
pts.push([x1, s.h[n - 1]]);
s.pts = pts;
s.Y = Y;
var line = 'M' + pts.map(function (p) { return p[0].toFixed(1) + ' ' + Y(p[1]).toFixed(1); }).join('L');
s.svg = mk('svg', { class: 'elp-svg', width: W, height: H, 'aria-hidden': 'true' });
el.insertBefore(s.svg, el.firstChild);
var defs = mk('defs', {}, s.svg);
var grad = mk('linearGradient', { id: s.id + '-fill', x1: 0, y1: 0, x2: 0, y2: 1 }, defs);
mk('stop', { offset: 0, 'stop-color': s.color, 'stop-opacity': 0.32 }, grad);
mk('stop', { offset: 1, 'stop-color': s.color, 'stop-opacity': 0.03 }, grad);
var clip = mk('clipPath', { id: s.id + '-clip' }, defs);
s.clipRect = mk('rect', { x: 0, y: 0, width: 0, height: H }, clip);
var done = mk('clipPath', { id: s.id + '-done' }, defs);
s.doneRect = mk('rect', { x: 0, y: 0, width: 0, height: H }, done);
s.grid = mk('g', { stroke: '#ece7f4', 'stroke-width': 1, opacity: 0 }, s.svg);
for (var g = y0v; g <= y1v + 0.1; g += step) {
mk('line', { x1: x0, x2: x1, y1: Y(g), y2: Y(g), 'stroke-dasharray': g === y0v ? '' : '3 4' }, s.grid);
addAx(el, fmt(g) + (g === y1v ? s.hu : ''), x0 - fs * 0.5, Y(g), '-100%', '-50%');
}
var ticks = s.dist <= 6 ? 1 : s.dist <= 15 ? 2 : s.dist <= 40 ? 5 : s.dist <= 100 ? 10 : 25;
for (var dd = 0; dd <= s.dist + 0.001; dd += ticks) {
var dx = x0 + dd / s.dist * (x1 - x0);
if (x1 - dx < fs * 2.5 && dd > 0 && dd < s.dist) continue;
addAx(el, dd + (dd + ticks > s.dist + 0.001 ? s.du : ''), dx, bot + fs * 0.45, dd === 0 ? '-20%' : '-50%', '0');
}
var g2 = mk('g', { 'clip-path': 'url(#' + s.id + '-clip)' }, s.svg);
mk('path', { d: line + 'L' + x1 + ' ' + bot + 'L' + x0 + ' ' + bot + 'Z', fill: 'url(#' + s.id + '-fill)' }, g2);
mk('path', { d: line, fill: 'none', stroke: s.color, 'stroke-width': Math.max(2, fs * 0.22), 'stroke-linejoin': 'round', opacity: 0.45 }, g2);
// The part already ridden, drawn bolder.
mk('path', { d: line, fill: 'none', stroke: s.color, 'stroke-width': Math.max(2.5, fs * 0.3), 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'clip-path': 'url(#' + s.id + '-done)' }, g2);
var peak = pts.reduce(function (a, b) { return b[1] > a[1] ? b : a; });
s.peak = mk('g', { opacity: 0 }, s.svg);
mk('line', { x1: peak[0], x2: peak[0], y1: Y(peak[1]), y2: Y(peak[1]) - fs * 1.3, stroke: '#1f1530', 'stroke-width': 1.4 }, s.peak);
mk('path', { d: 'M' + peak[0] + ' ' + (Y(peak[1]) - fs * 1.3) + 'l' + fs * 0.75 + ' ' + fs * 0.27 + 'l-' + fs * 0.75 + ' ' + fs * 0.27 + 'Z', fill: s.color }, s.peak);
s.peakLab.textContent = fmt(Math.max.apply(null, s.h)) + s.hu;
s.peakLab.style.left = Math.min(W - fs * 2, Math.max(fs * 2, peak[0])) + 'px';
s.peakLab.style.top = Y(peak[1]) - fs * 1.5 + 'px';
s.marker = mk('g', { opacity: 0 }, s.svg);
s.guide = mk('line', { y1: top - fs * 0.6, y2: bot, stroke: '#1f1530', 'stroke-width': 1, 'stroke-dasharray': '2 3', opacity: 0.5 }, s.marker);
s.dot = mk('circle', { r: fs * 0.48, fill: '#fff', stroke: s.color, 'stroke-width': fs * 0.24 }, s.marker);
s.stats.style.left = x0 + 'px';
}
function addAx(el, text, x, y, tx, ty) {
var a = document.createElement('span');
a.className = 'elp-ax';
a.setAttribute('aria-hidden', 'true');
a.textContent = text;
a.style.left = x + 'px';
a.style.top = y + 'px';
a.style.translate = tx + ' ' + ty;
el.appendChild(a);
}
function heightAt(s, x) {
var p = s.pts, lo = 0, hi = p.length - 1;
if (x <= p[0][0]) return p[0][1];
if (x >= p[hi][0]) return p[hi][1];
while (hi - lo > 1) { var m = (lo + hi) >> 1; if (p[m][0] <= x) lo = m; else hi = m; }
return p[lo][1] + (p[hi][1] - p[lo][1]) * (x - p[lo][0]) / (p[hi][0] - p[lo][0]);
}
function moveTo(el, f) {
var s = el._elp;
f = Math.max(0, Math.min(1, f));
s.pos = f;
var x = s.x0 + f * (s.x1 - s.x0), v = heightAt(s, x), y = s.Y(v);
s.marker.setAttribute('opacity', 1);
s.guide.setAttribute('x1', x); s.guide.setAttribute('x2', x);
s.dot.setAttribute('cx', x); s.dot.setAttribute('cy', y);
s.doneRect.setAttribute('width', x);
s.chip.style.opacity = 1;
s.chip.innerHTML = fmt(v) + s.hu + '<small>' + (f * s.dist).toFixed(1) + s.du + '</small>';
var cw = s.chip.offsetWidth, fs = parseFloat(getComputedStyle(el).fontSize);
s.chip.style.transform = 'translate(' + Math.max(0, Math.min(s.W - cw, x - cw / 2)) + 'px,' + Math.max(fs * 1.25, y - fs * 2.7) + 'px)';
}
function finish(el) {
var s = el._elp;
s.clipRect.setAttribute('width', s.W);
[s.grid, s.peak].forEach(function (g) { g.setAttribute('opacity', 1); });
el.querySelectorAll('.elp-ax').forEach(function (a) { a.style.opacity = 1; });
s.stats.style.opacity = 1;
s.peakLab.style.opacity = 1;
}
function draw(el) {
var s = el._elp, dur = +el.dataset.duration || DEFAULTS.duration;
s.ready = false;
s.marker.setAttribute('opacity', 0);
s.chip.style.opacity = 0;
s.doneRect.setAttribute('width', 0);
anime.animate(s.grid, { opacity: [0, 1], duration: 500, ease: 'out(2)' });
anime.animate(el.querySelectorAll('.elp-ax'), { opacity: [0, 1], duration: 500, delay: anime.stagger(30), ease: 'out(2)' });
anime.animate(s.stats, { opacity: [0, 1], y: [6, 0], duration: 500, ease: 'out(3)' });
var st = { w: 0 };
anime.animate(st, { w: 1, duration: dur, delay: 250, ease: 'inOut(2)', onUpdate: function () {
s.clipRect.setAttribute('width', s.x0 + st.w * (s.x1 - s.x0) + 6);
}, onComplete: function () { s.clipRect.setAttribute('width', s.W); } });
anime.animate(s.peak, { opacity: [0, 1], duration: 300, delay: dur + 150 });
anime.animate(s.peakLab, { opacity: [0, 1], y: [6, 0], duration: 420, delay: dur + 200, ease: 'outBack(2)' });
clearTimeout(s.timer);
s.timer = setTimeout(function () { s.ready = true; s.rides = 0; ride(el); }, dur + 650);
}
function ride(el) {
var s = el._elp;
clearTimeout(s.timer);
if (s.user || !s.visible) return;
if (el.dataset.loop !== undefined && s.rides >= 2) { draw(el); return; }
var st = { f: s.pos >= 0.999 ? 0 : s.pos };
var dur = (+el.dataset.ride || DEFAULTS.ride) * (1 - st.f);
s.rideAnim = anime.animate(st, { f: 1, duration: dur, ease: st.f ? 'out(1.5)' : 'inOut(1.5)', onUpdate: function () { moveTo(el, st.f); },
onComplete: function () {
s.rides++;
s.timer = setTimeout(function () {
anime.animate([s.marker], { opacity: 0, duration: 300 });
anime.animate(s.chip, { opacity: 0, duration: 300, onComplete: function () {
s.doneRect.setAttribute('width', 0);
s.pos = 0;
s.timer = setTimeout(function () { ride(el); }, 500);
} });
}, 1400);
} });
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._elp;
s.visible = e.isIntersecting;
if (!e.isIntersecting) { clearTimeout(s.timer); if (s.rideAnim) s.rideAnim.pause(); return; }
if (!s.played) { s.played = true; draw(el); }
else if (s.ready) ride(el);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-elevation-profile]').forEach(function (el) {
if (el._elp) return;
setup(el);
if (reduced()) {
el._elp.ready = true;
finish(el);
moveTo(el, 0.5);
} else io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-elevation-profile data-distance="18"
data-heights="120,180,340,520,410,690,880,760,530,300,240"></div>