Leaflet Marker Clustering
Performance can begin to degrade pretty quickly when you are trying to show large amounts of data on a map. Even at hundreds of markers using Leaflet via React Leaflet, you may feel it start to lag. By clustering the points together you can improve performance greatly, all while presenting the data in a more approachable way.
Supercluster is the go-to package for clustering points together on a map. For using supercluster together with React I created a useSupercluster hook to make things easier. This article shows how to integrate clustering with supercluster into your React with Leaflet app.
Full source code of this project can be found here.
Leaflet setup in React
To start with Leaflet we will import a few components from the react-leaflet
package. I have also included the remaining imports we will need for this demo along with comments explaining what they will be used for.
Within the return statement we have the Map
component. We're required to pass center
and zoom
props in order for it to work, but it's very important to note that without the TileLayer
component inside of it, the map will render blank. This adds correct attribution which points back to OpenStreetMap.
import React, { useRef, useState } from "react";// Used for the map itselfimport { Map, Marker, TileLayer } from "react-leaflet";// Used when making custom Marker iconsimport L from "leaflet";// Used to fetch remote dataimport useSwr from "swr";// Used to cluster pointsimport useSupercluster from "use-supercluster";// Stylingimport "./App.css";export default function App() {// state and refs// load and prepare data// get map bounds// get clusters// return mapreturn (<Map center={[52.6376, -1.135171]} zoom={13}><TileLayerurl="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'/>{/* markers here*/}</Map>);}
For the map to look and work correctly, we'll need to set some styles:
.leaflet-container {width: 100%;height: 100vh;}
Preparing data for supercluster
Data from an external/remote source will most likely need to be massaged into the format required by the supercluster library. The example below uses SWR to fetch remote data using hooks.
We must produce an array of GeoJSON Feature objects, with the geometry of each object being a GeoJSON Point.
An example of the data looks like:
[{"type": "Feature","properties": {"cluster": false,"crimeId": 78212911,"category": "anti-social-behaviour"},"geometry": { "type": "Point", "coordinates": [-1.135171, 52.6376] }}]
Fetching the data using SWR and converting it into the proper format looks like:
const fetcher = (...args) => fetch(...args).then(response => response.json());export default function App() {// state and refs// load and prepare dataconst url ="https://data.police.uk/api/crimes-street/all-crime?lat=52.629729&lng=-1.131592&date=2019-10";const { data, error } = useSwr(url, { fetcher });const crimes = data && !error ? data : [];const points = crimes.map(crime => ({type: "Feature",properties: { cluster: false, crimeId: crime.id, category: crime.category },geometry: {type: "Point",coordinates: [parseFloat(crime.location.longitude),parseFloat(crime.location.latitude)]}}));// get map bounds// get clusters// return map}
Getting map bounds
For supercluster to return the clusters based on the array of points we created in the previous section, we need to provide it with two additional pieces of information:
- The map bounds:
[westLng, southLat, eastLng, northLat]
- The map zoom: Integer representing the level of zoom our map is at
These values will come from the the bounds
and zoom
state properties which don't yet have values assigned to them. In order to get their value, we will start by attaching a mapRef
ref value to the map.
With the ref in place, we can create a function called updateMap
which can be called whenever the map has been updated, allowing us to grab new bounds and zoom properties from the mapRef
. This will be called once upon initial render via the useEffect
hook, and will also be called via the onMoveEnd
prop. This handles both initial render and subsequent changes to the map made by the user dragging it around or zooming in or out.
export default function App() {// state and refsconst [bounds, setBounds] = useState(null);const [zoom, setZoom] = useState(13);const mapRef = useRef();// load and prepare data// get map boundsfunction updateMap() {const b = mapRef.current.leafletElement.getBounds();setBounds([b.getSouthWest().lng,b.getSouthWest().lat,b.getNorthEast().lng,b.getNorthEast().lat]);setZoom(mapRef.current.leafletElement.getZoom());}React.useEffect(() => {updateMap();}, []);// get clusters// return mapreturn (<Mapcenter={[52.6376, -1.135171]}zoom={13}onMoveEnd={updateMap}ref={mapRef}>{/* markers here */}</Map>);}
Fetching clusters from hook
With our points
in the correct order, and with the bounds
and zoom
accessible, it's time to retrieve the clusters. This will use the useSupercluster
hook provided by the use-supercluster package.
It provides you through a destructured object an array of clusters and, if you need it, the supercluster
instance variable.
export default function App() {// state and refs// load and prepare data// get map bounds// get clustersconst { clusters, supercluster } = useSupercluster({points,bounds,zoom,options: { radius: 75, maxZoom: 20 }});// return map}
Clusters are an array of GeoJSON Feature objects, but some of them represent a cluster of points, and some represent individual points that you created above. A lot of it depends on your zoom level and how many points would be within a specific radius. When the number of points gets small enough, supercluster gives us individual points rather than clusters. The example below has a cluster (as denoted by the properties on it) and an individual point (which in our case represents a crime).
[{"type": "Feature","id": 1461,"properties": {"cluster": true,"cluster_id": 1461,"point_count": 857,"point_count_abbreviated": 857},"geometry": {"type": "Point","coordinates": [-1.132138301050194, 52.63486758501364]}},{"type": "Feature","properties": {"cluster": false,"crimeId": 78212911,"category": "anti-social-behaviour"},"geometry": { "type": "Point", "coordinates": [-1.135171, 52.6376] }}]
Displaying clusters as markers
Because the clusters
array contains features which represent either a cluster or an individual point, we have to handle that while mapping them. Either way, they both have coordinates, and we can use the cluster
property to determine which is which.
Styling the clusters is up to you, I have some simple styles applied to each of the markers:
.leaflet-div-icon {background: none !important;border: none !important;}.cluster-marker {color: #fff;background: #1978c8;border-radius: 50%;padding: 10px;width: 10px;height: 10px;display: flex;justify-content: center;align-items: center;}
Then as I am mapping the clusters, I change the size of the cluster with a calculation based on how many points the cluster contains: ${10 + (pointCount / points.length) * 20}px
.
Icons can be customized in Leaflet in a number of ways, but we will examine two of them. First of all, if you want to display an image as the marker cluster, it can be done using the L.Icon
class which comes directly from Leaflet. We will use this when rendering individual points (non-clustered).
For clustered markers, we want to change the size of the marker and place the number of points contained within it inside of the marker. Because this can't be done with an image, we can use the L.divIcon
function to create HTML based markers. I created a fetchIcon
function which given a count
(number of points within cluster) and the size
(how many pixels width and height we want the div to be) we can create new icons on the fly. It caches them inside of the icons
object to avoid re-creating the same icon over and over again.
const cuffs = new L.Icon({iconUrl: "/handcuffs.svg",iconSize: [25, 25]});const icons = {};const fetchIcon = (count, size) => {if (!icons[count]) {icons[count] = L.divIcon({html: `<div class="cluster-marker" style="width: ${size}px; height: ${size}px;">${count}</div>`});}return icons[count];};export default function App() {// state and refs// load and prepare data// get map bounds// get clusters// return mapreturn (<Map>{clusters.map(cluster => {// every cluster point has coordinatesconst [longitude, latitude] = cluster.geometry.coordinates;// the point may be either a cluster or a crime pointconst {cluster: isCluster,point_count: pointCount} = cluster.properties;// we have a cluster to renderif (isCluster) {return (<Markerkey={`cluster-${cluster.id}`}position={[latitude, longitude]}icon={fetchIcon(pointCount,10 + (pointCount / points.length) * 40)}/>);}// we have a single point (crime) to renderreturn (<Markerkey={`crime-${cluster.properties.crimeId}`}position={[latitude, longitude]}icon={cuffs}/>);})}</Map>);}
Animated zoom transition into a cluster
We can always zoom into the map ourselves as the user, but supercluster provides a function called getClusterExpansionZoom
, which when passed a cluster ID, it will return us which zoom level we need to change the map to in order for the cluster to be broken down into additional smaller clusters or individual points.
() => {const expansionZoom = Math.min(supercluster.getClusterExpansionZoom(cluster.id),17);const leaflet = mapRef.current.leafletElement;leaflet.setView([latitude, longitude], expansionZoom, {animate: true});};
But where does the above function live? It can be passed to the onClick
prop of the div
which represents a cluster.
Conclusion
Using react-leaflet
, we have the ability to use Leaflet within our React app. Using use-supercluster
we are able to use supercluster as a hook to render clusters of points onto our map.
Because we have access to the instance of supercluster, we're even able to grab the "leaves" (the individual points which make up a cluster) via the supercluster.getLeaves(cluster.id)
function. With this we can show details about the x number of points contained within a cluster.