Skip to content

Show Climb Statistics for a Route in MapLibre GL JS

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 MapLibre GL JS.

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 = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.hiking.json?api_key=${API_KEY}`,
      center: [11.391, 47.29],
      zoom: 12,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    async function fetchRoute() {
      const url = new URL("https://routing.maptoolkit.net/route");
      // The Routing API takes lat,lng.
      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: [lng, lat] pairs, ready for the map.
      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(coordinates) {
      const requests = [];
      for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
        // The Elevation API takes [lat, lng], GeoJSON is [lng, lat].
        const points = coordinates.slice(i, i + BATCH_SIZE).map(([lng, lat]) => [lat, lng]);
        const url = new URL("https://elevation.maptoolkit.net");
        url.searchParams.set("points", JSON.stringify(points));
        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();
    }

    // Great-circle distance in meters between two [lng, lat] points.
    function distance([lng1, lat1], [lng2, lat2]) {
      const rad = Math.PI / 180;
      const h = Math.sin(((lat2 - lat1) * rad) / 2) ** 2 +
        Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(((lng2 - lng1) * rad) / 2) ** 2;
      return 2 * 6371000 * Math.asin(Math.sqrt(h));
    }

    function summarize(coordinates, heights) {
      let ascent = 0, descent = 0, pending = 0, steepest = 0;
      for (let i = 1; i < heights.length; i++) {
        const run = distance(coordinates[i - 1], coordinates[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 };
    }

    map.once("style.load", async () => {
      const stats = document.getElementById("stats");
      try {
        const path = await fetchRoute();
        const coordinates = path.points.coordinates;

        map.addSource("route", { type: "geojson", data: path.points });
        map.addLayer({
          id: "route", type: "line", source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: { "line-color": "#303f7e", "line-width": 5 },
        });
        const [minLng, minLat, maxLng, maxLat] = path.bbox;
        map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 240, right: 40 } });

        const s = summarize(coordinates, await fetchElevations(coordinates));
        // The Routing API's own figure, so the distance matches what the API reports elsewhere.
        const km = path.distance / 1000;
        stats.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 {
        stats.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/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
  <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #stats {
      position: absolute; top: 12px; left: 12px; z-index: 1; 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>
  <div id="stats">Loading route...</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 = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.hiking.json?api_key=${API_KEY}`,
      center: [11.391, 47.29],
      zoom: 12,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    async function fetchRoute() {
      const url = new URL("https://routing.maptoolkit.net/route");
      // The Routing API takes lat,lng.
      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: [lng, lat] pairs, ready for the map.
      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(coordinates) {
      const requests = [];
      for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
        // The Elevation API takes [lat, lng], GeoJSON is [lng, lat].
        const points = coordinates.slice(i, i + BATCH_SIZE).map(([lng, lat]) => [lat, lng]);
        const url = new URL("https://elevation.maptoolkit.net");
        url.searchParams.set("points", JSON.stringify(points));
        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();
    }

    // Great-circle distance in meters between two [lng, lat] points.
    function distance([lng1, lat1], [lng2, lat2]) {
      const rad = Math.PI / 180;
      const h = Math.sin(((lat2 - lat1) * rad) / 2) ** 2 +
        Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(((lng2 - lng1) * rad) / 2) ** 2;
      return 2 * 6371000 * Math.asin(Math.sqrt(h));
    }

    function summarize(coordinates, heights) {
      let ascent = 0, descent = 0, pending = 0, steepest = 0;
      for (let i = 1; i < heights.length; i++) {
        const run = distance(coordinates[i - 1], coordinates[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 };
    }

    map.once("style.load", async () => {
      const stats = document.getElementById("stats");
      try {
        const path = await fetchRoute();
        const coordinates = path.points.coordinates;

        map.addSource("route", { type: "geojson", data: path.points });
        map.addLayer({
          id: "route", type: "line", source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: { "line-color": "#303f7e", "line-width": 5 },
        });
        const [minLng, minLat, maxLng, maxLat] = path.bbox;
        map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 240, right: 40 } });

        const s = summarize(coordinates, await fetchElevations(coordinates));
        // The Routing API's own figure, so the distance matches what the API reports elsewhere.
        const km = path.distance / 1000;
        stats.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 {
        stats.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 MapLibre GL JS map with the Maptoolkit hiking style. Calculate a walking route from [11.3857, 47.2683] to [11.3906, 47.3125] 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, so path.points goes straight into a MapLibre source, and its coordinates are the input for the elevation request. No polyline decoding is needed.

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.

Coordinate order. The Routing and Elevation APIs take lat,lng, GeoJSON and MapLibre use [lng, lat]. The comments mark the two places where the order changes.

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.

Distance from the route. The panel uses path.distance, the Routing API’s own figure for the route. 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.

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.