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.
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.