Skip to content

Show Weather Data in Leaflet

The Weather API delivers its data as vector tiles, which Leaflet cannot draw by itself. This example draws them with the maplibre-gl-leaflet plugin, as a transparent layer over a Maptoolkit raster basemap. Switch between temperature, rain and wind with the buttons on the left; the legend below them follows, in the colors of the Maptoolkit weather map.

const API_KEY = "YOUR_API_KEY";

    // Colors and opacities of the Maptoolkit weather map at maptoolkit.com/weather.
    const LAYERS = {
      temperature: {
        field: "degree", unit: "°C", opacity: 0.4, legendOpacity: 0.4, outline: "rgba(0, 0, 0, 0.2)", type: "interpolate",
        stops: [[-30, "rgb(150, 0, 104)"], [-20, "rgb(0, 0, 120)"], [-10, "rgb(0, 138, 196)"], [0, "rgb(20, 165, 145)"],
          [10, "rgb(140, 200, 30)"], [20, "rgb(255, 219, 0)"], [30, "rgb(210, 100, 10)"], [40, "rgb(135, 0, 15)"]],
      },
      precipitation: {
        field: "mm", unit: "mm", opacity: 0.4, legendOpacity: 0.45, outline: "rgba(0, 0, 0, 0.225)", type: "interpolate",
        stops: [[0.2, "rgb(47, 75, 190)"], [1, "rgb(21, 122, 151)"], [2, "rgb(10, 165, 77)"], [5, "rgb(0, 210, 0)"],
          [12, "rgb(255, 255, 0)"], [20, "rgb(230, 100, 0)"], [25, "rgb(229, 0, 0)"], [30, "rgb(170, 0, 29)"]],
      },
      wind: {
        // The tiles carry m/s; the legend shows km/h like the Maptoolkit weather map.
        field: "ms", unit: "km/h", opacity: 0.35, legendOpacity: 0.4, outline: "rgba(0, 0, 0, 0.175)", type: "step", toLabel: (ms) => Math.round(ms * 3.6),
        base: "rgb(100, 0, 254)",
        stops: [[4, "rgb(0, 50, 254)"], [6, "rgb(0, 150, 254)"], [8, "rgb(0, 230, 240)"], [10, "rgb(17, 212, 17)"],
          [12, "rgb(0, 250, 0)"], [14, "rgb(254, 254, 0)"], [16, "rgb(254, 200, 0)"], [18, "rgb(254, 150, 0)"],
          [20, "rgb(230, 100, 0)"], [22, "rgb(200, 50, 29)"], [24, "rgb(170, 0, 29)"], [26, "rgb(200, 0, 100)"], [28, "rgb(254, 0, 150)"]],
      },
    };

    function fillColor({ type, field, stops, base }) {
      return type === "step"
        ? ["step", ["get", field], base, ...stops.flat()]
        : ["interpolate", ["linear"], ["get", field], ...stops.flat()];
    }

    // One legend row per stop, highest first, tinted with the layer's own color and opacity.
    function legendRows({ stops, base, legendOpacity, toLabel = (v) => v }) {
      const rows = stops.map(([value, color]) => [toLabel(value), color]);
      if (base) rows.unshift([toLabel(stops[0][0] / 2), base]);
      return rows.reverse().map(([label, color]) =>
        `<div style="background:${color.replace("rgb(", "rgba(").replace(")", `, ${legendOpacity})`)}">${label}</div>`).join("");
    }

    // The weather tiles start at MapLibre zoom 3, which is Leaflet zoom 4 through the plugin.
    const map = L.map("map", { minZoom: 4, zoomControl: false }).setView([48.5, 11], 5);
    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);

    // The weather gets its own pane above the tiles (200) and below markers and popups (400).
    // In the default tile pane, the raster layer would paint over it.
    map.createPane("weather");
    map.getPane("weather").style.zIndex = 350;
    map.getPane("weather").style.pointerEvents = "none";

    // A MapLibre style with no basemap: only the weather source and one fill layer per value,
    // so the Leaflet tiles show through wherever the weather is transparent.
    const weather = L.maplibreGL({
      pane: "weather",
      style: {
        version: 8,
        sources: {
          weather: { type: "vector", url: `https://weather.maptoolkit.net/vector/icon.0.json?api_key=${API_KEY}` },
        },
        layers: Object.entries(LAYERS).map(([name, layer]) => ({
          id: `weather-${name}`, type: "fill", source: "weather", "source-layer": name,
          layout: { visibility: name === "temperature" ? "visible" : "none" },
          paint: { "fill-color": fillColor(layer), "fill-opacity": layer.opacity, "fill-outline-color": layer.outline },
        })),
      },
    }).addTo(map);

    function show(key) {
      const glMap = weather.getMaplibreMap();
      const apply = () => {
        for (const name of Object.keys(LAYERS)) {
          glMap.setLayoutProperty(`weather-${name}`, "visibility", name === key ? "visible" : "none");
        }
      };
      // The plugin's MapLibre map loads its style asynchronously; wait for it on the first call.
      glMap.isStyleLoaded() ? apply() : glMap.once("load", apply);
      document.querySelectorAll("#weather button").forEach((button) =>
        button.classList.toggle("active", button.dataset.key === key));
      document.getElementById("legend").innerHTML = `<div class="unit">${LAYERS[key].unit}</div>` + legendRows(LAYERS[key]);
    }

    // Buttons and legend sit over the map, so clicks on them must not reach Leaflet.
    L.DomEvent.disableClickPropagation(document.getElementById("weather"));
    show("temperature");

    document.querySelectorAll("#weather button").forEach((button) =>
      button.addEventListener("click", () => show(button.dataset.key)));
<!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" />
  <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/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-leaflet@0.1.4/leaflet-maplibre-gl.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #weather { position: absolute; top: 10px; left: 10px; z-index: 1000; width: 36px; font: 11px/1 system-ui, sans-serif; }
    #weather .buttons, #weather .legend { background: #fff; border-radius: 6px; box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); overflow: hidden; }
    #weather button {
      display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: none; background: none;
      cursor: pointer; color: #9aa0b4;
    }
    #weather button + button { border-top: 1px solid #eee; }
    #weather button.active { color: #303f7e; background: #eef0f6; }
    #weather button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
    #weather .legend { margin-top: 10px; text-align: center; }
    #weather .unit { padding: 5px 0; font-weight: 600; }
    #weather .legend div:not(.unit) { padding: 5px 0; color: #1f2430; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="weather">
    <div class="buttons">
      <button data-key="temperature" title="Temperature"><svg viewBox="0 0 24 24"><path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/></svg></button>
      <button data-key="precipitation" title="Precipitation"><svg viewBox="0 0 24 24"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg></button>
      <button data-key="wind" title="Wind"><svg viewBox="0 0 24 24"><path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2"/></svg></button>
    </div>
    <div class="legend" id="legend"></div>
  </div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    // Colors and opacities of the Maptoolkit weather map at maptoolkit.com/weather.
    const LAYERS = {
      temperature: {
        field: "degree", unit: "°C", opacity: 0.4, legendOpacity: 0.4, outline: "rgba(0, 0, 0, 0.2)", type: "interpolate",
        stops: [[-30, "rgb(150, 0, 104)"], [-20, "rgb(0, 0, 120)"], [-10, "rgb(0, 138, 196)"], [0, "rgb(20, 165, 145)"],
          [10, "rgb(140, 200, 30)"], [20, "rgb(255, 219, 0)"], [30, "rgb(210, 100, 10)"], [40, "rgb(135, 0, 15)"]],
      },
      precipitation: {
        field: "mm", unit: "mm", opacity: 0.4, legendOpacity: 0.45, outline: "rgba(0, 0, 0, 0.225)", type: "interpolate",
        stops: [[0.2, "rgb(47, 75, 190)"], [1, "rgb(21, 122, 151)"], [2, "rgb(10, 165, 77)"], [5, "rgb(0, 210, 0)"],
          [12, "rgb(255, 255, 0)"], [20, "rgb(230, 100, 0)"], [25, "rgb(229, 0, 0)"], [30, "rgb(170, 0, 29)"]],
      },
      wind: {
        // The tiles carry m/s; the legend shows km/h like the Maptoolkit weather map.
        field: "ms", unit: "km/h", opacity: 0.35, legendOpacity: 0.4, outline: "rgba(0, 0, 0, 0.175)", type: "step", toLabel: (ms) => Math.round(ms * 3.6),
        base: "rgb(100, 0, 254)",
        stops: [[4, "rgb(0, 50, 254)"], [6, "rgb(0, 150, 254)"], [8, "rgb(0, 230, 240)"], [10, "rgb(17, 212, 17)"],
          [12, "rgb(0, 250, 0)"], [14, "rgb(254, 254, 0)"], [16, "rgb(254, 200, 0)"], [18, "rgb(254, 150, 0)"],
          [20, "rgb(230, 100, 0)"], [22, "rgb(200, 50, 29)"], [24, "rgb(170, 0, 29)"], [26, "rgb(200, 0, 100)"], [28, "rgb(254, 0, 150)"]],
      },
    };

    function fillColor({ type, field, stops, base }) {
      return type === "step"
        ? ["step", ["get", field], base, ...stops.flat()]
        : ["interpolate", ["linear"], ["get", field], ...stops.flat()];
    }

    // One legend row per stop, highest first, tinted with the layer's own color and opacity.
    function legendRows({ stops, base, legendOpacity, toLabel = (v) => v }) {
      const rows = stops.map(([value, color]) => [toLabel(value), color]);
      if (base) rows.unshift([toLabel(stops[0][0] / 2), base]);
      return rows.reverse().map(([label, color]) =>
        `<div style="background:${color.replace("rgb(", "rgba(").replace(")", `, ${legendOpacity})`)}">${label}</div>`).join("");
    }

    // The weather tiles start at MapLibre zoom 3, which is Leaflet zoom 4 through the plugin.
    const map = L.map("map", { minZoom: 4, zoomControl: false }).setView([48.5, 11], 5);
    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);

    // The weather gets its own pane above the tiles (200) and below markers and popups (400).
    // In the default tile pane, the raster layer would paint over it.
    map.createPane("weather");
    map.getPane("weather").style.zIndex = 350;
    map.getPane("weather").style.pointerEvents = "none";

    // A MapLibre style with no basemap: only the weather source and one fill layer per value,
    // so the Leaflet tiles show through wherever the weather is transparent.
    const weather = L.maplibreGL({
      pane: "weather",
      style: {
        version: 8,
        sources: {
          weather: { type: "vector", url: `https://weather.maptoolkit.net/vector/icon.0.json?api_key=${API_KEY}` },
        },
        layers: Object.entries(LAYERS).map(([name, layer]) => ({
          id: `weather-${name}`, type: "fill", source: "weather", "source-layer": name,
          layout: { visibility: name === "temperature" ? "visible" : "none" },
          paint: { "fill-color": fillColor(layer), "fill-opacity": layer.opacity, "fill-outline-color": layer.outline },
        })),
      },
    }).addTo(map);

    function show(key) {
      const glMap = weather.getMaplibreMap();
      const apply = () => {
        for (const name of Object.keys(LAYERS)) {
          glMap.setLayoutProperty(`weather-${name}`, "visibility", name === key ? "visible" : "none");
        }
      };
      // The plugin's MapLibre map loads its style asynchronously; wait for it on the first call.
      glMap.isStyleLoaded() ? apply() : glMap.once("load", apply);
      document.querySelectorAll("#weather button").forEach((button) =>
        button.classList.toggle("active", button.dataset.key === key));
      document.getElementById("legend").innerHTML = `<div class="unit">${LAYERS[key].unit}</div>` + legendRows(LAYERS[key]);
    }

    // Buttons and legend sit over the map, so clicks on them must not reach Leaflet.
    L.DomEvent.disableClickPropagation(document.getElementById("weather"));
    show("temperature");

    document.querySelectorAll("#weather button").forEach((button) =>
      button.addEventListener("click", () => show(button.dataset.key)));
  </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 Central Europe on the Maptoolkit Summer raster style, and overlay the Maptoolkit Weather API vector tiles with maplibre-gl-leaflet in their own pane. Add buttons that switch between temperature, rain and wind, and a vertical legend, using the colors of the Maptoolkit weather map.

How it works

The weather is a vector tile source, not a REST call. The TileJSON at weather.maptoolkit.net/vector/icon.0.json describes tiles with one layer per value: temperature with the field degree, precipitation with mm and wind with ms. A fill layer colors each area by its field.

The overlay is a MapLibre style with no basemap. The style passed to L.maplibreGL has only the weather source and one fill layer, and no background layer, so the canvas is transparent wherever there is no weather to draw and the raster basemap shows through.

It needs its own pane. By default the plugin puts its canvas into Leaflet’s tile pane, next to the raster layer, and the raster layer paints over it: the weather tiles load, but nothing appears. A custom pane with a z-index of 350 places the weather above the tiles (200) and below markers and popups (400), and pointer-events: none lets clicks and drags through to the map.

One fill layer per value, switched with visibility. The style defines a fill layer for each of temperature, precipitation and wind, and show() sets visibility through getMaplibreMap() so only one is drawn. On the first call the plugin’s style may still be loading, so show() waits for its load event before changing anything. The source is shared, so switching downloads nothing again.

The colors are those of the Maptoolkit weather map. Temperature and rain interpolate between the same stops, at the same opacity, as maptoolkit.com/weather, and wind uses its step expression in 2 m/s steps. The legend is built from the same stops, highest value on top, and the wind legend converts the tiles’ m/s to km/h, as on the weather map.

The plugin copies the Deutscher Wetterdienst credit from the weather source into Leaflet’s attribution control. The raster basemap still needs its own attribution option.

Coverage and zoom. The data comes from the DWD ICON models: finest over Germany and its neighbors, coarser across the rest of Europe, and empty outside it. The tiles start at MapLibre zoom 3, which the plugin shows at Leaflet zoom 4, so minZoom: 4 keeps the map from zooming out past the data.

Next steps

The tiles also carry a weather_label layer with temperature, wind speed and direction per place, which a symbol layer can print on the map.

To put the weather below the map labels, draw the basemap with vector tiles as well: the Weather API with MapLibre example inserts the same layer below the style’s first label layer.