Create a Heatmap in Leaflet with Leaflet.heat
Leaflet.heat draws points as a heatmap on a canvas layer. This example loads the Eurostat population grid, which counts the people living in every 10 by 10 km cell of Europe, and turns each of its 50,269 inhabited cells into a point weighted by its population. London, Paris, the Rhine-Ruhr, the Randstad and Milan turn red, and the thinly populated countryside stays a faint blue. 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);
// The grid is in the European equal-area projection (EPSG:3035), in meters.
proj4.defs("EPSG:3035", "+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80 +units=m +no_defs");
let heat;
// One row per inhabited 10 km cell: x,y of its lower left corner and its population (Eurostat, 2018).
fetch("https://raw.githubusercontent.com/eurostat/gridviz/master/assets/csv/Europe/pop_2018_10km.csv")
.then((response) => response.text())
.then((csv) => {
const points = [];
for (const row of csv.trim().split("\n").slice(1)) {
const [x, y, population] = row.split(",").map(Number);
if (!population) continue;
// The cell center, 5 km from the corner, converted to [longitude, latitude].
const [lng, lat] = proj4("EPSG:3035", "EPSG:4326", [x + 5000, y + 5000]);
// [latitude, longitude, intensity]
points.push([lat, lng, population]);
}
heat = L.heatLayer(points, {
radius: 10,
blur: 10,
// Points reach full intensity at this zoom and fade by half for every zoom level below it.
maxZoom: 5,
// The summed population that shows as full red: a metropolitan core, not a small town.
max: 1000000,
attribution: "<a href='https://ec.europa.eu/eurostat/web/gisco/geodata/population-distribution' target='_blank'>Eurostat</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>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.22.0/dist/proj4.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 per 10 km cell</strong>
<label>Radius <span id="radius-value">10</span></label>
<input id="radius" type="range" min="3" max="30" value="10" />
<label>Blur <span id="blur-value">10</span></label>
<input id="blur" type="range" min="1" max="30" value="10" />
</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);
// The grid is in the European equal-area projection (EPSG:3035), in meters.
proj4.defs("EPSG:3035", "+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80 +units=m +no_defs");
let heat;
// One row per inhabited 10 km cell: x,y of its lower left corner and its population (Eurostat, 2018).
fetch("https://raw.githubusercontent.com/eurostat/gridviz/master/assets/csv/Europe/pop_2018_10km.csv")
.then((response) => response.text())
.then((csv) => {
const points = [];
for (const row of csv.trim().split("\n").slice(1)) {
const [x, y, population] = row.split(",").map(Number);
if (!population) continue;
// The cell center, 5 km from the corner, converted to [longitude, latitude].
const [lng, lat] = proj4("EPSG:3035", "EPSG:4326", [x + 5000, y + 5000]);
// [latitude, longitude, intensity]
points.push([lat, lng, population]);
}
heat = L.heatLayer(points, {
radius: 10,
blur: 10,
// Points reach full intensity at this zoom and fade by half for every zoom level below it.
maxZoom: 5,
// The summed population that shows as full red: a metropolitan core, not a small town.
max: 1000000,
attribution: "<a href='https://ec.europa.eu/eurostat/web/gisco/geodata/population-distribution' target='_blank'>Eurostat</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.
How it works
L.heatLayer takes an array of [latitude, longitude, intensity]. Here every point is the
center of one grid cell, and the intensity is the number of people living in it.
A grid, not a list of places. The data counts everyone, including the people who live between towns, so the faint wash over the countryside is real population and the red cores are where it is densest. A list of cities would only show where the counted places are.
Converting the grid coordinates. Eurostat publishes the grid in EPSG:3035, the European equal-area projection, with coordinates in meters. proj4 converts them to longitude and latitude in the browser. Each row gives a cell’s lower left corner, so 5,000 m is added to both values to place the point in the middle of the cell.
Raw population as the weight. Because every cell has the same size, its population is its
density, and the weight needs no scaling. Leaflet.heat adds up the weights of points that fall
close together on screen. max sets the sum that shows as full red. At one million, only
metropolitan areas reach red and the rest of the range stays graded. Lower it and whole regions
turn solid red; raise it and only the largest cities stay visible.
Radius and blur match the grid. At zoom level 5 the cell centers are about 3 pixels apart.
A radius of 10 pixels blends neighboring cells into a continuous surface without smearing a
city over its region. Both values 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.
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.
Setting it to the zoom you show the data at, 5 here, gives the points their full weight there.
With the default, the heatmap fades to a faint haze.
The grid covers the European Union, the EFTA countries and the United Kingdom, among others.
Countries outside it, such as Belarus and Ukraine, stay empty. The CSV is about 1.1 MB. Eurostat
also publishes the grid at 5, 20, 50 and 100 km in the same format: coarser cells load faster,
and need a larger radius and a higher max.
The data is from Eurostat,
loaded from the gridviz repository. Its credit goes into
the heat layer’s attribution option, next to the one the raster basemap needs.
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 MapLibre GL JS.