Draw a route on a MapLibre map

A route on a map is a line through the stops, with a marker at each one. MapLibre GL JS draws the line as a line layer from a GeoJSON source, and a routing API supplies its shape. This guide asks Mapricot's routing API for a walk from Pioneer Courthouse Square to a coffee shop and on to the waterfront in Portland, draws it, and routes again when a stop is dragged or the travel mode changes.

Everything goes in one HTML file, with no build step. It was tested with MapLibre GL JS 6.15 and @mapbox/polyline 1.2 in Chromium.

Drag the stops or pick another travel mode. The route, request, and response update together:

Request
curl "https://api.mapricot.com/v1/route" \
  -H "Authorization: Bearer $MAPRICOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "waypoints": [
      { "lat": 45.51886, "lon": -122.67932 },
      { "lat": 45.521399, "lon": -122.682775 },
      { "lat": 45.522754, "lon": -122.670024 }
    ],
    "mode": "walk"
  }'
Response
{  "routes": [    {      "distanceMeters": 1746,      "durationSeconds": 1241,      "polyline": "iagyuAt_w~hFXaByIvE}CfBqCtAeAa@]hBa@|Bq@xDqAm@u@]qAm@uCsA}\\{NqB}@mAzGmCdOiGr]q@tDa@|BsEzWe@nCc@hCqHvb@uB|L_@xBUrAgAdGcI`e@{AtIy@bFUpAc@nCcCiAsGuC}JeEgL{Eh@yCVsAgCeAyBcAoKsEmQyHi@S}@M}@?eAByCJg_@\\sCFCkDEwJ_@ue@E}EAq@CaDIkl@p@sCAgFAeE{@}Ca@ci@nA_E?uA?_EMwa@AgDCik@Aq@?yKM{HCq@AEOy_@AuEA}ACgE?[AeCGuG_@c_@GeLAeAC}C?a@EqEAGNqKFuIMkDMmQ?g@EyEIaGCoEUeEOiGEuFIgNAw@RqHEgGa@qm@AgAB_GxDKh@?tCErACtDSzBe@vBo@nCm@jAWJq^DeL@cE@_CBsB?_A?w@@gA?aABmC@mDPec@@w@BeFdDGlBmB?wD{BF}RV",      "legs": [        {          "distanceMeters": 501,          "durationSeconds": 355        },        {          "distanceMeters": 1245,          "durationSeconds": 886        }      ]    }  ],  "attribution": [    {      "source": "openstreetmap",      "text": "© OpenStreetMap contributors",      "url": "https://www.openstreetmap.org/copyright"    }  ]}

Before you start

You need a browser key with the Routing product. Getting started shows how to make one. Add http://localhost:* to its allowed origins, so it works from your computer while you build. The map tiles need no key.

Make the page

Make a folder for the map, and in it a file called index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>A walk through downtown Portland</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
  </head>
  <body style="margin: 0; font-family: system-ui, sans-serif">
    <p style="margin: 10px">
      <select id="mode" aria-label="Travel mode">
        <option value="walk">Walking</option>
        <option value="bicycle">Cycling</option>
        <option value="drive">Driving</option>
      </select>
      <span id="summary"></span>
    </p>
    <div id="map" style="height: calc(100vh - 50px)"></div>
    <script type="module">
      import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
      import polyline from "https://cdn.jsdelivr.net/npm/@mapbox/polyline@1.2.1/+esm";

      const key = "mapricot_pk_…";

      async function mapricot(path, body) {
        const response = await fetch(`https://api.mapricot.com${path}`, {
          method: "POST",
          headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
        if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
        return response.json();
      }

      const stops = [
        { lat: 45.51886, lon: -122.67932 }, // Pioneer Courthouse Square
        { lat: 45.521399, lon: -122.682775 }, // Stumptown Coffee Roasters
        { lat: 45.522754, lon: -122.670024 }, // Tom McCall Waterfront Park
      ];
      const mode = document.querySelector("#mode");
      const summary = document.querySelector("#summary");

      const map = new maplibregl.Map({
        container: "map",
        style: "https://tiles.mapricot.com/styles/light.json",
        center: [-122.6763, 45.5208],
        zoom: 15,
      });
      await map.once("load");
    </script>
  </body>
</html>

Put your key in place of mapricot_pk_…. The page loads MapLibre and its stylesheet from jsDelivr, and draws the map under a line with a travel mode menu and room for the distance. Markers and popups explains each part of a page like this. What's new here:

  • The second import is @mapbox/polyline, which decodes the route's line.
  • mapricot sends a POST with a JSON body, which is how the routing API takes its stops.
  • stops are the three places to visit, in order. Mapricot takes them as lat and lon.
  • map.once("load") waits for the style to load, since sources and layers can only be added after it does. Call it straight after creating the map, before awaiting anything else, so the event can't fire before anything is listening.

Open the page

Serve the folder with a web server, rather than opening the file directly. Opened from a file, the map stays blank with Worker failed to load, since browsers don't let a page from a file start MapLibre's web worker. Such a page also has no origin, so the API would refuse the key with 403 and "Key is not allowed from this origin." Any web server works. In the folder, run one of these:

npx serve
python3 -m http.server

npx serve needs Node.js, and serves the page at http://localhost:3000. Python's serves it at http://localhost:8000. Open the address to see a map of downtown Portland, and reload it after each change below.

Draw the line

Add an empty source for the route, and a line layer to draw it. Add this at the end of the script:

const labels = map.getStyle().layers.find((layer) => layer.type === "symbol").id;
map.addSource("route", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
map.addLayer(
  {
    id: "route",
    type: "line",
    source: "route",
    layout: { "line-join": "round", "line-cap": "round" },
    paint: { "line-color": "#fd7e14", "line-width": 5 },
  },
  labels,
);

The second argument to addLayer puts the route under the style's first label layer, so street names stay readable on top of it. Round joins and caps smooth the corners.

Then ask for the route and give the source its line. Add this at the end of the script:

function miles(meters) {
  const feet = meters * 3.28084;
  return feet < 528 ? `${Math.round(feet / 10) * 10} ft` : `${(meters / 1609.344).toFixed(1)} mi`;
}

async function showRoute() {
  const { routes } = await mapricot("/v1/route", { waypoints: stops, mode: mode.value });
  if (!routes.length) {
    summary.textContent = "No route between these stops";
    return [];
  }
  const [route] = routes;
  const coordinates = polyline.decode(route.polyline, 6).map(([lat, lon]) => [lon, lat]);
  map.getSource("route").setData({ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } });
  summary.textContent = `${miles(route.distanceMeters)}, ${Math.round(route.durationSeconds / 60)} min`;
  return coordinates;
}

Two details catch people out:

  • Mapricot's polyline is encoded with six decimal places, so decode it with precision 6. Most decoders default to Google's 5, which makes every coordinate ten times too large and puts the line far from where it should be.
  • The decoder returns [latitude, longitude] pairs, and MapLibre takes [longitude, latitude], so each pair is flipped.

The response gives the distance in meters and the time in seconds. miles shows feet up to a tenth of a mile, then miles. When no road or path connects the stops, routes is empty and the request isn't billed.

Add the stops

Put a draggable marker on each stop. When one is dropped, update its stop and route again. Add this at the end of the script:

function update() {
  showRoute().catch((error) => (summary.textContent = error.message));
}

stops.forEach((stop, index) => {
  const marker = new maplibregl.Marker({ draggable: true, color: index === 0 ? "#212529" : "#fd7e14" })
    .setLngLat([stop.lon, stop.lat])
    .addTo(map);
  marker.on("dragend", () => {
    const { lat, lng } = marker.getLngLat().wrap();
    stops[index] = { lat: Number(lat.toFixed(6)), lon: Number(lng.toFixed(6)) };
    update();
  });
});
mode.addEventListener("change", update);

The first stop's marker is dark, so it reads as the start. toFixed(6) rounds the dropped position, which MapLibre gives to about 14 decimal places; Mapricot accepts at most 10. update shows any error, such as a stop dropped too far away for the travel mode, in place of the distance.

Fit the map to the route

Last, draw the first route and fit the map around it. Add this at the end of the script:

const coordinates = await showRoute();
const bounds = new maplibregl.LngLatBounds();
for (const point of coordinates) bounds.extend(point);
if (coordinates.length) map.fitBounds(bounds, { padding: 60 });

The map fits only once, so it doesn't jump around while people drag stops. Reload the page to see the route, then drag a stop or change the mode.

The whole page

Here's index.html with everything above, ready to copy. Put your key in place of mapricot_pk_…:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>A walk through downtown Portland</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
  </head>
  <body style="margin: 0; font-family: system-ui, sans-serif">
    <p style="margin: 10px">
      <select id="mode" aria-label="Travel mode">
        <option value="walk">Walking</option>
        <option value="bicycle">Cycling</option>
        <option value="drive">Driving</option>
      </select>
      <span id="summary"></span>
    </p>
    <div id="map" style="height: calc(100vh - 50px)"></div>
    <script type="module">
      import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
      import polyline from "https://cdn.jsdelivr.net/npm/@mapbox/polyline@1.2.1/+esm";

      const key = "mapricot_pk_…";

      async function mapricot(path, body) {
        const response = await fetch(`https://api.mapricot.com${path}`, {
          method: "POST",
          headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
          body: JSON.stringify(body),
        });
        if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
        return response.json();
      }

      const stops = [
        { lat: 45.51886, lon: -122.67932 }, // Pioneer Courthouse Square
        { lat: 45.521399, lon: -122.682775 }, // Stumptown Coffee Roasters
        { lat: 45.522754, lon: -122.670024 }, // Tom McCall Waterfront Park
      ];
      const mode = document.querySelector("#mode");
      const summary = document.querySelector("#summary");

      const map = new maplibregl.Map({
        container: "map",
        style: "https://tiles.mapricot.com/styles/light.json",
        center: [-122.6763, 45.5208],
        zoom: 15,
      });
      await map.once("load");

      const labels = map.getStyle().layers.find((layer) => layer.type === "symbol").id;
      map.addSource("route", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
      map.addLayer(
        {
          id: "route",
          type: "line",
          source: "route",
          layout: { "line-join": "round", "line-cap": "round" },
          paint: { "line-color": "#fd7e14", "line-width": 5 },
        },
        labels,
      );

      function miles(meters) {
        const feet = meters * 3.28084;
        return feet < 528 ? `${Math.round(feet / 10) * 10} ft` : `${(meters / 1609.344).toFixed(1)} mi`;
      }

      async function showRoute() {
        const { routes } = await mapricot("/v1/route", { waypoints: stops, mode: mode.value });
        if (!routes.length) {
          summary.textContent = "No route between these stops";
          return [];
        }
        const [route] = routes;
        const coordinates = polyline.decode(route.polyline, 6).map(([lat, lon]) => [lon, lat]);
        map.getSource("route").setData({ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } });
        summary.textContent = `${miles(route.distanceMeters)}, ${Math.round(route.durationSeconds / 60)} min`;
        return coordinates;
      }

      function update() {
        showRoute().catch((error) => (summary.textContent = error.message));
      }

      stops.forEach((stop, index) => {
        const marker = new maplibregl.Marker({ draggable: true, color: index === 0 ? "#212529" : "#fd7e14" })
          .setLngLat([stop.lon, stop.lat])
          .addTo(map);
        marker.on("dragend", () => {
          const { lat, lng } = marker.getLngLat().wrap();
          stops[index] = { lat: Number(lat.toFixed(6)), lon: Number(lng.toFixed(6)) };
          update();
        });
      });
      mode.addEventListener("change", update);

      const coordinates = await showRoute();
      const bounds = new maplibregl.LngLatBounds();
      for (const point of coordinates) bounds.extend(point);
      if (coordinates.length) map.fitBounds(bounds, { padding: 60 });
    </script>
  </body>
</html>

Each leg of the trip

A route through more than two stops has one entry in legs for each pair of stops in a row, with its own distanceMeters and durationSeconds. To list them in the console, add this at the end of showRoute, before its return:

for (const [index, leg] of route.legs.entries()) {
  console.log(`Stop ${index + 1} to ${index + 2}: ${miles(leg.distanceMeters)}`);
}

The line is one polyline for the whole trip. Routes take 2 to 20 stops, visited in the order you give.

Credits

Routes are built from OpenStreetMap data, which the map style already credits in the corner. Keep that credit visible: OpenStreetMap's license requires it.

Questions

Why is my route drawn in the wrong place? Usually the precision. Decoding a precision-6 polyline as precision 5 makes every coordinate ten times too large, which puts the line far away or makes MapLibre throw Invalid LngLat latitude value. Decode Mapricot's polylines with 6. The other cause is forgetting to flip [lat, lon] to [lon, lat].

Why does the map show but no route? Look at the line above the map, which shows the error, or the browser's console. Mapricot returned 403 means the API refused the key: check that the page is served from an origin the key allows, such as http://localhost:*, and not opened as a file, and that the key has the Routing product. Mapricot returned 400 usually means a stop is too far away for the travel mode.

Can I show turn-by-turn directions? Mapricot's routes don't include instructions, only the line, distance, and time, overall and for each leg. Traffic, transit, and tolls aren't included either. Routing lists what's left out.

How do I make the line stand out on a busy map? Draw it twice: a wider white line first, then the colored line on top, as two layers on the same source. The white edge separates it from the roads under it.

Does each drag cost a request? Yes, each route is one request, from the free allowance first. Map tiles are free and don't count. Markers and popups shows how to add places along the way.