Skip to content

Draw Travel Time Bands in Leaflet

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 Leaflet as stacked bands with a legend.

const API_KEY = "YOUR_API_KEY";

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

    const map = L.map("map", { zoomControl: false }).setView(ORIGIN, 14);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{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 legend = L.control({ position: "topleft" });
    legend.onAdd = () => L.DomUtil.create("div", "legend");
    legend.addTo(map);
    legend.getContainer().textContent = "Loading...";

    // 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.join(","));
      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}`);
      return response.json();
    }

    Promise.all(BANDS.map((band) => isochrone(band.minutes)))
      .then((features) => {
        features.forEach((feature, i) => {
          L.geoJSON(feature, {
            style: { color: BANDS[i].color, weight: 1.5, opacity: 1, fillColor: BANDS[i].color, fillOpacity: 0.55 },
            interactive: false,
          }).addTo(map);
        });
        L.marker(ORIGIN).addTo(map);

        legend.getContainer().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.getContainer().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/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%; }
    .legend {
      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>
  <script>
    const API_KEY = "YOUR_API_KEY";

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

    const map = L.map("map", { zoomControl: false }).setView(ORIGIN, 14);
    L.control.zoom({ position: "topright" }).addTo(map);

    L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{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 legend = L.control({ position: "topleft" });
    legend.onAdd = () => L.DomUtil.create("div", "legend");
    legend.addTo(map);
    legend.getContainer().textContent = "Loading...";

    // 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.join(","));
      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}`);
      return response.json();
    }

    Promise.all(BANDS.map((band) => isochrone(band.minutes)))
      .then((features) => {
        features.forEach((feature, i) => {
          L.geoJSON(feature, {
            style: { color: BANDS[i].color, weight: 1.5, opacity: 1, fillColor: BANDS[i].color, fillOpacity: 0.55 },
            interactive: false,
          }).addTo(map);
        });
        L.marker(ORIGIN).addTo(map);

        legend.getContainer().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.getContainer().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 Leaflet map of Vienna with Maptoolkit raster tiles. Request 5, 10 and 15 minute walking isochrones from latitude 48.2082, longitude 16.3722 with the Maptoolkit Isochrone API and draw them as stacked shaded bands from dark to light, 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 request takes lat,lng, the response is [lng, lat]. point is latitude first, the same order as Leaflet’s own coordinates, so ORIGIN is passed as it is. The polygon comes back as GeoJSON in [longitude, latitude] order, and L.geoJSON converts it for you.

The polygons overlap, they are not rings. The 15 minute area contains the 10 minute area, which contains the 5 minute area. Leaflet draws vector layers in the order they are added, so BANDS is listed largest first and the 5 minute band ends up on top. Promise.all keeps the results in the order of the requests, whichever one answers first.

Because they overlap, fillOpacity 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.

interactive: false. The bands do not react to the mouse, so clicks and drags reach the map, and the cursor does not turn into a pointer over them.

The bands are drawn on top of the raster tiles, labels included, since the tiles are single images. The 0.55 opacity keeps the street names underneath readable. To draw bands below the labels, use vector tiles in Leaflet or a vector map such as the MapLibre version.

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.