Skip to content

Show Climb Statistics for a Route in Leaflet

Total ascent is the number people compare when they choose between two routes, and an elevation chart does not show it. This example calculates a walking route with the Maptoolkit Routing API, asks the Elevation API for the height of every point along it, and derives ascent, descent, the highest and lowest points and the steepest section in Leaflet.

const API_KEY = "YOUR_API_KEY";

    // Height changes below this are sampling noise of the terrain model, not climbing.
    const NOISE_THRESHOLD_M = 3;
    // The Elevation API reads its points from the query string, so a whole route is sent in batches.
    const BATCH_SIZE = 150;

    const map = L.map("map", { zoomControl: false }).setView([47.29, 11.391], 13);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.hiking/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    const panel = L.control({ position: "topleft" });
    panel.onAdd = () => L.DomUtil.create("div", "stats");
    panel.addTo(map);
    panel.getContainer().textContent = "Loading route...";

    async function fetchRoute() {
      const url = new URL("https://routing.maptoolkit.net/route");
      // The Routing API takes lat,lng, like Leaflet.
      url.searchParams.append("point", "47.2683,11.3857");
      url.searchParams.append("point", "47.3125,11.3906");
      url.searchParams.set("routeType", "foot");
      // GeoJSON instead of an encoded polyline, so no decoding library is needed.
      url.searchParams.set("points_encoded", "false");
      url.searchParams.set("api_key", API_KEY);
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
      return (await response.json()).paths[0];
    }

    // One request per batch; Promise.all keeps the batches in order.
    async function fetchElevations(latlngs) {
      const requests = [];
      for (let i = 0; i < latlngs.length; i += BATCH_SIZE) {
        const url = new URL("https://elevation.maptoolkit.net");
        url.searchParams.set("points", JSON.stringify(latlngs.slice(i, i + BATCH_SIZE)));
        url.searchParams.set("api_key", API_KEY);
        requests.push(fetch(url).then((response) => {
          if (!response.ok) throw new Error(`Elevation API returned ${response.status}`);
          return response.json();
        }));
      }
      return (await Promise.all(requests)).flat();
    }

    function summarize(latlngs, heights) {
      let ascent = 0, descent = 0, pending = 0, steepest = 0;
      for (let i = 1; i < heights.length; i++) {
        // map.distance() gives the distance in meters between two [lat, lng] points.
        const run = map.distance(latlngs[i - 1], latlngs[i]);
        const rise = heights[i] - heights[i - 1];
        // Carry small changes until they add up to more than the noise threshold.
        pending += rise;
        if (Math.abs(pending) >= NOISE_THRESHOLD_M) {
          if (pending > 0) ascent += pending; else descent -= pending;
          pending = 0;
        }
        // Over very short steps, a small height error reads as a steep slope.
        if (run > 20) steepest = Math.max(steepest, (Math.abs(rise) / run) * 100);
      }
      return { ascent, descent, high: Math.max(...heights), low: Math.min(...heights), steepest };
    }

    (async () => {
      try {
        const path = await fetchRoute();
        const route = L.geoJSON(path.points, { style: { color: "#303f7e", weight: 5, opacity: 1 } }).addTo(map);
        map.fitBounds(route.getBounds(), { paddingTopLeft: [240, 40], paddingBottomRight: [40, 40] });

        // GeoJSON is [lng, lat]; Leaflet and the Elevation API use [lat, lng].
        const latlngs = path.points.coordinates.map(([lng, lat]) => [lat, lng]);
        const s = summarize(latlngs, await fetchElevations(latlngs));
        // The Routing API's own figure, so the distance matches what the API reports elsewhere.
        const km = path.distance / 1000;
        panel.getContainer().innerHTML = `<dl>
          <dt>Distance</dt><dd>${km.toFixed(1)} km</dd>
          <dt>Ascent</dt><dd>${Math.round(s.ascent)} m</dd>
          <dt>Descent</dt><dd>${Math.round(s.descent)} m</dd>
          <dt>Highest point</dt><dd>${Math.round(s.high)} m</dd>
          <dt>Lowest point</dt><dd>${Math.round(s.low)} m</dd>
          <dt>Steepest</dt><dd>${s.steepest.toFixed(0)} %</dd>
          <dt>Ascent per km</dt><dd>${Math.round(s.ascent / km)} m</dd>
        </dl>`;
      } catch {
        panel.getContainer().textContent = "Could not load the route.";
      }
    })();
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
  <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    .stats {
      min-width: 190px; padding: 12px 14px; background: #fff; border-radius: 10px;
      box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18); font: 13px/1.6 system-ui, sans-serif; color: #1f2430;
    }
    .stats dl { display: grid; grid-template-columns: auto auto; gap: 0 16px; margin: 0; }
    .stats dt { color: #6b7185; }
    .stats dd { margin: 0; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    // Height changes below this are sampling noise of the terrain model, not climbing.
    const NOISE_THRESHOLD_M = 3;
    // The Elevation API reads its points from the query string, so a whole route is sent in batches.
    const BATCH_SIZE = 150;

    const map = L.map("map", { zoomControl: false }).setView([47.29, 11.391], 13);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.hiking/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
      maxZoom: 18,
      attribution:
        "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
        "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
    }).addTo(map);

    const panel = L.control({ position: "topleft" });
    panel.onAdd = () => L.DomUtil.create("div", "stats");
    panel.addTo(map);
    panel.getContainer().textContent = "Loading route...";

    async function fetchRoute() {
      const url = new URL("https://routing.maptoolkit.net/route");
      // The Routing API takes lat,lng, like Leaflet.
      url.searchParams.append("point", "47.2683,11.3857");
      url.searchParams.append("point", "47.3125,11.3906");
      url.searchParams.set("routeType", "foot");
      // GeoJSON instead of an encoded polyline, so no decoding library is needed.
      url.searchParams.set("points_encoded", "false");
      url.searchParams.set("api_key", API_KEY);
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
      return (await response.json()).paths[0];
    }

    // One request per batch; Promise.all keeps the batches in order.
    async function fetchElevations(latlngs) {
      const requests = [];
      for (let i = 0; i < latlngs.length; i += BATCH_SIZE) {
        const url = new URL("https://elevation.maptoolkit.net");
        url.searchParams.set("points", JSON.stringify(latlngs.slice(i, i + BATCH_SIZE)));
        url.searchParams.set("api_key", API_KEY);
        requests.push(fetch(url).then((response) => {
          if (!response.ok) throw new Error(`Elevation API returned ${response.status}`);
          return response.json();
        }));
      }
      return (await Promise.all(requests)).flat();
    }

    function summarize(latlngs, heights) {
      let ascent = 0, descent = 0, pending = 0, steepest = 0;
      for (let i = 1; i < heights.length; i++) {
        // map.distance() gives the distance in meters between two [lat, lng] points.
        const run = map.distance(latlngs[i - 1], latlngs[i]);
        const rise = heights[i] - heights[i - 1];
        // Carry small changes until they add up to more than the noise threshold.
        pending += rise;
        if (Math.abs(pending) >= NOISE_THRESHOLD_M) {
          if (pending > 0) ascent += pending; else descent -= pending;
          pending = 0;
        }
        // Over very short steps, a small height error reads as a steep slope.
        if (run > 20) steepest = Math.max(steepest, (Math.abs(rise) / run) * 100);
      }
      return { ascent, descent, high: Math.max(...heights), low: Math.min(...heights), steepest };
    }

    (async () => {
      try {
        const path = await fetchRoute();
        const route = L.geoJSON(path.points, { style: { color: "#303f7e", weight: 5, opacity: 1 } }).addTo(map);
        map.fitBounds(route.getBounds(), { paddingTopLeft: [240, 40], paddingBottomRight: [40, 40] });

        // GeoJSON is [lng, lat]; Leaflet and the Elevation API use [lat, lng].
        const latlngs = path.points.coordinates.map(([lng, lat]) => [lat, lng]);
        const s = summarize(latlngs, await fetchElevations(latlngs));
        // The Routing API's own figure, so the distance matches what the API reports elsewhere.
        const km = path.distance / 1000;
        panel.getContainer().innerHTML = `<dl>
          <dt>Distance</dt><dd>${km.toFixed(1)} km</dd>
          <dt>Ascent</dt><dd>${Math.round(s.ascent)} m</dd>
          <dt>Descent</dt><dd>${Math.round(s.descent)} m</dd>
          <dt>Highest point</dt><dd>${Math.round(s.high)} m</dd>
          <dt>Lowest point</dt><dd>${Math.round(s.low)} m</dd>
          <dt>Steepest</dt><dd>${s.steepest.toFixed(0)} %</dd>
          <dt>Ascent per km</dt><dd>${Math.round(s.ascent / km)} m</dd>
        </dl>`;
      } catch {
        panel.getContainer().textContent = "Could not load the route.";
      }
    })();
  </script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create a Leaflet map with the Maptoolkit hiking raster tiles. Calculate a walking route from latitude 47.2683, longitude 11.3857 to latitude 47.3125, longitude 11.3906 with the Routing API as GeoJSON, request the elevation of every point with the Elevation API in batches, and show total ascent, descent, highest and lowest point, steepest section and ascent per km in a panel.

How it works

Route as GeoJSON. points_encoded=false makes the Routing API return the geometry as a GeoJSON LineString, which L.geoJSON draws directly. No polyline decoding is needed.

One coordinate flip. GeoJSON stores [lng, lat]. Leaflet and the Elevation API both use [lat, lng], so the coordinates are flipped once, and the same array then serves the elevation request and the distance calculation.

Elevation in batches. The Elevation API takes a JSON array of [lat, lng] points in the query string and returns a flat array of heights in meters, in the same order. The index of a height is the index of its coordinate. A route has hundreds of points, too many for one URL, so they are sent in batches of 150 in parallel. The endpoint is GET only, and the Maps JS version explains what goes wrong with longer URLs or a POST.

Distances from Leaflet. map.distance() returns the distance in meters between two points, so there is no need to write a haversine function for the gradient. The total in the panel is path.distance, the Routing API’s own figure. Adding up the segments of the returned line gives a few percent less on this steep route (8.5 instead of 8.9 km), and a total that disagrees with the API’s would confuse users.

The noise threshold decides the total. A terrain model samples the ground on a grid, so neighboring points on flat ground differ by a meter or two. Adding up every rise turns that noise into climbing that isn’t there. The loop carries small changes and only counts them once they exceed 3 m, the way GPS software does. A different threshold gives a different total, so pick one and keep it.

Steepest section. Steps shorter than 20 m are skipped for the gradient: over 5 m, a 2 m sampling error reads as a 40 % slope.

Next steps

To show where the climbing happens, not only how much, draw the heights as a chart under the map, as in Show an Elevation Profile.

For a recorded track rather than a calculated route, the Route Enhancement API returns elevation and surface for the track in one request.

The same example in Maptoolkit Maps JS is Show Climb Statistics for a Route.