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 special setup! For mobile, server, and non-local web development, sign up for a free account to configure domain auth or an API key. Our authentication guide has all the gritty details.

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