Geotagging is easy. Clustering

10 min read January 18, 2025 #openlayers #javascript #maps

If there are a lot of photos, displaying them all at once on the map is not the right choice for UX and performance. So let's group the photos and display their count. preview

For grouping OpenLayers has a cluster (ol.source.Cluster). It works with points (ol.geom.Point) out of the box, so it won't be difficult to add it for our task. Set the distance and where to get the points from (photosSource), and rename layerThumbs to layerClusters:

const clusterSource = new ol.source.Cluster({
  distance: 30,
  minDistance: 10,
  source: photosSource
});
const layerClusters = new ol.layer.Vector({
  source: clusterSource,
  style: clusterStyle,
  updateWhileAnimating: true,
  updateWhileInteracting: true,
});

const map = new ol.Map({
  // ..
  layers: [layerOSM, layerClusters],
  // ..
});

function clusterStyle(feature, resolution) {
  const features = feature.get('features');
  const size = features.length;
  const key = `cl-${size}`;
  if (!cache[key]) {
    cache[key] = new ol.style.Style({
      zIndex: 110,
      image: new ol.style.Circle({
        radius: 12,
        stroke: new ol.style.Stroke({color: '#8AFFD9'}),
        fill: new ol.style.Fill({color: '#229D75'})
      }),
      text: new ol.style.Text({
        text: `${size}`,
        fill: new ol.style.Fill({color: '#fff'})
      }),
    });
  }
  return cache[key];
}

clusterStyle is similar to photoStyle, except it draws not an icon, but a circle with a fill and a stroke, and text on top of it.

Since there are no individual photos on the map now and only these circles can be selected, we'll have to rewrite the selection function:

map.on('click', event => {
  layerClusters.getFeatures(event.pixel).then(clickedFeatures => {
    if (!clickedFeatures.length) return;
    const features = clickedFeatures[0].get('features');
    const details = features.map(f => photoDetails(f));
    document.getElementById('photo-details').innerHTML = details.join();
  });
});

When clicking on the map, we collect an array of objects within the selection and display their details.

📄 Source code

We can also show individual photos if there is only one object in the cluster:

function clusterStyle(feature, resolution) {
  const features = feature.get('features');
  const size = features.length;
  if (size === 1) return thumbStyle(features[0], resolution);
  // ..

The selection logic can be enhanced by adding smooth zooming on a cluster click:

map.on('click', event => {
  layerClusters.getFeatures(event.pixel).then(clickedFeatures => {
    if (!clickedFeatures.length) return;
    const features = clickedFeatures[0].get('features');
    if (features.length > 1 && !isMaximumZoom(map.getView())) {
      // Zoom in
      const extent = ol.extent.boundingExtent(
        features.map(r => r.getGeometry().getCoordinates()),
      );
      map.getView().fit(extent, {duration: 400, padding: [150, 150, 150, 150]});
    } else {
      // Show photo details
      const details = features.map(f => photoDetails(f));
      document.getElementById('photo-details').innerHTML = details.join();
    }
  });
});

function isMaximumZoom(view) {
  const maxZoom = view.getMaxZoom();
  const currentZoom = view.getZoom();
  return currentZoom >= maxZoom;
}

If a circle with 2+ elements is selected and there is still room to enlarge the map, we iterate through the selected coordinates and form a virtual extent area from them:

const extent = ol.extent.boundingExtent(
  features.map(r => r.getGeometry().getCoordinates()),
);

Then zoom in to this area within 400ms so that there're 150px padding on the edges:

map.getView().fit(extent, {duration: 400, padding: [150, 150, 150, 150]});;

Demo:

📄 Source code