How to Do Map Clustering with React Native

upsilon_it

Upsilon

Posted on August 19, 2021

How to Do Map Clustering with React Native

In this article, we will describe how we used clustering to display a large number of markers on a map and provide you with the React Native code needed for its implementation.


Most developers working with React Native or any other programming language for mobile app development sooner or later get a project where they need to add maps to an app. Here they meet the challenge of lack of performance. The more data it is, the worse performance can become. The reason for this is excessive re-rendering; it means that there takes place more frequent rendering of components than it should, which is time-consuming. Such rendering does not relate to the component (marker in this case) but changes in part of the state. While allowing only necessary re-rendering with changes in the state of the marker, the performance will become distinctly better.

Moreover, when operating with lots of markers, the map becomes overcharged and can negatively impact the mobile UX. Displaying all the markers at once creates a poor user experience. At UpsilonIT, we had this issue with events on our map in one of the recent projects. With a small number of pins, the map looked fine. But when increasing the number of markers, the map looks cluttered and does not provide the best overview of where events are happening.

The most intuitive solution here is to use clustering. By clustering the markers together (zoom-out feature) and de-clustering (zoom-in feature), you can greatly improve app performance while presenting the data in a more approachable way. In this article, we will describe in detail how we did map clustering, and provide you with the programming code we used.

Setup

  1. Install Node.js
  2. Install the Expo CLI and yarn. npm install expo-cli yarn --global
  3. Create your Expo project. expo init rn-clustering-example
  4. Navigate to the project. cd rn-clustering-example
  5. Install Super Cluster and other dependencies. yarn add react-native-maps supercluster @mapbox/geo-viewport
  6. Start up the server. expo start

Implementation

Let’s start with creating a basic map using react-native-maps library and adding some markers. This will construct a native MapView that is used to render the Map component on the screen. We pass the Marker components inside the MapView and it shows them on the map.



import React, { useEffect, useState } from 'react'
import { Dimensions, StyleSheet, View } from 'react-native'
import MapView, { Marker, Region } from 'react-native-maps'

import MapZoomPanel from './components/MapZoomPanel'

function getRandomLatitude(min = 48, max = 56) {
  return Math.random() * (max - min) + min
}

function getRandomLongitude(min = 14, max = 24) {
  return Math.random() * (max - min) + min
}
interface Markers {
  id: number
  latitude: number
  longitude: number
}

function App(): JSX.Element {
  const [zoom, setZoom] = useState(18)
  const [markers, setMarkers] = useState<Markers[]>([
    { id: 0, latitude: 53.91326738786109, longitude: 27.523712915343737 },
  ])
  const region: Region = {
    latitude: 53.91326738786109,
    longitude: 27.523712915343737,
    latitudeDelta: 0.1,
    longitudeDelta: 0.1,
  }
  const map = React.useRef(null)

  const generateMarkers = React.useCallback((lat: number, long: number) => {
    const markersArray = []

    for (let i = 0; i < 50; i++) {
      markersArray.push({
        id: i,
        latitude: getRandomLatitude(lat - 0.05, lat + 0.05),
        longitude: getRandomLongitude(long - 0.05, long + 0.05),
      })
    }
    setMarkers(markersArray)
  }, [])

  useEffect(() => {
    generateMarkers(region.latitude, region.longitude)
  }, [])

  const getRegionForZoom = (lat: number, lon: number, zoom: number) => {
    const distanceDelta = Math.exp(Math.log(360) - zoom * Math.LN2)
    const { width, height } = Dimensions.get('window')
    const aspectRatio = width / height
    return {
      latitude: lat,
      longitude: lon,
      latitudeDelta: distanceDelta * aspectRatio,
      longitudeDelta: distanceDelta,
    }
  }

  const mapZoomIn = () => {
    if (zoom > 18) {
      setZoom(18)
    } else {
      setZoom(zoom + 1)
      const regn = getRegionForZoom(region.latitude, region.longitude, zoom + 1)
      map.current.animateToRegion(regn, 200)
    }
  }

  const mapZoomOut = () => {
    if (zoom < 3) {
      setZoom(3)
    } else {
      setZoom(zoom - 1)
      const regn = getRegionForZoom(region.latitude, region.longitude, zoom - 1)
      map.current.animateToRegion(regn, 200)
    }
  }

  return (
    <View style={styles.container}>
      <MapView ref={map} mapType="hybrid" style={styles.mapView} initialRegion={region}>
        {markers.map((item) => (
          <Marker
            key={item.id}
            coordinate={{
              latitude: item.latitude,
              longitude: item.longitude,
            }}></Marker>
        ))}
      </MapView>
      <MapZoomPanel
        onZoomIn={() => {
          mapZoomIn()
        }}
        onZoomOut={() => {
          mapZoomOut()
        }}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  mapView: { flex: 1, width: '100%', height: '100%' },
})

export default App


Enter fullscreen mode Exit fullscreen mode

If you’ve ever used react-native-community’s react-native-maps package to add a native MapView to your react-native app, you probably encountered the common issue of cluttering the map with pins, icons, and other content.

Map without clustering

We decided to solve this issue with the mapbox/supercluster library.

Supercluster is a geospatial point clustering solution for a cluttered react-native-maps MapView. It is easy and fast to implement, and it works pretty much out of the box.

Cluster logic is used for combining some amount of markers (let’s say for 5+) into one clustered marker when we zoom out the map. Clustered marker contains the number of markers inside and when we press on it, it zooms the map in and shows the markers that were inside of the clustered marker.

We also need the mapbox/geo-viewport library to turn the bounding boxes into centerpoint & zoom combos on the maps.

Implementation of basic clustering using SuperCluster and its integration with MapView can be done in a few steps:

  1. Install SuperCluster and mapbox/geo-viewport using Yarn (yarn add supercluster @mapbox/geo-viewport) or NPM (npm install --save supercluster @mapbox/geo-viewport)

  2. To integrate clustering into react-native-maps’ MapView, we need custom markers, types and helpers used for geodata processing, styling clusters, etc. You can find them on our Github.

  3. To finish it off we need to put together the basic map, custom components, and the cluster logic, and the code should look like this:



import React, { forwardRef, memo, useEffect, useMemo, useRef, useState } from 'react'
import { Dimensions, LayoutAnimation, Platform } from 'react-native'
import MapView, { MapViewProps, Polyline } from 'react-native-maps'
import SuperCluster from 'supercluster'

import { MapClusteringProps } from './ClusteredMapViewTypes'
import ClusterMarker from './ClusteredMarker'
import {
  calculateBBox,
  generateSpiral,
  isMarker,
  markerToGeoJSONFeature,
  returnMapZoom,
} from './helpers'

const ClusteredMapView = forwardRef<MapClusteringProps & MapViewProps, any>(
  (
    {
      radius,
      maxZoom,
      minZoom,
      minPoints,
      extent,
      nodeSize,
      children,
      onClusterPress,
      onRegionChangeComplete,
      onMarkersChange,
      preserveClusterPressBehavior,
      clusteringEnabled,
      clusterColor,
      clusterTextColor,
      clusterFontFamily,
      spiderLineColor,
      layoutAnimationConf,
      animationEnabled,
      renderCluster,
      tracksViewChanges,
      spiralEnabled,
      superClusterRef,
      ...restProps
    },
    ref,
  ) => {
    const [markers, updateMarkers] = useState([])
    const [spiderMarkers, updateSpiderMarker] = useState([])
    const [otherChildren, updateChildren] = useState([])
    const [superCluster, setSuperCluster] = useState(null)
    const [currentRegion, updateRegion] = useState(restProps.region || restProps.initialRegion)

    const [isSpiderfier, updateSpiderfier] = useState(false)
    const [clusterChildren, updateClusterChildren] = useState(null)
    const mapRef = useRef()

    const propsChildren = useMemo(() => React.Children.toArray(children), [children])

    useEffect(() => {
      const rawData = []
      const otherChildren = []

      if (!clusteringEnabled) {
        updateSpiderMarker([])
        updateMarkers([])
        updateChildren(propsChildren)
        setSuperCluster(null)
        return
      }

      propsChildren.forEach((child, index) => {
        if (isMarker(child)) {
          rawData.push(markerToGeoJSONFeature(child, index))
        } else {
          otherChildren.push(child)
        }
      })

      const superCluster = new SuperCluster({
        radius,
        maxZoom,
        minZoom,
        minPoints,
        extent,
        nodeSize,
      })

      superCluster.load(rawData)

      const bBox = calculateBBox(currentRegion)
      const zoom = returnMapZoom(currentRegion, bBox, minZoom)
      const markers = superCluster.getClusters(bBox, zoom)

      updateMarkers(markers)
      updateChildren(otherChildren)
      setSuperCluster(superCluster)

      superClusterRef.current = superCluster
    }, [propsChildren, clusteringEnabled])

    useEffect(() => {
      if (!spiralEnabled) {
        return
      }

      if (isSpiderfier && markers.length > 0) {
        const allSpiderMarkers = []
        let spiralChildren = []
        markers.map((marker, i) => {
          if (marker.properties.cluster) {
            spiralChildren = superCluster.getLeaves(marker.properties.cluster_id, Infinity)
          }
          const positions = generateSpiral(marker, spiralChildren, markers, i)
          allSpiderMarkers.push(...positions)
        })

        updateSpiderMarker(allSpiderMarkers)
      } else {
        updateSpiderMarker([])
      }
    }, [isSpiderfier, markers])

    const _onRegionChangeComplete = (region) => {
      if (superCluster && region) {
        const bBox = calculateBBox(region)
        const zoom = returnMapZoom(region, bBox, minZoom)
        const markers = superCluster.getClusters(bBox, zoom)
        if (animationEnabled && Platform.OS === 'ios') {
          LayoutAnimation.configureNext(layoutAnimationConf)
        }
        if (zoom >= 18 && markers.length > 0 && clusterChildren) {
          if (spiralEnabled) {
            updateSpiderfier(true)
          }
        } else {
          if (spiralEnabled) {
            updateSpiderfier(false)
          }
        }
        updateMarkers(markers)
        onMarkersChange(markers)
        onRegionChangeComplete(region, markers)
        updateRegion(region)
      } else {
        onRegionChangeComplete(region)
      }
    }

    const _onClusterPress = (cluster) => () => {
      const children = superCluster.getLeaves(cluster.id, Infinity)
      updateClusterChildren(children)

      if (preserveClusterPressBehavior) {
        onClusterPress(cluster, children)
        return
      }

      const coordinates = children.map(({ geometry }) => ({
        latitude: geometry.coordinates[1],
        longitude: geometry.coordinates[0],
      }))

      mapRef.current.fitToCoordinates(coordinates, {
        edgePadding: restProps.edgePadding,
      })

      onClusterPress(cluster, children)
    }

    return (
      <MapView
        {...restProps}
        ref={(map) => {
          mapRef.current = map
          if (ref) {
            ref.current = map
          }
          restProps.mapRef(map)
        }}
        onRegionChangeComplete={_onRegionChangeComplete}>
        {markers.map((marker) =>
          marker.properties.point_count === 0 ? (
            propsChildren[marker.properties.index]
          ) : !isSpiderfier ? (
            renderCluster ? (
              renderCluster({
                onPress: _onClusterPress(marker),
                clusterColor,
                clusterTextColor,
                clusterFontFamily,
                ...marker,
              })
            ) : (
              <ClusterMarker
                key={`cluster-${marker.id}`}
                {...marker}
                onPress={_onClusterPress(marker)}
                clusterColor={
                  restProps.selectedClusterId === marker.id
                    ? restProps.selectedClusterColor
                    : clusterColor
                }
                clusterTextColor={clusterTextColor}
                clusterFontFamily={clusterFontFamily}
                tracksViewChanges={tracksViewChanges}
              />
            )
          ) : null,
        )}
        {otherChildren}
        {spiderMarkers.map((marker) => {
          return propsChildren[marker.index]
            ? React.cloneElement(propsChildren[marker.index], {
                coordinate: { ...marker },
              })
            : null
        })}
        {spiderMarkers.map((marker, index) => (
          <Polyline
            key={index}
            coordinates={[marker.centerPoint, marker, marker.centerPoint]}
            strokeColor={spiderLineColor}
            strokeWidth={1}
          />
        ))}
      </MapView>
    )
  },
)

ClusteredMapView.defaultProps = {
  clusteringEnabled: true,
  spiralEnabled: true,
  animationEnabled: true,
  preserveClusterPressBehavior: false,
  layoutAnimationConf: LayoutAnimation.Presets.spring,
  tracksViewChanges: false,
  // SuperCluster parameters
  radius: Dimensions.get('window').width * 0.06,
  maxZoom: 20,
  minZoom: 1,
  minPoints: 2,
  extent: 512,
  nodeSize: 64,
  // Map parameters
  edgePadding: { top: 50, left: 50, right: 50, bottom: 50 },
  // Cluster styles
  clusterColor: '#00B386',
  clusterTextColor: '#FFFFFF',
  spiderLineColor: '#FF0000',
  // Callbacks
  onRegionChangeComplete: () => {},
  onClusterPress: () => {},
  onMarkersChange: () => {},
  superClusterRef: {},
  mapRef: () => {},
}

export default memo(ClusteredMapView)


Enter fullscreen mode Exit fullscreen mode

After implementing the steps above, you will get a Custom MapView component that has clustering functionality.

This is how the Clustered Custom Map look:

Map with clustering

We have created a repository on our Github where you can explore code examples in more detail. Also, if you decide to use this article as a guideline, you may need some extra components to make everything work, so be sure to check out our Github with the whole programming code that we used.

Concluding Thoughts

Clustering makes the map light and allows users to have a clear overview of everything that is happening instead of hiding the map on top of markers that overlap each other. Quickly see your points aggregated into smaller groupings of points. This provides a better understanding of how many points exist within an area. That’s why it is crucial to think about both performance and UX while working on your map. With map clustering, you will be able to build faster, more responsive applications that can provide a superb user experience.


Have questions? We welcome any feedback and will be glad to start a discussion in the comments section.

💖 💪 🙅 🚩
upsilon_it
Upsilon

Posted on August 19, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

How to Do Map Clustering with React Native
reactnative How to Do Map Clustering with React Native

August 19, 2021