Cluster points in MapLibre GL JS

With hundreds of points on a map, they pile on top of each other until you zoom in. Clustering groups nearby points into one circle with a count, and splits the groups apart as you zoom. MapLibre GL JS does this itself: set cluster: true on a GeoJSON source, and it groups the points again at every zoom level.

This guide maps 156 coffee shops across Portland, from Mapricot's nearby search, as clusters and as a heatmap. The map is one HTML file with no build step, and a short Node.js script collects the points. It was tested with MapLibre GL JS 6.15 in Chromium and Node.js 22.

Click a cluster to zoom in to its points, and a single point for its name. Move the map and press "Add coffee shops here" to add the ones around its center from the live API.

Before you start

You need two keys, both made as Getting started shows, with the Places product:

  • A server key, for the script that collects the points. It runs on your computer, so the key stays secret.
  • A browser key, for the map's "Add coffee shops here" button. Add http://localhost:* to its allowed origins, so it works from your computer while you build.

You also need Node.js 18 or later to run the script. The map tiles need no key.

Collect the points

Nearby search returns up to 20 places a request, so to cover a city, this script asks around ten neighborhoods and keeps each place once. It saves them as coffee-shops.geojson, which the map loads.

Make a folder for the map, and in it a file called coffee-shops.mjs:

import { writeFile } from "node:fs/promises";

const neighborhoods = [
  { lat: 45.5189, lon: -122.6793 }, // Downtown
  { lat: 45.5265, lon: -122.6825 }, // Pearl District
  { lat: 45.5275, lon: -122.6985 }, // Nob Hill
  { lat: 45.5485, lon: -122.6755 }, // Mississippi
  { lat: 45.559, lon: -122.6445 }, // Alberta
  { lat: 45.5345, lon: -122.6205 }, // Hollywood
  { lat: 45.512, lon: -122.627 }, // Hawthorne
  { lat: 45.5047, lon: -122.6435 }, // Division
  { lat: 45.4655, lon: -122.653 }, // Sellwood
  { lat: 45.59, lon: -122.7535 }, // St. Johns
];

const places = new Map();
for (const { lat, lon } of neighborhoods) {
  const response = await fetch(
    `https://api.mapricot.com/v1/nearby?lat=${lat}&lon=${lon}&category=food:coffee_shop&limit=20`,
    { headers: { Authorization: `Bearer ${process.env.MAPRICOT_KEY}` } },
  );
  if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
  const { results } = await response.json();
  for (const place of results) places.set(place.id, place);
}

const features = [...places.values()].map((place) => ({
  type: "Feature",
  properties: { id: place.id, name: place.name },
  geometry: { type: "Point", coordinates: [place.lon, place.lat] },
}));
await writeFile("coffee-shops.geojson", JSON.stringify({ type: "FeatureCollection", features }));
console.log(`Saved ${features.length} coffee shops`);

In the folder, run it with your server key:

MAPRICOT_KEY=mapricot_sk_… node coffee-shops.mjs

It prints Saved 156 coffee shops, or however many there are today, and writes the file next to it.

  • The requests go one at a time, since an account can only have a few in progress at once.
  • Neighborhoods close together return some of the same places. The Map is keyed by Mapricot's ID, so each place is kept only once.
  • Each feature keeps the place's id and name. The map shows the name, and uses the ID to tell new places from ones it already has.
  • Loading the points from a file means the map costs no requests when it opens. The same file could hold your own points instead, such as store locations from your database.

Make the page

Next to coffee-shops.geojson, make index.html with a map:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Coffee shops in Portland</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
  </head>
  <body style="margin: 0">
    <div id="map" style="height: 100vh"></div>
    <script type="module">
      import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";

      const key = "mapricot_pk_…";
      const attribution = '<a href="https://docs.overturemaps.org/attribution/">Overture Maps Foundation</a>';

      const map = new maplibregl.Map({
        container: "map",
        style: "https://tiles.mapricot.com/styles/light.json",
        center: [-122.67, 45.53],
        zoom: 11,
      });
      await map.once("load");
    </script>
  </body>
</html>

Put your browser key in place of mapricot_pk_…. The page loads MapLibre and its stylesheet from jsDelivr, and draws the map in the map element, which fills the window. Markers and popups explains each part of a page like this. map.once("load") waits for the style to load, since sources and layers can only be added after it does.

attribution is the credit for the places' other source, the Overture Maps Foundation. The map style already credits OpenStreetMap.

Open the page

Serve the folder with a web server, rather than opening the file directly. Opened from a file, the map stays blank with Worker failed to load, since browsers don't let a page from a file start MapLibre's web worker, and the API would refuse the browser key, since the page has no origin. In the folder, run one of these:

npx serve
python3 -m http.server

npx serve serves the page at http://localhost:3000, and Python's at http://localhost:8000. Open the address to see a map of Portland, and reload it after each change below.

Add the points as a clustered source

Add the file as a source, with clustering on. Add this at the end of the script:

map.addSource("coffee", {
  type: "geojson",
  data: "coffee-shops.geojson",
  cluster: true,
  clusterRadius: 50,
  clusterMaxZoom: 15,
  attribution,
});
  • data takes GeoJSON or a URL to it, here the file the script saved, next to the page.
  • clusterRadius is how close, in pixels, points have to be to join a cluster. Larger values make fewer, bigger clusters.
  • clusterMaxZoom is the last zoom level with clusters. Past it, every point shows by itself, even ones at the same spot.

Nothing shows yet: a source only holds data, and layers draw it.

Draw the clusters

The clustered source has two kinds of feature: clusters, which have a point_count property, and points that didn't join one. Draw each with its own layers, picked out by filter. Add this at the end of the script:

map.addLayer({
  id: "clusters",
  type: "circle",
  source: "coffee",
  filter: ["has", "point_count"],
  paint: {
    "circle-color": "#fd7e14",
    "circle-radius": ["step", ["get", "point_count"], 14, 10, 18, 25, 24],
    "circle-stroke-color": "#ffffff",
    "circle-stroke-width": 2,
  },
});
map.addLayer({
  id: "cluster-counts",
  type: "symbol",
  source: "coffee",
  filter: ["has", "point_count"],
  layout: {
    "text-field": ["get", "point_count_abbreviated"],
    "text-font": ["Noto Sans Medium"],
    "text-size": 13,
  },
  paint: { "text-color": "#212529" },
});
map.addLayer({
  id: "places",
  type: "circle",
  source: "coffee",
  filter: ["!", ["has", "point_count"]],
  paint: {
    "circle-radius": 6,
    "circle-color": "#fd7e14",
    "circle-stroke-color": "#ffffff",
    "circle-stroke-width": 2,
  },
});

The step expression sizes clusters by count: a radius of 14 pixels below 10 points, 18 from 10, and 24 from 25. point_count_abbreviated is the count as text, shortened to 1.2k past a thousand. text-font has to be a font the style has; Mapricot's styles have Noto Sans Regular, Medium, and Italic.

Zoom in on click

Clicking a cluster should show what's in it. The source works out the zoom level where the cluster splits apart. Add this at the end of the script:

map.on("click", "clusters", async (event) => {
  const [cluster] = event.features;
  const zoom = await map.getSource("coffee").getClusterExpansionZoom(cluster.properties.cluster_id);
  map.easeTo({ center: cluster.geometry.coordinates, zoom });
});
map.on("click", "places", (event) => {
  const [place] = event.features;
  new maplibregl.Popup({ offset: 10 })
    .setLngLat(place.geometry.coordinates)
    .setText(place.properties.name)
    .addTo(map);
});
for (const layer of ["clusters", "places"]) {
  map.on("mouseenter", layer, () => (map.getCanvas().style.cursor = "pointer"));
  map.on("mouseleave", layer, () => (map.getCanvas().style.cursor = ""));
}

Passing a layer's ID to map.on limits the event to that layer's features, and event.features lists the ones under the pointer. Single points open a popup with their name. setText shows it as plain text, so a name can't add HTML to the page. To list a cluster's places instead of zooming, getClusterLeaves(clusterId, limit, offset) returns its points.

Show a heatmap instead

A heatmap shows where points are dense without counting them. It needs a source without clustering, so add the same file again, as a second source with a heatmap layer. Add this at the end of the script:

const labels = map.getStyle().layers.find((layer) => layer.type === "symbol").id;
map.addSource("coffee-heat", { type: "geojson", data: "coffee-shops.geojson", attribution });
map.addLayer(
  {
    id: "heatmap",
    type: "heatmap",
    source: "coffee-heat",
    layout: { visibility: "none" },
    paint: {
      "heatmap-radius": 30,
      "heatmap-intensity": 0.3,
      "heatmap-color": [
        "interpolate",
        ["linear"],
        ["heatmap-density"],
        0, "rgba(255, 218, 106, 0)",
        0.3, "#ffda6a",
        0.7, "#fd7e14",
        1, "#ca6510",
      ],
    },
  },
  labels,
);
  • heatmap-color maps density, from 0 to 1, to a color: transparent where there are no points, through yellow and orange to dark orange where they're densest.
  • heatmap-radius is how far, in pixels, each point spreads, and heatmap-intensity scales the density. With many points close together, a lower intensity keeps the busiest areas from all reaching the darkest color.
  • The second argument to addLayer puts the heatmap under the style's first label layer, so street and place names stay readable on top of it.
  • visibility: "none" hides it to start with.

To switch between the two, add a button above the map element, in the body. The style puts it over the top-left corner of the map:

<div style="position: absolute; z-index: 1; top: 10px; left: 10px">
  <button id="toggle">Show a heatmap</button>
</div>

Then, at the end of the script, show one set of layers and hide the other when it's clicked:

const toggle = document.querySelector("#toggle");
toggle.addEventListener("click", () => {
  const heatmap = map.getLayoutProperty("heatmap", "visibility") === "none";
  map.setLayoutProperty("heatmap", "visibility", heatmap ? "visible" : "none");
  for (const layer of ["clusters", "cluster-counts", "places"]) {
    map.setLayoutProperty(layer, "visibility", heatmap ? "none" : "visible");
  }
  toggle.textContent = heatmap ? "Show clusters" : "Show a heatmap";
});

Add points as people explore

To let people fill in parts of the map the script didn't cover, ask for the places around the map's center and add the new ones. Put a second button next to the first, inside the same div:

<button id="add-here">Add coffee shops here</button>

Then add this at the end of the script:

document.querySelector("#add-here").addEventListener("click", async () => {
  const { lat, lng } = map.getCenter().wrap();
  const response = await fetch(
    `https://api.mapricot.com/v1/nearby?lat=${lat.toFixed(6)}&lon=${lng.toFixed(6)}&category=food:coffee_shop&limit=20`,
    { headers: { Authorization: `Bearer ${key}` } },
  );
  if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
  const { results } = await response.json();

  const data = await map.getSource("coffee").getData();
  const known = new Set(data.features.map((feature) => feature.properties.id));
  for (const place of results) {
    if (known.has(place.id)) continue;
    data.features.push({
      type: "Feature",
      properties: { id: place.id, name: place.name },
      geometry: { type: "Point", coordinates: [place.lon, place.lat] },
    });
  }
  map.getSource("coffee").setData(data);
  map.getSource("coffee-heat").setData(data);
});
  • wrap brings the longitude back between -180 and 180 after the map has been panned around the world, and toFixed(6) rounds the coordinates, which MapLibre gives to about 14 decimal places. Mapricot accepts at most 10.
  • getData returns the source's GeoJSON, which starts as what it loaded from the file.
  • The Set of IDs skips places the map already has, the same way the script did.
  • setData replaces each source's data, and the clusters and heatmap are worked out again.

The whole page

Here's index.html with everything above, ready to copy. Put your browser key in place of mapricot_pk_…, and keep coffee-shops.geojson next to it:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Coffee shops in Portland</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
  </head>
  <body style="margin: 0">
    <div style="position: absolute; z-index: 1; top: 10px; left: 10px">
      <button id="toggle">Show a heatmap</button>
      <button id="add-here">Add coffee shops here</button>
    </div>
    <div id="map" style="height: 100vh"></div>
    <script type="module">
      import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";

      const key = "mapricot_pk_…";
      const attribution = '<a href="https://docs.overturemaps.org/attribution/">Overture Maps Foundation</a>';

      const map = new maplibregl.Map({
        container: "map",
        style: "https://tiles.mapricot.com/styles/light.json",
        center: [-122.67, 45.53],
        zoom: 11,
      });
      await map.once("load");

      map.addSource("coffee", {
        type: "geojson",
        data: "coffee-shops.geojson",
        cluster: true,
        clusterRadius: 50,
        clusterMaxZoom: 15,
        attribution,
      });

      map.addLayer({
        id: "clusters",
        type: "circle",
        source: "coffee",
        filter: ["has", "point_count"],
        paint: {
          "circle-color": "#fd7e14",
          "circle-radius": ["step", ["get", "point_count"], 14, 10, 18, 25, 24],
          "circle-stroke-color": "#ffffff",
          "circle-stroke-width": 2,
        },
      });
      map.addLayer({
        id: "cluster-counts",
        type: "symbol",
        source: "coffee",
        filter: ["has", "point_count"],
        layout: {
          "text-field": ["get", "point_count_abbreviated"],
          "text-font": ["Noto Sans Medium"],
          "text-size": 13,
        },
        paint: { "text-color": "#212529" },
      });
      map.addLayer({
        id: "places",
        type: "circle",
        source: "coffee",
        filter: ["!", ["has", "point_count"]],
        paint: {
          "circle-radius": 6,
          "circle-color": "#fd7e14",
          "circle-stroke-color": "#ffffff",
          "circle-stroke-width": 2,
        },
      });

      map.on("click", "clusters", async (event) => {
        const [cluster] = event.features;
        const zoom = await map.getSource("coffee").getClusterExpansionZoom(cluster.properties.cluster_id);
        map.easeTo({ center: cluster.geometry.coordinates, zoom });
      });
      map.on("click", "places", (event) => {
        const [place] = event.features;
        new maplibregl.Popup({ offset: 10 })
          .setLngLat(place.geometry.coordinates)
          .setText(place.properties.name)
          .addTo(map);
      });
      for (const layer of ["clusters", "places"]) {
        map.on("mouseenter", layer, () => (map.getCanvas().style.cursor = "pointer"));
        map.on("mouseleave", layer, () => (map.getCanvas().style.cursor = ""));
      }

      const labels = map.getStyle().layers.find((layer) => layer.type === "symbol").id;
      map.addSource("coffee-heat", { type: "geojson", data: "coffee-shops.geojson", attribution });
      map.addLayer(
        {
          id: "heatmap",
          type: "heatmap",
          source: "coffee-heat",
          layout: { visibility: "none" },
          paint: {
            "heatmap-radius": 30,
            "heatmap-intensity": 0.3,
            "heatmap-color": [
              "interpolate",
              ["linear"],
              ["heatmap-density"],
              0, "rgba(255, 218, 106, 0)",
              0.3, "#ffda6a",
              0.7, "#fd7e14",
              1, "#ca6510",
            ],
          },
        },
        labels,
      );

      const toggle = document.querySelector("#toggle");
      toggle.addEventListener("click", () => {
        const heatmap = map.getLayoutProperty("heatmap", "visibility") === "none";
        map.setLayoutProperty("heatmap", "visibility", heatmap ? "visible" : "none");
        for (const layer of ["clusters", "cluster-counts", "places"]) {
          map.setLayoutProperty(layer, "visibility", heatmap ? "none" : "visible");
        }
        toggle.textContent = heatmap ? "Show clusters" : "Show a heatmap";
      });

      document.querySelector("#add-here").addEventListener("click", async () => {
        const { lat, lng } = map.getCenter().wrap();
        const response = await fetch(
          `https://api.mapricot.com/v1/nearby?lat=${lat.toFixed(6)}&lon=${lng.toFixed(6)}&category=food:coffee_shop&limit=20`,
          { headers: { Authorization: `Bearer ${key}` } },
        );
        if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
        const { results } = await response.json();

        const data = await map.getSource("coffee").getData();
        const known = new Set(data.features.map((feature) => feature.properties.id));
        for (const place of results) {
          if (known.has(place.id)) continue;
          data.features.push({
            type: "Feature",
            properties: { id: place.id, name: place.name },
            geometry: { type: "Point", coordinates: [place.lon, place.lat] },
          });
        }
        map.getSource("coffee").setData(data);
        map.getSource("coffee-heat").setData(data);
      });
    </script>
  </body>
</html>

Questions

How many points can MapLibre cluster? Tens of thousands in the browser without trouble. MapLibre clusters in a web worker with the Supercluster library, so the page stays responsive while it works. Past a few hundred thousand points, serve them as vector tiles instead of one GeoJSON file.

Why don't my clusters split when I zoom in? Points at exactly the same spot stay together until clusterMaxZoom, then show stacked on top of each other. Lower clusterMaxZoom, or open a list with getClusterLeaves for clusters past it.

Why is the map empty? Open the browser's console. A failed request for coffee-shops.geojson means the file isn't next to index.html, or the page was opened as a file instead of served. Mapricot returned 403 from the button means the browser key isn't allowed from the page's origin, or lacks the Places product.

Can a cluster show a sum instead of a count? Yes. clusterProperties adds up a property across a cluster's points, such as clusterProperties: { seats: ["+", ["get", "seats"]] }, and the layers can read seats like point_count.

Does showing the map cost requests? No. Map tiles are free and don't count, and the points come from the file. Only the script's ten requests, and each "Add coffee shops here", count against your free allowance.