Supercluster with @react-native-mapbox-gl/maps

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.