During a recent project in my work at Airship I had to stop using the built in cluster functionality that @react-native-mapbox-gl/maps
provides and utilize Supercluster instead. The reason is we need access to the points that make up the clusters. We had some items that never broke out of their clusters because they had the same exact longitude & latitude combination. As well as wanting to show a slide up view of those locations in a list view. What started me down this path was an issue on the deprecated react-native-mapbox-gl
library which shares a lot of functionality with the new library. You can view that issue here. I’m honestly surprised that this functionality isn’t available in the library since it is supported in the Mapbox JS SDK as documented here with the getClusterLeaves()
function. I noticed people asking how to do this so when I nailed it down I knew a how-to was coming.
Without Supercluster
I setup a code example here to show a contrived starting state utilizing the built in clustering functionality. This is essentially where I started with the core Mapbox functionality. Let’s walk through this code some.
render() {
return (
<View style={{ flex: 1 }}>
<MapboxGL.MapView
ref={c => (this._map = c)}
zoomEnabled
style={[{ flex: 1 }]}
>
{this.renderPoints()}
</MapboxGL.MapView>
</View>
);
}
Here’s our MapboxGL container to setup our map. We then need to render all our points on the map and we handle that in a helper function renderPoints()
.
renderPoints = () => {
const { points } = this.state;
return (
<MapboxGL.ShapeSource
id="symbolLocationSource"
hitbox={{ width: 18, height: 18 }}
onPress={this.onMarkerSelected}
shape={points}
cluster
>
<MapboxGL.SymbolLayer
minZoomLevel={6}
id="pointCount"
style={mapStyles.clusterCount}
/>
<MapboxGL.CircleLayer
id="clusteredPoints"
minZoomLevel={6}
belowLayerID="pointCount"
filter={["has", "point_count"]}
style={mapStyles.clusteredPoints}
/>
<MapboxGL.SymbolLayer
id="symbolLocationSymbols"
minZoomLevel={6}
filter={["!has", "point_count"]}
style={mapStyles.icon}
/>
</MapboxGL.ShapeSource>
);
};
So here we’re getting the list of groups from state and passing them into MapboxGL.ShapeSource
and toggling the cluster
functionality on. We have three layers inside there which I’ll refer to by ID values.
– pointCount
is the actual numerical value of the number of items that make up the cluster.
– clusteredPoints
is the cluster circle which we see is set to be below the pointCount
layer.
– symbolLocationSymbols
is the map marker for a single location on the map that isn’t being clustered.
When we click a marker, whether it’s a cluster or single point on the map we call onMarkerSelected
which currently only has functionality implemented for non-clusters like so:
onMarkerSelected = event => {
const point = event.nativeEvent.payload;
const { name, cluster } = point.properties;
const coordinates = point.geometry.coordinates;
if (cluster) {
console.log(cluster);
} else {
this.setState(
{
selectedPointName: name,
selectedPointLat: coordinates[1],
selectedPointLng: coordinates[0],
},
() => {
this.map.flyTo(point.geometry.coordinates, 500);
}
);
}
};
The if/else
statement is just logging the cluster if there is one otherwise it’s setting the state to the selected point and centering the map on that point. The idea of adding the point info to state is to do something with that info.
We decide if we’re going to render the circle or marker based on the filter
criteria being passed into the CircleLayer
and SymbolLayer
.
const mapStyles = MapboxGL.StyleSheet.create({
icon: {
iconAllowOverlap: true,
iconSize: 0.35
},
clusteredPoints: {
circleColor: "#004466",
circleRadius: [
"interpolate",
["exponential", 1.5],
["get", "point_count"],
15,
15,
20,
30
],
circleOpacity: 0.84
},
clusterCount: {
textField: "{point_count}",
textSize: 12,
textColor: "#ffffff"
}
});
This last piece provides some of the styling and actually won’t change when we swap out the clustering functionality.
Implementing Supercluster
So the idea of switching to Supercluster to to replace the built in clustering of the raw FeatureCollection
data. Supercluster is not going to do anything with the actual rendering of that data. We need to initialize a cluster using Supercluster then update that cluster based on the bounds of the map and zoom level. I’m going to walk through the guts of this conversion.
Firstly, you’ll need the cluster which I decided to store in state. I think this makes the most sense and works well for me.
const collection = MapboxGL.geoUtils.makeFeatureCollection(groupFeatures);
const cluster = new Supercluster({ radius: 40, maxZoom: 16 });
cluster.load(collection.features);
this.setState({
point: collection,
loading: false,
selectedPoints: [],
superCluster: cluster,
userFound: false
});
So now I have the FeatureCollection
as state.groups
and the Supercluster cluster as state.superCluster
. However, we will not be able to pass this superCluster
into our MapboxGL.ShapeSource
just yet. This cluster is immutable and essentially what we will now use to create the shape object we will pass into ShapeSource
. Next let’s update our MapView
like so:
<MapboxGL.MapView
ref={c => (this._map = c)}
onRegionDidChange={this.updateClusters}
zoomEnabled
style={[{ flex: 1 }]}
>
{this.renderPoints()}
</MapboxGL.MapView>
Notice the added onRegionDidChange
which takes a callback function. This prop I found works the best for me, however, as the react-native-mapbox-gl/maps
library continues to evolve there may be a better solution. This calls updateClusters
after the map has been moved. Now lets take a look at the updateClusters
function:
updateClusters = async () => {
const sc = this.state.superCluster;
if (sc) {
const bounds = await this._map.getVisibleBounds();
const westLng = bounds[1][0];
const southLat = bounds[1][1];
const eastLng = bounds[0][0];
const northLat = bounds[0][1];
const zoom = Math.round(await this._map.getZoom());
this.setState({
superClusterClusters: sc.getClusters(
[westLng, southLat, eastLng, northLat],
zoom
)
});
}
};
So, I take the cluster that was created in my componentDidMount()
and set that to a local variable. Just in case I check to ensure it exists before doing anything else. Next, I get the visible bounds from the _map
ref that is setup on the MapView
. I extract the four bounds into their own variables mostly for ease of knowing what they are. I then get the zoom and round it to a whole number (I found decimal zooms gave Supercluster issues). Finally, I take all that information and create the appropriate clusters for those bounds and zoom level and save them in state to superClusterClusters
.
This superClusterClusters
is what gets fed into the ShapeSource
in renderPoints
like so:
renderPoints = () => {
const { superClusterClusters } = this.state;
return (
<MapboxGL.ShapeSource
id="symbolLocationSource"
hitbox={{ width: 18, height: 18 }}
onPress={this.onMarkerSelected}
shape={{ type: "FeatureCollection", features: superClusterClusters }}
>
<MapboxGL.SymbolLayer
id="pointCount"
minZoomLevel={6}
style={mapStyles.clusterCount}
/>
<MapboxGL.CircleLayer
id="clusteredPoints"
minZoomLevel={6}
belowLayerID="pointCount"
filter={[">", "point_count", 1]}
style={mapStyles.clusteredPoints}
/>
<MapboxGL.SymbolLayer
id="symbolLocationSymbols"
minZoomLevel={6}
filter={["!", ["has", "point_count"]]}
style={mapStyles.icon}
/>
</MapboxGL.ShapeSource>
);
};
Notice that the shape
prop requires the creation of an object and I’m not passing in the superClusterClusters
directly from the state. Also notice that the cluster
prop is no longer included on ShapeSource
. This is something I forgot about and caused me a lot of grief. The built in clustering was conflicting with my clustering.
Lastly we add in functionality for getting the info about each point out of the cluster when we touch it on the phone in our onMarkerSelected()
like so:
onMarkerSelected = event => {
const point = event.nativeEvent.payload;
const { name, cluster } = point.properties;
const coordinates = point.geometry.coordinates;
if (cluster) {
const sc = this.state.superCluster;
if (sc) {
const points = sc
.getLeaves(point.properties.cluster_id, Infinity)
.map(leaf => ({
selectedPointName: leaf.properties.name,
selectedPointLat: leaf.geometry.coordinates[1],
selectedPointLng: leaf.geometry.coordinates[0],
}));
this.setState({ selectedPoints: points });
console.log(points);
} else {
this.setState(
{
selectedPoints: [
{
selectedPointName: name,
selectedPointLat: coordinates[1],
selectedPointLng: coordinates[0],
},
],
},
() => {
this.camera.flyTo(point.geometry.coordinates, 500);
}
);
}
}
};
By using Superclusters getLeaves()
we map those to a new array and set our selectedPoints
state to it. We can now use this new data in state to render something in the UI.
Final Thoughts
While there might seem like there are many steps involved in adding Supercluster to a react-native-mapbox-gl/maps
map to access the underlying points of a cluster most of the code that I have shared can be reused as is to ease the transition.
For reference, the exact versions I’m using in this example are:
react-native-mapbox-gl/maps
: 7.0.1
supercluster
: 6.0.2
A final code sample is available here.
NOTE: The code samples WILL NOT WORK. Although, if you’re reading this article I’m making the assumption you already have Mapbox implemented. With that I also assume you have the library initialized with your access token.
I hope this saves people some headaches along the way and you build amazing things having access to the underlying data points that make up your clusters.
The post Supercluster with @react-native-mapbox-gl/maps appeared first on Seth Alexander.
Top comments (6)
Great article! I wish I had access to this when I was working on this project that uses Supercluster!! I used Vue for that, but most of the ideas are the same.
amazing thanks
You're welcome :-)
BIG THANKS!!!
You're welcome :-)
How can I implement 2 clusters in my react-native app using these techniques?