Geotagging is easy. OpenLayers

12 min read January 14, 2025 #openlayers #javascript #maps

I've always found it tedious and difficult to display stuff on maps. To understand the API, you need examples that aren't always accessible because they require a token to display and interact with the map. The token has to be obtained and hidden from other people's eyes.

But then I thought, why do I need proprietary Google Maps when there is OpenStreetMaps? I searched for APIs, came across OpenLayers, opened examples and was surprised by their number and simplicity.

In this series of articles, we will explore OpenLayers, display photo thumbnails on a map, add clustering, and finally create a Rust application that collects geodata and thumbnails from photos and generates a map.

Layer 1. OpenStreetMap

Let's start with the first layer, the map. We'll use OpenStreetMap since it doesn't require any tokens.

Let's add the OpenLayers library and its styles to HTML:

<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/ol.css">
<style>html, body, .map {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
}</style>
</head>
<body>
  <div id="map" class="map"></div>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ol.js"></script>
  <script src="map.js"></script>
</body>
</html>

Then, create an OpenStreetMap layer and a map object in JS:

const layerOSM = new ol.layer.Tile({
  source: new ol.source.OSM()
});
const map = new ol.Map({
  target: 'map',
  layers: [layerOSM],
  view: new ol.View({
    center: ol.proj.transform([20, 47], 'EPSG:4326', 'EPSG:3857'),
    zoom: 4
  })
});

In the map parameters, in target we need to specify the id of the HTML element, list the layers in layers, and customize the view.

The code is very simple, except for this line:

center: ol.proj.transform([20, 47], 'EPSG:4326', 'EPSG:3857'),

Here I'm centering the map on Europe, namely 47° North latitude and 20° East longitude from EPSG:4326 projection to the web map standard - EPSG:385 projection. Every time you work with the GPS coordinate system, you'll have to do this conversion.

That's enough to display the map:

📄 Source code

Layer 2. Photo thumbnails

Let's add a container for the marker objects. This will be an ol.source.Vector:

const photosSource = new ol.source.Vector();

Let's create a layer with thumbnails:

const layerThumbs = new ol.layer.Vector({
  source: photosSource,
  style: thumbStyle,
});

// -- icon styles --
const cache = {};

function photoStyle(feature, scale) {
  const url = feature.get('url');
  const key = `${scale}${url}`;
  if (!cache[key]) {
    cache[key] = new ol.style.Style({
      image: new ol.style.Icon({src: url, scale})
    });
  }
  return cache[key];
}

function thumbStyle(feature, resolution) {
  return [photoStyle(feature, clamp(0.2, 0.1 / resolution, 1))];
}

function clamp(min, value, max) {
  if (value < min) return min;
  if (value > max) return max;
  return value;
}

This layer will take thumbnails from the photosSource vector and display them in the style defined in the thumbStyle function.

Styling is needed to specify what exactly we'll draw and with what size. In this case, we will draw icons with a certain URL taken from feature.

const url = feature.get('url');
// ..
cache[key] = new ol.style.Style({
  image: new ol.style.Icon({src: url, scale})
})

What isfeature? ol.Feature is a geographic object displayed on the map (position, area, territory). It can be linked to a layer and contains attributes, styles, and geometry.

To display a photo, we need the following data:

  1. Marker coordinates.
  2. Image URL (optional, you can just draw circles).
  3. Details (optional) that will be displayed when the marker is selected.

We'll handle data collection in the next articles, but for now, let's look at an example:

[{
  "preview": "https://annimon.com/albums/screens/dsc_1337_ps_5996.jpg.250.png",
  "lat": 47.765957,
  "lon": 37.255646
}]

This is a JSON array with one element. We'll load it directly from the code:

const handleMapDataLoaded = items => {
  const transform = ol.proj.getTransform('EPSG:4326', 'EPSG:3857');
  items.forEach(item => {
    const feature = new ol.Feature(item);
    feature.set('url', item.preview);
    const coordinate = transform([parseFloat(item.lon), parseFloat(item.lat)]);
    feature.setGeometry(new ol.geom.Point(coordinate));
    photosSource.addFeature(feature);
  });
};
handleMapDataLoaded([{
  "preview": "https://annimon.com/albums/screens/dsc_1337_ps_5996.jpg.250.png",
  "lat": 47.765957,
  "lon": 37.255646
}]);

In handleMapDataLoaded we iterate through the loaded features, create an ol.Feature object, transform the coordinates into the map projection and set the geometry to a single point ol.geom.Point. Then add the object to photosSource.

All that's left is to add a layer to the map:

const map = new ol.Map({
  target: 'map',
  layers: [layerOSM, layerThumbs],
  ...

Demo

📄 Source code

Details on click

Small thumbnails aren't very convenient to look at, so let's collect a bit more data. For example, the camera model, original photo URL, and creation date:

handleMapDataLoaded([{
  "name": "dsc_1337_ps_5996.jpg",
  "path": "https://annimon.com/albums/files/dsc_1337_ps_5996.jpg",
  "preview": "https://annimon.com/albums/screens/dsc_1337_ps_5996.jpg.250.png",
  "lat": 47.765957,
  "lon": 37.255646,
  "make": "Sony Ericsson",
  "model": "MK16i",
  "date": "2017-06-02 19:30:08"
}]);

When clicking on a photo on the map, we'll show the details in a separate panel:

<div id="map" class="map"></div>
<div id="photo-details"></div>

<script type="text/html" id="photo-template">
  <div class="photo-details-container">
    <a href="{path}" target="_blank" title="Click to open photo in new tab">
      <img src="{preview}" class="photo-thumbnail">
    </a>
    <div class="photo-details-content">
      <h3>{name}</h3>
      <p><b>Camera:</b> {make} {model}</p>
      <p><b>Date:</b> {date}</p>
    </div>
  </div>
</script>

Using ol.interaction.Select we can add selection handlers for the map objects and define their styles:

const thumbSelector = new ol.interaction.Select({
  layers: [layerThumbs],
  style: selectedStyle
});
map.addInteraction(thumbSelector);

function selectedStyle(feature, resolution) {
  return [photoStyle(feature, clamp(0.4, 0.14 / resolution, 1.2))];
}

The selection will be configured for layerThumbs layer, and the scale of the selected photo will be slightly increased. Now, let's add selection and deselection handlers:

const selectedFeatures = thumbSelector.getFeatures();
selectedFeatures.on('add', event => {
  const feature = event.target.item(0);
  const details = photoDetails(feature);
  document.getElementById('photo-details').innerHTML = details;
});
selectedFeatures.on('remove', () => {
  document.getElementById('photo-details').innerHTML = '';
});

function photoDetails(feature) {
  let content = document.getElementById('photo-template').innerHTML;
  const keys = ['name', 'preview', 'date', 'lat', 'lon', 'make', 'model', 'path'];
  keys.forEach(key => {
    const value = feature.get(key);
    content = content.replace(`{${key}}`, value);
  });
  return content;
}

When a marker is selected, we insert HTML to #photo-details with the replaced parameters listed in the array const keys = ['name', 'preview', 'date', 'lat', 'lon', 'make', 'model', 'path'];.

Demo

📄 Source code

As you can see, if there are a lot of photos, they overlap, so we'll add clustering in the next article.