[GIS] Custom icon for clusters in Mapbox GL Js

javascriptmapbox-glmapbox-gl-js

Is it possible to use a custom icon instead of a circle and a symbol (with the cluster_count string) for Mapbox GL Js' "Superclusters?"

My goal here is to approximate something like Pie Clusters or MiniCharts that used to be possible in Leaflet. I need some way to visually represent what's inside a cluster.

Best Answer

I wound up getting a satisfactory result, but it took some doing. I expanded on this very helpful Supercluster property aggregation example with custom icons.

Mapbox GL pie clusters example

The main thing is to use the external Supercluster library to do all the clustering, and not use mapbox-gl's built-in clustering at all. Trying to share the work between internal and external Supercluster just complicates things. Future versions of mapbox-gl will expose this functionality in their API, but we can just use the external Supercluster for now. I'm using mapbox-gl@0.41.0 and supercluster@2.3.0 here.

First, create the cluster object with the initial/map/reduce methods for property aggregation:

// I did this in a Vue component so forgive all the "this" bits
this.cluster = supercluster({
    radius: this.clusterRadius,
    maxZoom: this.clusterMaxZoom, // 14 here
    initial() {
        return {
            // example: household pets
            cats: 0,
            dogs: 0,
            fish: 0,
        };
    },
    map(properties) {
        return {
            cats: properties.pet === 'cat' ? 1 : 0,
            dogs: properties.pet === 'dog' ? 1 : 0,
            fish: properties.pet === 'fish' ? 1 : 0,
        };
    },
    reduce(accumulated, properties) {
        accumulated.cats += properties.cats;
        accumulated.dogs += properties.dogs;
        accumulated.fish += properties.fish;
    },
});

// load the initial data
this.cluster.load(pointsData);

Then, to get the cluster data as a geojson feature collection, I used the featureCollection function in @turf/helpers:

/**
 * update the cluster layer data with the
 * current clusters for this zoom level
 */
updateClusterData() {
    this.clusterData = featureCollection(
        this.cluster.getClusters(this.worldBounds, this.intZoom),
    );
},

Note that worldBounds is the bounding box inwhich to calculate your clusters. I used the whole world because I don't have a lot of clusters. If you use your viewport bounding box, you'll need to recalculate clusters every time the map moves. intZoom is the current integer zoom level (Math.floor(this.mapZoom)).

On the actual map, add the clusterData geojson featureCollection as a source.

this.map.addSource('my-locations', {
    type: 'geojson',
    data: this.clusterData,
    buffer: 1, // I honestly don't know what this does
    maxzoom: this.clusterMaxZoom,
});

I also added a regular mapbox-gl layer for the unclustered points as per the regular cluster example. (filter: ['!has', 'point_count'],). The clusters themselves will be all custom icons, though.

Mapbox GL doesn't do "layers" when it comes to custom icons (not in the way Leaflet does), so just use an array to keep track of all of your icon objects. (this.customClusters = [])

Each time you change your clusterData, you will want to update and re-render the clusters:

/**
 * re-render the custom clusters layer
 */
updateCustomClusters() {
    // clear existing custom clusters "layer"
    this.customClusters.forEach(markerObj => markerObj.remove());
    this.customClusters = [];

    // create a custom icon (HTML element) for each cluster
    this.clusterData.features.forEach((c) => {
        // skip non-cluster points
        if (!c.properties.cluster) {
            return;
        }

        const coords = c.geometry.coordinates;
        const clusterId = c.properties.cluster_id;

        // create a DOM element for the marker
        const el = document.createElement('div');
        el.classList.add('cluster-marker');

        // add marker to map
        const markerObj = new mapboxgl.Marker(el)
        .setLngLat(coords)
        .addTo(this.map);


        // [...]
        // All of the cluster properties are available here in
        // c.properties, including the property aggregation 
        // (dogs: 5, cats: 7...)
        // Here is where you would render a pie chart with d3
        // or whatever you want. I mounted a Vue component onto the 
        // div.custom-marker element. 
        // Add any mouse event handlers here, etc.
        // [...]


        // keep track of the clusters in this "layer"
        // so they can be removed on zoom change
        this.customClusters.push(markerObj);
    });
},

As I mentioned above, using custom icons means we can't use mapbox-gl's filter system, so we'll have to manually filter the array and update the clusters.

The last step is to re-calculate and re-render the clusters when the map or the data changes.

Re-calculate clusters when the zoom level changes by a whole value (this.intZoom)

this.updateClusterData();
// update the source for the unclustered points layer
this.map.getSource('my-locations').setData(this.clusterData);

Re-calculate clusters when the geojson data changes (i.e. from filtering)

this.cluster.load(filteredPointsData);
this.updateClusterData();
// update the source for the unclustered points layer
this.map.getSource('my-locations').setData(this.clusterData);

Re-render clusters when the cluster layer data changes (this.clusterData)

this.updateCustomClusters();

Math.floor(this.mapZoom)

Related Question