Skip to content

Clustering and styling points with MapLibre GL JS

Free

Starter

Standard

Professional

When you are adding a plethora points to your maps, they can quickly become cluttered and difficult to read, navigate, and interact with. That's why MapLibre GL JS provides you with the ability to cluster and stylize your points.

In this tutorial, you'll learn how to add a map to your webpage, add points from a GeoJSON data source, along with stylizing and clustering the points.

Drawing a map with point clusters

Let's dive in with some example code to accomplish this. The code below is based on MapLibre's create and style clusters example, adding an explanation of the code and adapting it for Stadia Maps.

For local development on a web server at localhost or 127.0.0.1, you can get started without any API keys or domain setup!

For mobile, backend, and non-local web development, you'll need either domain auth or an API key. If you don't already have a Stadia Maps account, sign up for a free to get started.

Domain-based authentication

Domain-based authentication is the easiest form of authentication for production web apps. No additional application code is required, and you don't need to worry about anyone scraping your API keys. We recommend this for most browser-based applications.

  1. Sign in to the client dashboard.
  2. Click "Manage Properties."
  3. Under "Authentication Configuration," click the button to add your domain.
API key authentication

Authenticating requests via API key

You can authenticate your requests by adding an API key to the query string or HTTP header.

The simplest is to add a query string parameter api_key=YOUR-API-KEY to your request URL. For example, to access the /tz/lookup API endpoint, your request URL might look like this.

Example URL with an API key placeholder
https://api.stadiamaps.com/tz/lookup/v1?lat=59.43696&lng=24.75357&api_key=YOUR-API-KEY

You can also use an Authorization HTTP header instead of a query string as shown below. Don't forget the Stadia-Auth prefix!

Example Authorization header with an API key placeholder
Authorization: Stadia-Auth YOUR-API-KEY

How to get an API key

Don't have an API key yet? Follow these easy steps!

  1. Sign in to the client dashboard. (If you don't have an account yet, sign up for free; no credit card required!)
  2. Click "Manage Properties."
  3. If you have more than one property (ex: for several websites or apps), make sure you have selected the correct property from the dropdown at the top of the page.
  4. Under "Authentication Configuration," you can generate, view or revoke your API key.

Video: How to generate your API key

To make experimenting even easier, we've packaged the example code as a JSFiddle playground. Click the "Try it in JSFiddle" button to start experimenting right from your web browser.

Try it in JSFiddle
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Create and style clusters with MapLibre GL JS and Stadia Maps</title>
    <meta property="og:description" content="Use MapLibre GL JS' built-in functions to visualize points as clusters." />
    <meta charset='utf-8'>
    <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
    <link rel='stylesheet' href='https://unpkg.com/maplibre-gl@4.0.2/dist/maplibre-gl.css' />
    <script src='https://unpkg.com/maplibre-gl@4.0.2/dist/maplibre-gl.js'>
    </script>
    <style type="text/css">
        body {
            margin: 0;
            padding: 0;
        }

        #map {
            position: absolute;
            top: 0;
            bottom: 0;
            width: 100%;
        }
    </style>
</head>
<body>
    <div id="map"></div>

    <script type="text/javascript">
        const map = new maplibregl.Map({
            container: 'map',
            style: 'https://tiles.stadiamaps.com/styles/alidade_smooth.json',
            center: [-103, 40],
            zoom: 3
        });

    map.on('load', () => {
        // Add a new source from the MapLibre GL JS GeoJSON data and
        // set the 'cluster' option to true. GL-JS will
        // add the point_count property to your source data.
        map.addSource('earthquakes', {
            type: 'geojson',
            // Point to GeoJSON data. This example visualizes all M1.0+ earthquakes
            // from 12/22/15 to 1/21/16 as logged by USGS' Earthquake hazards program.
            data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson',
            cluster: true,
            clusterMaxZoom: 14, // Max zoom to cluster points on
            clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
        });

         // MapLibre GL JS does not handle RTL text by default,
         // so we recommend adding this dependency to fully support RTL rendering if your style includes RTL text
         maplibregl.setRTLTextPlugin('https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js');

        map.addLayer({
            id: 'clusters',
            type: 'circle',
            source: 'earthquakes',
            filter: ['has', 'point_count'],
            paint: {
                // Use step expressions (https://maplibre.org/maplibre-style-spec/expressions/#step)
                // with three steps to implement three types of circles:
                //   * Blue, 20px circles when point count is less than 100
                //   * Yellow, 30px circles when point count is between 100 and 750
                //   * Pink, 40px circles when point count is greater than or equal to 750
                'circle-color': [
                    'step',
                    ['get', 'point_count'],
                    '#32abcd',
                    100,
                    '#84ca35',
                    750,
                    '#ca3584'
                ],
                'circle-radius': [
                    'step',
                    ['get', 'point_count'],
                    20,
                    100,
                    30,
                    750,
                    40
                ]
            }
        });

        map.addLayer({
            id: 'cluster-count',
            type: 'symbol',
            source: 'earthquakes',
            filter: ['has', 'point_count'],
            layout: {
            'text-field': '{point_count_abbreviated}',
            'text-font': ['Stadia Regular'],
            'text-size': 12
            }
        });

        map.addLayer({
            id: 'unclustered-point',
            type: 'circle',
            source: 'earthquakes',
            filter: ['!', ['has', 'point_count']],
            paint: {
                'circle-color': '#11b4da',
                'circle-radius': 4,
                'circle-stroke-width': 1,
                'circle-stroke-color': '#fff'
            }
        });

        // inspect a cluster on click
        map.on('click', 'clusters', async (e) => {
            const features = map.queryRenderedFeatures(e.point, {
                layers: ['clusters']
            });
            const clusterId = features[0].properties.cluster_id;
            const zoom = await map.getSource('earthquakes').getClusterExpansionZoom(clusterId);
            map.easeTo({
                center: features[0].geometry.coordinates,
                zoom
            });
        });

        // When a click event occurs on a feature in
        // the unclustered-point layer, open a popup at
        // the location of the feature, with
        // description HTML from its properties.
        map.on('click', 'unclustered-point', (e) => {
            const coordinates = e.features[0].geometry.coordinates.slice();
            const mag = e.features[0].properties.mag;
            let tsunami;

            if (e.features[0].properties.tsunami === 1) {
                tsunami = 'yes';
            } else {
                tsunami = 'no';
            }

            // Ensure that if the map is zoomed out such that if
            // multiple copies of the feature are visible, the
            // popup appears over the copy being pointed to.
            while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
                coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
            }

            new maplibregl.Popup()
                .setLngLat(coordinates)
                .setHTML(
                    `magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`
                )
                .addTo(map);
        });

        map.on('mouseenter', 'clusters', () => {
            map.getCanvas().style.cursor = 'pointer';
        });
        map.on('mouseleave', 'clusters', () => {
            map.getCanvas().style.cursor = '';
        });

        map.on('mouseenter', 'unclustered-point', () => {
            map.getCanvas().style.cursor = 'pointer';
        });
        map.on('mouseleave', 'unclustered-point', () => {
            map.getCanvas().style.cursor = '';
        });

        // Add zoom and rotation controls to the map.
        map.addControl(new maplibregl.NavigationControl());
    });
</script>
</body>
</html>

Code Walkthrough

First, we'll add some opening HTML content that will make the document standard compliant:

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Create and style clusters with MapLibre GL JS and Stadia Maps</title>
    <meta property="og:description" content="Use MapLibre GL JS' built-in functions to visualize points as clusters." />
    <meta charset='utf-8'>
    <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />

Next, we'll include the MapLibre library files:

    <link rel='stylesheet' href='https://unpkg.com/maplibre-gl@4.0.2/dist/maplibre-gl.css' />
    <script src='https://unpkg.com/maplibre-gl@4.0.2/dist/maplibre-gl.js'>
    </script>

Now, we'll add CSS to define the body, the map ID, and then close the <head> tag:

        <style type="text/css">
            body {
                margin: 0;
                padding: 0;
            }

            #map {
                position: absolute;
                top: 0;
                bottom: 0;
                width: 100%;
            }
        </style>
    </head>

With the <head> details in, we can now add the body for our page. This is where we'll create the map element:

    <body>
        <div id="map"></div>

Next, we'll add the JavaScript for:

  • initializing the map, specifying the container, style, center, and zoom:
        <script type="text/javascript">
            const map = new maplibregl.Map({
                container: 'map',
                style: 'https://tiles.stadiamaps.com/styles/alidade_smooth.json',
                center: [-103, 40],
                zoom: 3
            });
  • setting up a GeoJSON data source we will use to populate our map points and set the cluster, clusterMaxZoom and clusterRadius:
            map.on('load', () => {
                // Add a new source from the MapLibre GL JS GeoJSON data and
                // set the 'cluster' option to true. GL-JS will
                // add the point_count property to your source data.
                map.addSource('earthquakes', {
                    type: 'geojson',
                    // Point to GeoJSON data. This example visualizes all M1.0+ earthquakes
                    // from 12/22/15 to 1/21/16 as logged by USGS' Earthquake hazards program.
                    data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson',
                    cluster: true,
                    clusterMaxZoom: 14, // Max zoom to cluster points on
                    clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
                });
  • configuring RTL (right-to-left) support:
         // MapLibre GL JS does not handle RTL text by default,
         // so we recommend adding this dependency to fully support RTL rendering if your style includes RTL text
         maplibregl.setRTLTextPlugin('https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js');
  • adding a layer for the cluster circle points, steps for grouping, and colors:
        map.addLayer({
            id: 'clusters',
            type: 'circle',
            source: 'earthquakes',
            filter: ['has', 'point_count'],
            paint: {
                // Use step expressions (https://maplibre.org/maplibre-style-spec/expressions/#step)
                // with three steps to implement three types of circles:
                //   * Blue, 20px circles when point count is less than 100
                //   * Green, 30px circles when point count is between 100 and 750
                //   * Pink, 40px circles when point count is greater than or equal to 750
                'circle-color': [
                    'step',
                    ['get', 'point_count'],
                    '#32abcd',
                    100,
                    '#84ca35',
                    750,
                    '#ca3584'
                ],
                'circle-radius': [
                    'step',
                    ['get', 'point_count'],
                    20,
                    100,
                    30,
                    750,
                    40
                ]
            }
        });
  • adding another layer for the cluster-count pop-up details:

Fonts

Note that you need to use one of our fonts! You can find the full list here. If you specify a font not in our list, the map will not load at all and you'll get a confusing JavaScript error.

        map.addLayer({
            id: 'cluster-count',
            type: 'symbol',
            source: 'earthquakes',
            filter: ['has', 'point_count'],
            layout: {
            'text-field': '{point_count_abbreviated}',
            'text-font': ['Stadia Regular'],
            'text-size': 12
            }
        });
  • adding another layer for the unclustered-point objects and stylings:
        map.addLayer({
            id: 'unclustered-point',
            type: 'circle',
            source: 'earthquakes',
            filter: ['!', ['has', 'point_count']],
            paint: {
                'circle-color': '#11b4da',
                'circle-radius': 4,
                'circle-stroke-width': 1,
                'circle-stroke-color': '#fff'
            }
        });
  • configuring a click handler the clusters circle:
        // inspect a cluster on click
        map.on('click', 'clusters', async (e) => {
            const features = map.queryRenderedFeatures(e.point, {
                layers: ['clusters']
            });
            const clusterId = features[0].properties.cluster_id;
            const zoom = await map.getSource('earthquakes').getClusterExpansionZoom(clusterId);
            map.easeTo({
                center: features[0].geometry.coordinates,
                zoom
            });
        });
  • configuring another click handler for individual points (unclustered-point) on the map:
        // When a click event occurs on a feature in
        // the unclustered-point layer, open a popup at
        // the location of the feature, with
        // description HTML from its properties.
        map.on('click', 'unclustered-point', (e) => {
            const coordinates = e.features[0].geometry.coordinates.slice();
            const mag = e.features[0].properties.mag;
            let tsunami;

            if (e.features[0].properties.tsunami === 1) {
                tsunami = 'yes';
            } else {
                tsunami = 'no';
            }

            // Ensure that if the map is zoomed out such that
            // multiple copies of the feature are visible, the
            // popup appears over the copy being pointed to.
            while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
                coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
            }

            new maplibregl.Popup()
                .setLngLat(coordinates)
                .setHTML(
                    `magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`
                )
                .addTo(map);
        });
  • adding a dynamic cursor based on if you are inside a clusters object or not:
        map.on('mouseenter', 'clusters', () => {
            map.getCanvas().style.cursor = 'pointer';
        });
        map.on('mouseleave', 'clusters', () => {
            map.getCanvas().style.cursor = '';
        });
  • adding a dynamic cursor based on if you are touching an unclustered-point object or not:
        map.on('mouseenter', 'unclustered-point', () => {
            map.getCanvas().style.cursor = 'pointer';
        });
        map.on('mouseleave', 'unclustered-point', () => {
            map.getCanvas().style.cursor = '';
        });
  • and setting up navigation controls:
        // Add zoom and rotation controls to the map.
        map.addControl(new maplibregl.NavigationControl());
    });
    </script>

Finally, we'll close up the <body> and <html> tags:

    </body>
</html>

And that's it. Congratulations; you can now visualize data with point clustering!

All the code, put together, will match the code at the start of this section.

Next Steps

This tutorial gives you a solid foundation in how to use MapLibre GL JS to add GeoJSON point data to a map, interactive click events, along with stylizing the points and clusters. You can get additional inspiration from what others have built at the MapLibre GL JS Examples page. They have in-depth examples covering everything from GeoJSON lines to interactive time sliders. And don't forget to check out the plugins and the rest of the MapLibre GL JS documentation either.

Once you're ready to move beyond localhost testing, sign up for a free Stadia Maps account, and we'll walk through the next steps.

Get Started With a Free Account