Skip to content

Leaflet Layer Control with Overlays and a Legend

L.control.layers switches between basemaps and turns overlays on and off. A legend that lists every layer, visible or not, quickly gets confusing, so this example keeps the legend in step with the layer control: an overlay’s entries appear when it is switched on and disappear when it is switched off. The basemaps are three Maptoolkit raster styles. The overlays are walking-time bands around Innsbruck’s old town from the Maptoolkit Isochrone API, a walk up to the Alpenzoo from the Routing API, and a group of sights as colored pins.

const API_KEY = "YOUR_API_KEY";
    const ORIGIN = [47.2686, 11.3933]; // Goldenes Dachl, in the old town
    const ATTRIBUTION =
      "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
      "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>";

    // One tile layer per Maptoolkit raster style; the layer control shows one at a time.
    const basemap = (style) => L.tileLayer(
      `https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.${style}/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`,
      { maxZoom: 18, attribution: ATTRIBUTION });
    const basemaps = { Light: basemap("light"), Summer: basemap("summer"), Winter: basemap("winter") };

    const map = L.map("map", { zoomControl: false, layers: [basemaps.Light] }).setView([47.2716, 11.394], 14);
    L.control.zoom({ position: "topright" }).addTo(map);

    // Largest first: drawn in this order, the 5 minute band ends up on top.
    const BANDS = [{ minutes: 15, color: "#c6dbef" }, { minutes: 10, color: "#6baed6" }, { minutes: 5, color: "#2171b5" }];
    const SIGHT_COLOR = "#e8590c";
    const ROUTE_COLOR = "#2f9e44";
    const SIGHTS = [
      ["Goldenes Dachl", 47.26857, 11.39328], ["Hofburg", 47.26886, 11.39490], ["Ferdinandeum", 47.26721, 11.39771],
      ["Markthalle", 47.26719, 11.38960], ["Cathedral of St. James", 47.26936, 11.39421], ["Alpenzoo", 47.28173, 11.39757],
    ];

    // Each overlay carries its own legend rows, so the legend can be built from what is on the map.
    const overlays = {
      "Walking time": Object.assign(L.layerGroup(), {
        legend: BANDS.toReversed().map((b) => `<span class="area" style="background:${b.color}"></span>${b.minutes} min on foot`),
      }),
      "Sights": Object.assign(L.layerGroup(), {
        legend: [`<span class="dot" style="background:${SIGHT_COLOR}"></span>Sights`],
      }),
      "Walk to the Alpenzoo": Object.assign(L.geoJSON(null, { style: { color: ROUTE_COLOR, weight: 5, opacity: 1 } }), {
        legend: [`<span class="line" style="border-color:${ROUTE_COLOR}"></span>Walk to the Alpenzoo`],
      }),
    };
    L.control.layers(basemaps, overlays, { collapsed: false, position: "topright" }).addTo(map);

    const legend = L.control({ position: "bottomleft" });
    legend.onAdd = () => L.DomUtil.create("div", "legend");
    legend.addTo(map);

    // List only the overlays that are on the map, in the order of the layer control.
    function updateLegend() {
      const rows = Object.values(overlays).filter((layer) => map.hasLayer(layer)).flatMap((layer) => layer.legend);
      legend.getContainer().innerHTML = "<strong>Legend</strong>" +
        (rows.map((row) => `<div class="row">${row}</div>`).join("") || '<div class="empty">No overlays shown</div>');
    }
    map.on("overlayadd overlayremove", updateLegend);

    // The sights need no request, so their pins go into the group right away.
    const pin = L.divIcon({
      className: "pin",
      iconSize: [24, 32],
      iconAnchor: [12, 31],
      popupAnchor: [0, -28],
      html: `<svg width="24" height="32" viewBox="0 0 24 32">
        <path d="M12 31S23 18.8 23 11.5a11 11 0 0 0-22 0C1 18.8 12 31 12 31Z" fill="${SIGHT_COLOR}" stroke="#fff" stroke-width="2"/>
        <circle cx="12" cy="11.5" r="4" fill="#fff"/></svg>`,
    });
    for (const [name, lat, lng] of SIGHTS) L.marker([lat, lng], { icon: pin }).bindPopup(name).addTo(overlays.Sights);

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

    function isochroneUrl(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);
      return url;
    }

    const routeUrl = new URL("https://routing.maptoolkit.net/route");
    routeUrl.searchParams.append("point", ORIGIN.join(","));
    routeUrl.searchParams.append("point", "47.28173,11.39757"); // Alpenzoo
    routeUrl.searchParams.set("routeType", "foot");
    routeUrl.searchParams.set("points_encoded", "false");
    routeUrl.searchParams.set("api_key", API_KEY);

    Promise.all([...BANDS.map((band) => getJson(isochroneUrl(band.minutes))), getJson(routeUrl)])
      .then((responses) => {
        const route = responses.pop();
        responses.forEach((feature, i) => L.geoJSON(feature, {
          style: { color: BANDS[i].color, weight: 1.5, opacity: 1, fillColor: BANDS[i].color, fillOpacity: 0.45 },
          interactive: false,
        }).addTo(overlays["Walking time"]));
        overlays["Walk to the Alpenzoo"].addData(route.paths[0].points);
        Object.values(overlays).forEach((layer) => layer.addTo(map));
        updateLegend();
      })
      .catch(() => { legend.getContainer().textContent = "Could not load the overlays."; });
<!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%; }
    .leaflet-control-layers { font: 13px/1.5 system-ui, sans-serif; }
    .legend {
      min-width: 170px; 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 .area { width: 16px; height: 12px; border-radius: 3px; }
    .legend .line { width: 18px; border-top: 4px solid; }
    .legend .dot { width: 10px; height: 10px; margin: 0 2px; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); }
    .legend .empty { color: #6b7185; }
    .pin { background: none; border: none; }
    .pin svg { display: block; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); }
  </style>
</head>
<body>
  <div id="map"></div>
  <script>
    const API_KEY = "YOUR_API_KEY";
    const ORIGIN = [47.2686, 11.3933]; // Goldenes Dachl, in the old town
    const ATTRIBUTION =
      "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
      "© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>";

    // One tile layer per Maptoolkit raster style; the layer control shows one at a time.
    const basemap = (style) => L.tileLayer(
      `https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.${style}/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`,
      { maxZoom: 18, attribution: ATTRIBUTION });
    const basemaps = { Light: basemap("light"), Summer: basemap("summer"), Winter: basemap("winter") };

    const map = L.map("map", { zoomControl: false, layers: [basemaps.Light] }).setView([47.2716, 11.394], 14);
    L.control.zoom({ position: "topright" }).addTo(map);

    // Largest first: drawn in this order, the 5 minute band ends up on top.
    const BANDS = [{ minutes: 15, color: "#c6dbef" }, { minutes: 10, color: "#6baed6" }, { minutes: 5, color: "#2171b5" }];
    const SIGHT_COLOR = "#e8590c";
    const ROUTE_COLOR = "#2f9e44";
    const SIGHTS = [
      ["Goldenes Dachl", 47.26857, 11.39328], ["Hofburg", 47.26886, 11.39490], ["Ferdinandeum", 47.26721, 11.39771],
      ["Markthalle", 47.26719, 11.38960], ["Cathedral of St. James", 47.26936, 11.39421], ["Alpenzoo", 47.28173, 11.39757],
    ];

    // Each overlay carries its own legend rows, so the legend can be built from what is on the map.
    const overlays = {
      "Walking time": Object.assign(L.layerGroup(), {
        legend: BANDS.toReversed().map((b) => `<span class="area" style="background:${b.color}"></span>${b.minutes} min on foot`),
      }),
      "Sights": Object.assign(L.layerGroup(), {
        legend: [`<span class="dot" style="background:${SIGHT_COLOR}"></span>Sights`],
      }),
      "Walk to the Alpenzoo": Object.assign(L.geoJSON(null, { style: { color: ROUTE_COLOR, weight: 5, opacity: 1 } }), {
        legend: [`<span class="line" style="border-color:${ROUTE_COLOR}"></span>Walk to the Alpenzoo`],
      }),
    };
    L.control.layers(basemaps, overlays, { collapsed: false, position: "topright" }).addTo(map);

    const legend = L.control({ position: "bottomleft" });
    legend.onAdd = () => L.DomUtil.create("div", "legend");
    legend.addTo(map);

    // List only the overlays that are on the map, in the order of the layer control.
    function updateLegend() {
      const rows = Object.values(overlays).filter((layer) => map.hasLayer(layer)).flatMap((layer) => layer.legend);
      legend.getContainer().innerHTML = "<strong>Legend</strong>" +
        (rows.map((row) => `<div class="row">${row}</div>`).join("") || '<div class="empty">No overlays shown</div>');
    }
    map.on("overlayadd overlayremove", updateLegend);

    // The sights need no request, so their pins go into the group right away.
    const pin = L.divIcon({
      className: "pin",
      iconSize: [24, 32],
      iconAnchor: [12, 31],
      popupAnchor: [0, -28],
      html: `<svg width="24" height="32" viewBox="0 0 24 32">
        <path d="M12 31S23 18.8 23 11.5a11 11 0 0 0-22 0C1 18.8 12 31 12 31Z" fill="${SIGHT_COLOR}" stroke="#fff" stroke-width="2"/>
        <circle cx="12" cy="11.5" r="4" fill="#fff"/></svg>`,
    });
    for (const [name, lat, lng] of SIGHTS) L.marker([lat, lng], { icon: pin }).bindPopup(name).addTo(overlays.Sights);

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

    function isochroneUrl(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);
      return url;
    }

    const routeUrl = new URL("https://routing.maptoolkit.net/route");
    routeUrl.searchParams.append("point", ORIGIN.join(","));
    routeUrl.searchParams.append("point", "47.28173,11.39757"); // Alpenzoo
    routeUrl.searchParams.set("routeType", "foot");
    routeUrl.searchParams.set("points_encoded", "false");
    routeUrl.searchParams.set("api_key", API_KEY);

    Promise.all([...BANDS.map((band) => getJson(isochroneUrl(band.minutes))), getJson(routeUrl)])
      .then((responses) => {
        const route = responses.pop();
        responses.forEach((feature, i) => L.geoJSON(feature, {
          style: { color: BANDS[i].color, weight: 1.5, opacity: 1, fillColor: BANDS[i].color, fillOpacity: 0.45 },
          interactive: false,
        }).addTo(overlays["Walking time"]));
        overlays["Walk to the Alpenzoo"].addData(route.paths[0].points);
        Object.values(overlays).forEach((layer) => layer.addTo(map));
        updateLegend();
      })
      .catch(() => { legend.getContainer().textContent = "Could not load the overlays."; });
  </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 Innsbruck with a layer control for three Maptoolkit raster styles as basemaps and three overlays: 5, 10 and 15 minute walking isochrones as one group, sights as colored SVG pins, and a walking route. Add a legend control that only lists the overlays currently on the map, updated on overlayadd and overlayremove.

How it works

Basemaps and overlays. L.control.layers(basemaps, overlays) takes two objects whose keys are the labels. Base layers get radio buttons, so exactly one is shown; overlays get checkboxes. Each Maptoolkit raster style is its own tile URL, so each basemap is its own L.tileLayer, and the one in the map’s layers option is shown first. collapsed: false keeps the control open instead of showing it on hover.

One overlay, several layers. An overlay can be any layer, including an L.layerGroup. The three walking-time bands are three L.geoJSON layers in one group, so one checkbox switches all of them, and the sights are markers in another group.

Legend entries on the layers. Each overlay carries a legend array with its rows: three color boxes for the bands, a dot for the sights, a line for the route. The legend is rebuilt from the overlays that map.hasLayer() reports as visible, so it can’t drift from the map.

overlayadd and overlayremove. The layer control fires these on the map whenever the user ticks or unticks an overlay, and the legend updates on both. Changing the basemap fires baselayerchange instead, which a legend for basemap-specific symbols could use.

Overlays are ready before the data. The overlays are created empty and handed to the layer control at once, so its checkboxes are in place while the requests run. The Isochrone API returns one polygon per request, so the bands are three requests with format=geojson, and the Routing API with points_encoded=false returns the route. All four run in parallel, and Promise.all keeps the answers in request order, so the largest band is drawn first and the 5 minute band ends up on top.

A swatch matches its layer: a filled box for an area, a dot for a point, a line for a route, each in the layer’s color. For many categories inside one layer, such as a choropleth, the legend lists the classes instead, as in Style GeoJSON in Leaflet.

Next steps

The basemaps here are three of the seven Maptoolkit raster styles; all of them are listed in Maptoolkit as a Leaflet Tile Provider. How the bands are built is explained in Draw Travel Time Bands, and the pins in Custom Markers.

In Maptoolkit Maps JS, Add an Opacity Slider and Layer Toggle toggles a layer, and Add a Legend for a Data-Driven Layer builds a legend from a style expression.