Skip to content

Create a Heatmap in Leaflet with Leaflet.heat

Leaflet.heat draws points as a heatmap on a canvas layer. This example weights 7,342 cities and towns by their population, so the heat shows where people live: the Rhine-Ruhr and the Low Countries, England, Paris and northern Italy stand out. It uses the Maptoolkit Light style, which keeps the colors readable. Move the sliders to see what radius and blur do.

const map = L.map("map").setView([50, 10], 5);

    L.tileLayer("https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.light/{z}/{x}/{y}{r}.png?api_key=YOUR_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);

    let heat;

    // 7,342 cities and towns worldwide with their population, from Natural Earth (public domain).
    fetch("https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson")
      .then((response) => response.json())
      .then((data) => {
        // [latitude, longitude, intensity]. The square root keeps a city of 10 million from
        // drowning out everything else: it counts about 30 times as much as a town of 10,000, not 1,000.
        const points = data.features.map((feature) => [
          feature.geometry.coordinates[1],
          feature.geometry.coordinates[0],
          Math.sqrt(feature.properties.pop_max) / 1000,
        ]);

        heat = L.heatLayer(points, {
          radius: 25,
          blur: 20,
          // Points reach full intensity at this zoom and fade by half for every zoom level below it.
          maxZoom: 5,
          // The summed intensity that shows as full red. Higher values keep dense areas graded.
          max: 3,
          attribution: "<a href='https://www.naturalearthdata.com/' target='_blank'>Natural Earth</a>",
        }).addTo(map);
      });

    for (const option of ["radius", "blur"]) {
      const input = document.getElementById(option);
      input.addEventListener("input", () => {
        document.getElementById(`${option}-value`).textContent = input.value;
        heat?.setOptions({ [option]: Number(input.value) });
      });
    }
<!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>
  <script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #panel {
      position: absolute; top: 12px; right: 12px; z-index: 1000; width: 220px; padding: 12px 14px;
      background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.4 system-ui, sans-serif; color: #1f2430;
    }
    #panel strong { display: block; margin-bottom: 8px; font-size: 14px; }
    #panel label { display: flex; justify-content: space-between; margin-top: 6px; color: #4a5068; }
    #panel input { width: 100%; accent-color: #303f7e; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="panel">
    <strong>Population of cities and towns</strong>
    <label>Radius <span id="radius-value">25</span></label>
    <input id="radius" type="range" min="5" max="50" value="25" />
    <label>Blur <span id="blur-value">20</span></label>
    <input id="blur" type="range" min="1" max="40" value="20" />
  </div>
  <script>
    const map = L.map("map").setView([50, 10], 5);

    L.tileLayer("https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.light/{z}/{x}/{y}{r}.png?api_key=YOUR_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);

    let heat;

    // 7,342 cities and towns worldwide with their population, from Natural Earth (public domain).
    fetch("https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson")
      .then((response) => response.json())
      .then((data) => {
        // [latitude, longitude, intensity]. The square root keeps a city of 10 million from
        // drowning out everything else: it counts about 30 times as much as a town of 10,000, not 1,000.
        const points = data.features.map((feature) => [
          feature.geometry.coordinates[1],
          feature.geometry.coordinates[0],
          Math.sqrt(feature.properties.pop_max) / 1000,
        ]);

        heat = L.heatLayer(points, {
          radius: 25,
          blur: 20,
          // Points reach full intensity at this zoom and fade by half for every zoom level below it.
          maxZoom: 5,
          // The summed intensity that shows as full red. Higher values keep dense areas graded.
          max: 3,
          attribution: "<a href='https://www.naturalearthdata.com/' target='_blank'>Natural Earth</a>",
        }).addTo(map);
      });

    for (const option of ["radius", "blur"]) {
      const input = document.getElementById(option);
      input.addEventListener("input", () => {
        document.getElementById(`${option}-value`).textContent = input.value;
        heat?.setOptions({ [option]: Number(input.value) });
      });
    }
  </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 Europe with the Maptoolkit Light raster style at zoom level 5. Load the Natural Earth populated places GeoJSON into a Leaflet.heat heatmap weighted by the square root of each place’s population, and add sliders for the radius and blur.

How it works

L.heatLayer takes an array of [latitude, longitude, intensity]. The GeoJSON stores [longitude, latitude], so the order is swapped when the points are built. The third value is the weight, here taken from each place’s population in pop_max.

Weight by the square root, not the raw number. Populations range from about 10,000 to over 20 million. Used directly, the few largest cities would be the only thing on the map. The square root compresses that range, so a city of 10 million counts about 30 times as much as a town of 10,000, and dense regions made of many mid-sized cities, like the Rhine-Ruhr, show up as clearly as a single capital.

maxZoom decides whether you see anything. Leaflet.heat scales every point by 1 / 2^(maxZoom - zoom), and maxZoom defaults to the map’s own maximum, 18 on raster tiles. At zoom 3 that divides each point by thousands, and the heatmap fades to a faint haze. Setting maxZoom to about the zoom you show the data at gives the points their full weight there. With the default, points fade to a faint haze at this zoom; with maxZoom: 5, the dense areas turn red.

max keeps dense areas graded. It sets the summed intensity that shows as full red. Too low, and every region with a few cities is the same solid red; at 3 here, the densest regions still show which parts are densest.

Radius and blur shape the spots. radius is the size of each point in pixels, and blur softens its edge. Both are in screen pixels, so the spots stay the same size on screen as you zoom. setOptions() applies a change without rebuilding the layer, which is what the sliders call.

The data is from Natural Earth, which is public domain; its credit goes into the heat layer’s attribution option, next to the one the raster basemap needs. The file is about 530 kB compressed.

It shows where the counted places are, weighted by their size, which is close to where people live but not a density grid: rural population between the places is not in the data.

Leaflet.heat’s last release was 0.2.0, and it works with Leaflet 1.9.4 as shown.

Next steps

A heatmap answers where things are dense, not where each one is. Past the zoom level where individual places matter, swap to markers or clustered markers, which show every point with a popup.

For a heatmap drawn in WebGL, with the weighting and color ramp written as style expressions, Create a Heatmap Layer shows the same idea in Maptoolkit Maps JS.