Guide

An elevation profile for your tour pages

A profile under the map shows how far a tour goes and how much it climbs. The heights come from one request per tour, which you make once and save as a file next to the page. Visitors then only load the map.

Loading the map…
The code below, with the 5-Seenweg above Zermatt, past five lakes. Point at the profile or at the route.

Three steps

  1. Save the tour as a GPX file. Tour planners, GPS devices and apps such as Komoot and Strava can export one.
  2. Send the file to the API once, with a key of the type “All APIs” from your dashboard (create a free account first), and save the answer next to your tour page:
    curl https://api.overpass.ch/v1/YOUR_API_KEY/elevation/profile --data-binary @5-seenweg.gpx -o 5-seenweg.json
  3. Create a key of the type “Maps only” with your website as allowed referer. Copy the code below, put that key where it says YOUR_KEY, and change 5-seenweg.json to the name of your file.

The answer has a point every 90 m, each with its height and its distance from the start, and a summary with the distance, the metres up and down, and the lowest and highest point. The page draws everything from this file and makes no elevation requests of its own. The key in the page can therefore be a Maps only key, which visitors can see but which cannot look up heights.

The code

<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<style>
  .tour-facts { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0 0 10px; }
  .tour-facts b { display: block; font-size: 1.25em; }
  #map { height: 380px; border-radius: 8px 8px 0 0; }
  #profile { display: block; width: 100%; height: 180px; background: #f4f6f8; border-radius: 0 0 8px 8px; touch-action: pan-y; }
  #profile text { font: 11px system-ui, sans-serif; fill: #5b6670; }
  .tour-credit { font-size: 11px; color: #6b7480; margin-top: 4px; }
</style>

<div class="tour-facts" id="facts"></div>
<div id="map"></div>
<svg id="profile" role="img" aria-label="Elevation profile of the tour"></svg>
<p class="tour-credit">Heights: produced using Copernicus WorldDEM™-90 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.</p>

<script type="module">
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";

// The answer of /elevation/profile for this tour, saved next to the page
const tour = await (await fetch("5-seenweg.json")).json();
const points = tour.results; // a point every 90 m, with distance and elevation
const sum = tour.profile;

// Walking time by Naismith's rule: 5 km an hour, plus an hour for every 600 m up
const hours = sum.distance_m / 5000 + sum.ascent_m / 600;
const time = `${Math.floor(hours)} h ${String(Math.round((hours % 1) * 60)).padStart(2, "0")} min`;
const m = (v) => `${Math.round(v).toLocaleString("en")} m`;
const facts = [["Distance", `${(sum.distance_m / 1000).toFixed(1)} km`], ["Up", m(sum.ascent_m)],
  ["Down", m(sum.descent_m)], ["Highest point", m(sum.max_m)], ["Walking time", time]];
document.getElementById("facts").innerHTML = facts.map(([k, v]) => `<div>${k}<b>${v}</b></div>`).join("");

const line = points.map((p) => [p.location.lng, p.location.lat]);
const map = new maplibregl.Map({
  container: "map",
  style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/outdoor.json?lang=en",
  bounds: line.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(line[0], line[0])),
  fitBoundsOptions: { padding: 40 },
  cooperativeGestures: true // the page scrolls past the map; Ctrl + wheel or two fingers move it
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }));
map.on("load", () => {
  map.addSource("tour", { type: "geojson", data: { type: "LineString", coordinates: line } });
  map.addLayer({ id: "tour-casing", type: "line", source: "tour", paint: { "line-color": "#fff", "line-width": 7 },
    layout: { "line-join": "round", "line-cap": "round" } });
  map.addLayer({ id: "tour", type: "line", source: "tour", paint: { "line-color": "#c2185b", "line-width": 4 },
    layout: { "line-join": "round", "line-cap": "round" } });
});
new maplibregl.Marker({ color: "#2b8a3e", scale: 0.8 }).setLngLat(line[0]).addTo(map);
new maplibregl.Marker({ color: "#c2185b", scale: 0.8 }).setLngLat(line.at(-1)).addTo(map);
const here = document.createElement("div");
here.style.cssText = "width:14px;height:14px;border-radius:50%;background:#c2185b;border:3px solid #fff;box-shadow:0 0 3px #0006";
const hereMarker = new maplibregl.Marker({ element: here });

// The profile: drawn at the width of the page, again when that changes
const svg = document.getElementById("profile");
const NS = "http://www.w3.org/2000/svg";
let x, y;
function draw() {
  const W = svg.clientWidth, H = svg.clientHeight, left = 58, bottom = 22;
  svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
  const lo = sum.min_m, hi = sum.max_m, step = hi - lo > 600 ? 200 : 100;
  const y0 = Math.floor(lo / step) * step, y1 = Math.ceil(hi / step) * step;
  x = (d) => left + (d / sum.distance_m) * (W - left - 12);
  y = (h) => 10 + (1 - (h - y0) / (y1 - y0)) * (H - 10 - bottom);
  const path = points.map((p, i) => `${i ? "L" : "M"}${x(p.distance).toFixed(1)},${y(p.elevation).toFixed(1)}`).join("");
  let out = `<path d="${path}L${x(sum.distance_m)},${y(y0)}L${x(0)},${y(y0)}Z" fill="#c2185b" fill-opacity=".15"/>`
    + `<path d="${path}" fill="none" stroke="#c2185b" stroke-width="2"/>`;
  for (let h = y0; h <= y1; h += step) out += `<line x1="${left}" x2="${W - 12}" y1="${y(h)}" y2="${y(h)}" stroke="#0002"/>`
    + `<text x="${left - 6}" y="${y(h) + 4}" text-anchor="end">${h.toLocaleString("en")} m</text>`;
  const kmStep = sum.distance_m > 20000 ? 5 : sum.distance_m > 8000 ? 2 : 1;
  for (let k = 0; k * 1000 <= sum.distance_m; k += kmStep) out += `<text x="${x(k * 1000)}" y="${H - 6}" text-anchor="middle">${k} km</text>`;
  out += `<g id="cursor" visibility="hidden"><line y1="10" y2="${H - bottom}" stroke="#333"/><circle r="4" fill="#c2185b" stroke="#fff" stroke-width="2"/>`
    + `<text id="cursor-text" y="22" style="fill:#222;font-weight:600"></text></g>`;
  svg.innerHTML = out;
}
draw();
new ResizeObserver(draw).observe(svg);

// Point at the profile or at the route on the map: both show the same place
function show(i) {
  const p = points[i], c = document.getElementById("cursor"), X = x(p.distance), W = svg.clientWidth;
  c.setAttribute("visibility", "visible");
  c.querySelector("line").setAttribute("transform", `translate(${X},0)`);
  c.querySelector("circle").setAttribute("transform", `translate(${X},${y(p.elevation)})`);
  const t = c.querySelector("text");
  t.textContent = `${(p.distance / 1000).toFixed(1)} km · ${m(p.elevation)}`;
  t.setAttribute("x", X > W - 130 ? X - 8 : X + 8);
  t.setAttribute("text-anchor", X > W - 130 ? "end" : "start");
  hereMarker.setLngLat([p.location.lng, p.location.lat]).addTo(map);
}
function hide() {
  document.getElementById("cursor").setAttribute("visibility", "hidden");
  hereMarker.remove();
}
svg.addEventListener("pointermove", (e) => {
  const d = ((e.offsetX - x(0)) / (x(sum.distance_m) - x(0))) * sum.distance_m;
  show(points.reduce((best, p, i) => (Math.abs(p.distance - d) < Math.abs(points[best].distance - d) ? i : best), 0));
});
svg.addEventListener("pointerleave", hide);
map.on("mousemove", "tour", (e) => {
  const near = (p) => (p.location.lng - e.lngLat.lng) ** 2 + (p.location.lat - e.lngLat.lat) ** 2;
  show(points.reduce((best, p, i) => (near(p) < near(points[best]) ? i : best), 0));
});
map.on("mouseleave", "tour", hide);
</script>

Walking time

The code estimates the walking time with Naismith’s rule: an hour for every 5 km, plus an hour for every 600 m up. For the 5-Seenweg that gives 2 h 38 min. If you work with another rule, change the line that computes hours. For bike tours, take the walking time out of facts.

In WordPress and website builders

Paste the code into a Custom HTML block, as for the map of a business. WordPress does not accept JSON files in its media library. Upload the file with the file manager of your hosting, or put its content into the code: replace await (await fetch("5-seenweg.json")).json() with it. A 10 km tour is about 17 KB.

What it costs

The profile costs one lookup per point, once: about 120 lookups for the 10.6 km of the 5-Seenweg. A new profile is only needed when the route changes. Visitors cost map tiles. The first view of the demo loads 7 tiles on a phone and 13 on a large screen; a visitor who zooms out twice and moves the map four times loads about 45. A browser keeps the tiles for a day. We measured this with MapLibre GL JS 6.13.0 in windows of 1280 × 720 and 390 × 700 pixels.

PlanLookups a monthMap tiles a monthVisits at 45 tilesFor a business website
Every account, free1 million100'000about 2'200✗ No, for non-commercial websites only
Elevation, $7 a month50 million100'000about 2'200✓ Yes
Maps Small, $12 a month50 million2 millionabout 44'000✓ Yes

Prices plus tax. All plans: pricing.

Good to know

  • The heights come from a grid with a point every 90 m. Over a whole tour the totals come close to those of tour planners: for the 5-Seenweg we get 301 m up and 600 m down. A few tight bends in a steep slope smooth out, and every planner counts the metres up a little differently, so the numbers differ by a few percent between apps.
  • Where you show heights, credit the elevation data: the line under the profile in the code. The map credits its own data in a corner.
  • The outdoor style shows hiking trails by difficulty, huts and stops. For a bike tour, light or driving may suit better; all nine styles work.
  • Waymarked trails are in OpenStreetMap. The example profile of a hiking route gets a route with an Overpass query, with its heights.