Skip to content

Draw Travel Time Bands in MapLibre GL JS

One isochrone answers a yes or no question: can I get there in ten minutes. Three of them, shaded from dark to light, show how reachability falls off with distance, which is what a catchment map is for. This example requests 5, 10 and 15 minute walking isochrones for the same point from the Maptoolkit Isochrone API and draws them in MapLibre GL JS as stacked bands with a legend.

const API_KEY = "YOUR_API_KEY";

    const ORIGIN = [16.3722, 48.2082];
    // Largest first: the bands are drawn in this order, so the smallest ends up on top.
    const BANDS = [
      { minutes: 15, color: "#c6dbef" },
      { minutes: 10, color: "#6baed6" },
      { minutes: 5, color: "#2171b5" },
    ];

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

    // One request per band: the API returns one polygon per call.
    async function isochrone(minutes) {
      const url = new URL("https://routing.maptoolkit.net/isochrone");
      url.searchParams.set("point", `${ORIGIN[1]},${ORIGIN[0]}`);
      url.searchParams.set("time", minutes);
      url.searchParams.set("routeType", "foot");
      url.searchParams.set("format", "geojson");
      url.searchParams.set("api_key", API_KEY);
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Isochrone API returned ${response.status}`);
      const feature = await response.json();
      return { ...feature, properties: { minutes } };
    }

    // "style.load" fires as soon as the style is parsed; "load" would also wait for every tile.
    map.once("style.load", async () => {
      const legend = document.getElementById("legend");
      try {
        const features = await Promise.all(BANDS.map((band) => isochrone(band.minutes)));
        map.addSource("bands", { type: "geojson", data: { type: "FeatureCollection", features } });

        const color = ["match", ["get", "minutes"], ...BANDS.flatMap((band) => [band.minutes, band.color]), "#cccccc"];
        // Below the first label layer, so street names stay readable on top of the bands.
        const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
        map.addLayer({ id: "bands-fill", type: "fill", source: "bands", paint: { "fill-color": color, "fill-opacity": 0.55 } }, firstSymbolId);
        map.addLayer({ id: "bands-line", type: "line", source: "bands", paint: { "line-color": color, "line-width": 1.5 } }, firstSymbolId);

        new maplibregl.Marker({ color: "#303f7e" }).setLngLat(ORIGIN).addTo(map);

        legend.innerHTML = "<strong>Walking time</strong>" + BANDS.toReversed()
          .map((band) => `<div class="row"><span class="swatch" style="background:${band.color}"></span>${band.minutes} min</div>`)
          .join("");
      } catch {
        legend.textContent = "Could not load the isochrones.";
      }
    });
<!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%; }
    #legend {
      position: absolute; top: 12px; left: 12px; z-index: 1; padding: 10px 12px;
      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;
    }
    #legend strong { display: block; margin-bottom: 2px; }
    #legend .row { display: flex; align-items: center; gap: 8px; }
    #legend .swatch { width: 14px; height: 14px; border-radius: 3px; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="legend">Loading...</div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    const ORIGIN = [16.3722, 48.2082];
    // Largest first: the bands are drawn in this order, so the smallest ends up on top.
    const BANDS = [
      { minutes: 15, color: "#c6dbef" },
      { minutes: 10, color: "#6baed6" },
      { minutes: 5, color: "#2171b5" },
    ];

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

    // One request per band: the API returns one polygon per call.
    async function isochrone(minutes) {
      const url = new URL("https://routing.maptoolkit.net/isochrone");
      url.searchParams.set("point", `${ORIGIN[1]},${ORIGIN[0]}`);
      url.searchParams.set("time", minutes);
      url.searchParams.set("routeType", "foot");
      url.searchParams.set("format", "geojson");
      url.searchParams.set("api_key", API_KEY);
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Isochrone API returned ${response.status}`);
      const feature = await response.json();
      return { ...feature, properties: { minutes } };
    }

    // "style.load" fires as soon as the style is parsed; "load" would also wait for every tile.
    map.once("style.load", async () => {
      const legend = document.getElementById("legend");
      try {
        const features = await Promise.all(BANDS.map((band) => isochrone(band.minutes)));
        map.addSource("bands", { type: "geojson", data: { type: "FeatureCollection", features } });

        const color = ["match", ["get", "minutes"], ...BANDS.flatMap((band) => [band.minutes, band.color]), "#cccccc"];
        // Below the first label layer, so street names stay readable on top of the bands.
        const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
        map.addLayer({ id: "bands-fill", type: "fill", source: "bands", paint: { "fill-color": color, "fill-opacity": 0.55 } }, firstSymbolId);
        map.addLayer({ id: "bands-line", type: "line", source: "bands", paint: { "line-color": color, "line-width": 1.5 } }, firstSymbolId);

        new maplibregl.Marker({ color: "#303f7e" }).setLngLat(ORIGIN).addTo(map);

        legend.innerHTML = "<strong>Walking time</strong>" + BANDS.toReversed()
          .map((band) => `<div class="row"><span class="swatch" style="background:${band.color}"></span>${band.minutes} min</div>`)
          .join("");
      } catch {
        legend.textContent = "Could not load the isochrones.";
      }
    });
  </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 of Vienna. Request 5, 10 and 15 minute walking isochrones from [16.3722, 48.2082] with the Maptoolkit Isochrone API and draw them as stacked shaded bands from dark to light below the labels, with a marker at the origin and a legend.

How it works

One request per band. The Isochrone API returns one polygon per call. Repeating time, passing time=5,10,15 or adding buckets all still return a single polygon. Promise.all sends the three requests in parallel, so three bands take about as long as one.

The polygons overlap, they are not rings. The 15 minute area contains the 10 minute area, which contains the 5 minute area. In a single fill layer, features are drawn in the order of the collection, so BANDS is listed largest first and the 5 minute band ends up on top. In the opposite order, the 15 minute band would cover the others.

Because they overlap, fill-opacity adds up where bands stack, and the inner band looks darker than its own color. That helps the reading here. If the colors on the map must match the legend exactly, cut each band into a ring by subtracting the next smaller one, which needs a geometry library such as Turf.

One layer for all bands. format=geojson returns a Feature, and isochrone() sets its minutes property. A single match expression then colors every band from its minutes, and the outline layer reuses it so each edge has the color of its band.

Below the labels. Passing the first symbol layer’s id as the second argument to addLayer inserts the bands under it, so street names stay readable.

Next steps

To test which of your own locations fall inside an area, see Find Locations Inside an Isochrone. With several bands, the same test tells you which band each location falls in.

routeType changes the shape more than the minutes do: 15 minutes on foot and 15 minutes by bike are different areas. The values are in the Isochrone API reference.

The same example in Maptoolkit Maps JS is Draw Travel Time Bands.