Photo clusters with mapbox-gl-js

Photo clusters on map

I've seen photo clusters on map in the past, think way back Instagram used to have it, so it's nothing new. But I wanted to see if I can create them using mapbox-gl-js as I've been using it quite extensively at work and thought there might be a product fit possibility for photo clusters on the map so I wanted prototype it to get a better understanding what is possible and how it might be done.

While Mapbox GL JS documentation has lot's of good examples, they do not have an example on how to do photo clusters. However after finding this two examples it was clear that it should be possible to create photo clusters on the map.

(Thanks Mapbox team for having awesome docs and examples)

Interactive Example

Code Example

Here is the code that follows similar pattern as on Mapbox GL JS example pages:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Display Photo clusters</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<link href="https://api.mapbox.com/mapbox-gl-js/v1.13.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v1.13.0/mapbox-gl.js"></script>
<style>
  body { margin: 0; padding: 0; }
  #map { bottom: 0; position: absolute; top: 0; width: 100%; }

  .cluster-base {
    background-color: #fff;
    border: 4px solid #fff;
    border-radius: 4px;
    box-shadow: 0 3px 3px rgba(0, 0, 0, 0.1);
    position: relative;
  }

  .cluster-base img {
    border-radius: 4px;
    display: flex;
    height: 60px;
    object-fit: cover;
    width: 60px;
  }

  .cluster-base .count {
    background-color: #f00;
    border-radius: 100px;
    box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);
    color: #fff;
    padding: 0 6px;
    position: absolute;
    right: -10px;
    top: -10px;
  }
</style>
</head>
<body>
<div id="map"></div>

<script>
  // TO MAKE THE MAP APPEAR YOU MUST
  // ADD YOUR ACCESS TOKEN FROM
  // https://account.mapbox.com
  mapboxgl.accessToken = '<your access token here>';
  var map = new mapboxgl.Map({
    container: 'map',
    zoom: 11,
    center: [-122.4194, 37.7749],
    style: 'mapbox://styles/mapbox/light-v10',
  });

  map.addControl(new mapboxgl.NavigationControl());

  map.on('load', function() {
    map.addSource('photo-cluster', {
      type: 'geojson',
      data: {
        type: 'FeatureCollection',
        features: [{
          type: 'Feature',
          properties: { src: 'http://placekitten.com/g/400/400' },
          geometry: { type: 'Point', coordinates: [-122.462, 37.782] }
        }, {
          type: 'Feature',
          properties: { src: 'http://placekitten.com/500/500' },
          geometry: { type: 'Point', coordinates: [-122.462, 37.785] }
        }, {
          type: 'Feature',
          properties: { src: 'http://placekitten.com/300/300' },
          geometry: { type: 'Point', coordinates: [-122.468, 37.782] }
        }, {
          type: 'Feature',
          properties: { src: 'http://placekitten.com/350/350' },
          geometry: { type: 'Point', coordinates: [-122.47, 37.79] }
        }]
      },
      cluster: true,
      clusterRadius: 64,
      clusterProperties: {
        src: ['string', ['get', 'src'], ''] // might want to set a default value here
      }
    });

    map.addLayer({
      id: 'photo-clusters',
      type: 'circle',
      source: 'photo-cluster',
      filter: ['!=', 'cluster', true],
      paint: {
        'circle-color': 'rgba(0, 0, 0, 0)', // this will hide singular cluster
        'circle-radius': 64
      }
    });

    // objects for caching and keeping track of HTML marker objects (for performance)
    var markers = {};
    var markersOnScreen = {};

    function updateMarkers() {
      var newMarkers = {};
      var features = map.querySourceFeatures('photo-cluster');

      // for every cluster on the screen, create an HTML marker for it (if we didn't yet),
      // and add it to the map if it's not there already
      for (var i = 0; i < features.length; i++) {
        var coords = features[i].geometry.coordinates;
        var props = features[i].properties;
        // when it's a cluster there will be using cluster_id,
        // otherwise we can grab src as it should be unique
        var markerId = props.cluster ? props.cluster_id : props.src;

        var marker = markers[markerId];
        // if marker is not present create it
        if (!marker) {
          var el = createClusterElement(props);
          markers[markerId] = new mapboxgl.Marker({
            element: el
          }).setLngLat(coords);
          marker = markers[markerId];
        }
        newMarkers[markerId] = marker;

        if (!markersOnScreen[markerId]) marker.addTo(map);
      }
      // for every marker we've added previously, remove those that are no longer visible
      for (id in markersOnScreen) {
        if (!newMarkers[id]) markersOnScreen[id].remove();
      }
      markersOnScreen = newMarkers;
    }

    // update markers on the screen on every frame
    map.on('render', function() {
      updateMarkers();
    });
  });

  function createClusterElement(props) {
    var html = '<div class="cluster-base">' +
      (props.cluster ? '<span class="count">'+ props.point_count + '</span>' : '') +
      '<img src="'+ props.src +'" />' +
    '</div>';

    var el = document.createElement('div');
    el.innerHTML = html;
    return el;
  }
</script>

</body>
</html>
marexandre.com :]