Skip to content

Style a Route by Surface Type in MapLibre GL JS

A cyclist choosing between two routes wants to know where the asphalt ends. The Maptoolkit Route Enhancement API returns the surface and road type under each part of a route, as positions along the line rather than as geometry. This example requests a bike route, enhances it with surface data, cuts the line at the surface changes and draws it in MapLibre GL JS as one layer colored by surface, with a breakdown of how much of the ride is on each.

const API_KEY = "YOUR_API_KEY";

    const SURFACE_COLORS = {
      asphalt: "#37474f",
      paved: "#78909c",
      unpaved: "#b07d4a",
      natural: "#7a9a55",
      alpine: "#9c8aa5",
      other: "#c2c6cc",
    };

    const map = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.cycling.json?api_key=${API_KEY}`,
      center: [12.401, 47.453],
      zoom: 13.5,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    // Cumulative length along the line, so a fraction of the route can be turned into a position.
    function measure(coordinates) {
      const lengths = [0];
      for (let i = 1; i < coordinates.length; i++) {
        const [x1, y1] = coordinates[i - 1];
        const [x2, y2] = coordinates[i];
        const dx = (x2 - x1) * Math.cos((((y1 + y2) / 2) * Math.PI) / 180);
        lengths.push(lengths[i - 1] + Math.hypot(dx, y2 - y1));
      }
      return lengths;
    }

    // The part of the line between two fractions, with both ends interpolated so the pieces meet.
    function slice(coordinates, lengths, from, to) {
      const total = lengths[lengths.length - 1];
      const pointAt = (d) => {
        let i = 1;
        while (i < lengths.length - 1 && lengths[i] < d) i++;
        const span = lengths[i] - lengths[i - 1];
        const t = span > 0 ? (d - lengths[i - 1]) / span : 0;
        const [x1, y1] = coordinates[i - 1];
        const [x2, y2] = coordinates[i];
        return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
      };
      const d0 = from * total, d1 = to * total;
      const inner = coordinates.filter((_, i) => lengths[i] > d0 && lengths[i] < d1);
      return [pointAt(d0), ...inner, pointAt(d1)];
    }

    async function fetchJson(url, options) {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`${url} returned ${response.status}`);
      return response.json();
    }

    map.once("style.load", async () => {
      const breakdown = document.getElementById("breakdown");
      try {
        const routeUrl = new URL("https://routing.maptoolkit.net/route");
        routeUrl.searchParams.append("point", "47.4460,12.3920");
        routeUrl.searchParams.append("point", "47.4600,12.4100");
        routeUrl.searchParams.set("routeType", "bike");
        // GeoJSON instead of an encoded polyline, ready for the enhancement request and the map.
        routeUrl.searchParams.set("points_encoded", "false");
        routeUrl.searchParams.set("api_key", API_KEY);
        const path = (await fetchJson(routeUrl)).paths[0];

        // POST, because a route is too long for a query string. The api_key stays in the URL.
        const enhanced = await fetchJson(`https://enhance.maptoolkit.net/route?api_key=${API_KEY}`, {
          method: "POST",
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          body: new URLSearchParams({ geometry: JSON.stringify(path.points), surface: "1", routeType: "bike" }),
        });

        // The geometry is a MultiLineString, with one list of surface sections per line.
        const features = enhanced.geometry.coordinates.flatMap((coordinates, i) => {
          const lengths = measure(coordinates);
          return (enhanced.surface[i] || []).map(({ from, to, surface, highway }) => ({
            type: "Feature",
            properties: { surface, highway },
            geometry: { type: "LineString", coordinates: slice(coordinates, lengths, from, to) },
          }));
        });

        map.addSource("route", { type: "geojson", data: { type: "FeatureCollection", features } });
        map.addLayer({
          id: "route-casing", type: "line", source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: { "line-color": "#ffffff", "line-width": 9 },
        });
        map.addLayer({
          id: "route", type: "line", source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: {
            "line-width": 6,
            // The last value is the fallback, so a new surface type shows in gray instead of disappearing.
            "line-color": ["match", ["get", "surface"], ...Object.entries(SURFACE_COLORS).flat(), SURFACE_COLORS.other],
          },
        });

        const [minLng, minLat, maxLng, maxLat] = path.bbox;
        map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 220, right: 40 } });

        // Share of the route per surface, from the fractions.
        const share = {};
        for (const { surface, from, to } of enhanced.surface.flat()) share[surface] = (share[surface] || 0) + (to - from);
        breakdown.innerHTML = "<strong>Surface</strong>" + Object.entries(share)
          .filter(([, fraction]) => Math.round(fraction * 100) >= 1)
          .sort((a, b) => b[1] - a[1])
          .map(([surface, fraction]) => `<div class="row">
            <span class="swatch" style="background:${SURFACE_COLORS[surface] || SURFACE_COLORS.other}"></span>
            ${surface === "other" ? "unknown" : surface}<span class="share">${Math.round(fraction * 100)} %</span></div>`)
          .join("");
      } catch {
        breakdown.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%; }
    #breakdown {
      position: absolute; top: 12px; left: 12px; z-index: 1; min-width: 160px; padding: 10px 12px;
      background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.7 system-ui, sans-serif; color: #1f2430;
    }
    #breakdown strong { display: block; }
    #breakdown .row { display: flex; align-items: center; gap: 8px; }
    #breakdown .swatch { width: 16px; height: 5px; border-radius: 3px; }
    #breakdown .share { margin-left: auto; font-variant-numeric: tabular-nums; color: #6b7185; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="breakdown">Loading route...</div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    const SURFACE_COLORS = {
      asphalt: "#37474f",
      paved: "#78909c",
      unpaved: "#b07d4a",
      natural: "#7a9a55",
      alpine: "#9c8aa5",
      other: "#c2c6cc",
    };

    const map = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.cycling.json?api_key=${API_KEY}`,
      center: [12.401, 47.453],
      zoom: 13.5,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    // Cumulative length along the line, so a fraction of the route can be turned into a position.
    function measure(coordinates) {
      const lengths = [0];
      for (let i = 1; i < coordinates.length; i++) {
        const [x1, y1] = coordinates[i - 1];
        const [x2, y2] = coordinates[i];
        const dx = (x2 - x1) * Math.cos((((y1 + y2) / 2) * Math.PI) / 180);
        lengths.push(lengths[i - 1] + Math.hypot(dx, y2 - y1));
      }
      return lengths;
    }

    // The part of the line between two fractions, with both ends interpolated so the pieces meet.
    function slice(coordinates, lengths, from, to) {
      const total = lengths[lengths.length - 1];
      const pointAt = (d) => {
        let i = 1;
        while (i < lengths.length - 1 && lengths[i] < d) i++;
        const span = lengths[i] - lengths[i - 1];
        const t = span > 0 ? (d - lengths[i - 1]) / span : 0;
        const [x1, y1] = coordinates[i - 1];
        const [x2, y2] = coordinates[i];
        return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
      };
      const d0 = from * total, d1 = to * total;
      const inner = coordinates.filter((_, i) => lengths[i] > d0 && lengths[i] < d1);
      return [pointAt(d0), ...inner, pointAt(d1)];
    }

    async function fetchJson(url, options) {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`${url} returned ${response.status}`);
      return response.json();
    }

    map.once("style.load", async () => {
      const breakdown = document.getElementById("breakdown");
      try {
        const routeUrl = new URL("https://routing.maptoolkit.net/route");
        routeUrl.searchParams.append("point", "47.4460,12.3920");
        routeUrl.searchParams.append("point", "47.4600,12.4100");
        routeUrl.searchParams.set("routeType", "bike");
        // GeoJSON instead of an encoded polyline, ready for the enhancement request and the map.
        routeUrl.searchParams.set("points_encoded", "false");
        routeUrl.searchParams.set("api_key", API_KEY);
        const path = (await fetchJson(routeUrl)).paths[0];

        // POST, because a route is too long for a query string. The api_key stays in the URL.
        const enhanced = await fetchJson(`https://enhance.maptoolkit.net/route?api_key=${API_KEY}`, {
          method: "POST",
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          body: new URLSearchParams({ geometry: JSON.stringify(path.points), surface: "1", routeType: "bike" }),
        });

        // The geometry is a MultiLineString, with one list of surface sections per line.
        const features = enhanced.geometry.coordinates.flatMap((coordinates, i) => {
          const lengths = measure(coordinates);
          return (enhanced.surface[i] || []).map(({ from, to, surface, highway }) => ({
            type: "Feature",
            properties: { surface, highway },
            geometry: { type: "LineString", coordinates: slice(coordinates, lengths, from, to) },
          }));
        });

        map.addSource("route", { type: "geojson", data: { type: "FeatureCollection", features } });
        map.addLayer({
          id: "route-casing", type: "line", source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: { "line-color": "#ffffff", "line-width": 9 },
        });
        map.addLayer({
          id: "route", type: "line", source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: {
            "line-width": 6,
            // The last value is the fallback, so a new surface type shows in gray instead of disappearing.
            "line-color": ["match", ["get", "surface"], ...Object.entries(SURFACE_COLORS).flat(), SURFACE_COLORS.other],
          },
        });

        const [minLng, minLat, maxLng, maxLat] = path.bbox;
        map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 220, right: 40 } });

        // Share of the route per surface, from the fractions.
        const share = {};
        for (const { surface, from, to } of enhanced.surface.flat()) share[surface] = (share[surface] || 0) + (to - from);
        breakdown.innerHTML = "<strong>Surface</strong>" + Object.entries(share)
          .filter(([, fraction]) => Math.round(fraction * 100) >= 1)
          .sort((a, b) => b[1] - a[1])
          .map(([surface, fraction]) => `<div class="row">
            <span class="swatch" style="background:${SURFACE_COLORS[surface] || SURFACE_COLORS.other}"></span>
            ${surface === "other" ? "unknown" : surface}<span class="share">${Math.round(fraction * 100)} %</span></div>`)
          .join("");
      } catch {
        breakdown.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 cycling style. Calculate a bike route from [12.3920, 47.4460] to [12.4100, 47.4600] with the Routing API as GeoJSON, enhance it with surface data from the Route Enhancement API, split the line at the surface changes, color each piece by surface with a match expression, and show a percentage breakdown.

How it works

One geometry for both calls. points_encoded=false makes the Routing API return the route as a GeoJSON LineString. The same object is sent to the Route Enhancement API as geometry, by POST because it is too long for a query string.

Surface comes back as positions, not lines. Each section in surface has a from and a to that are fractions of the route, 0 at the start and 1 at the end, plus surface and highway. The response says where the surface changes and leaves the cutting to you. measure builds the cumulative length along the line, and slice returns the part between two fractions, with both ends interpolated so neighboring pieces meet without gaps.

measure scales longitude by the cosine of the latitude instead of computing great-circle distances. The fractions only need the proportions along one line, and the approximation is accurate enough for that.

An array per line. The enhanced geometry is a MultiLineString, and surface holds one list of sections per line, in the same order. That is why the code walks the lines and reads enhanced.surface[i] for each. Using surface[0] for the whole route only works while the route is one unbroken line.

One layer, one expression. Every piece is a feature with its surface property, and a single match expression colors them all. A white casing layer underneath keeps the lighter colors visible on the cycling style.

other means unknown. It shows up where a section could not be matched confidently enough to read its surface. Label it as unknown instead of counting it as unpaved, which would invent data. Sections under 1 % are left out of the breakdown so it isn’t cluttered with slivers.

Next steps

The breakdown is what people act on: filtering a list of tours down to those with more than 90 % asphalt only needs the percentages this example already computes.

The Route Enhancement API can return elevation in the same request as surface. For ascent and descent from the Elevation API, see Show Climb Statistics for a Route.

The same example in Maptoolkit Maps JS is Style a Route by Surface Type.