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.
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 [email protected]
and [email protected]
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)