Home | Send Feedback

Present photos on Google Maps

Published: May 02, 2020  •  javascript, java

In this blog post, I show you a way how to present your holiday photos on Google Maps. We are going to develop an application that presents the users a Google Maps and displays markers on the position the photos were taken. Instead of the usual marker icons, the application shows a thumbnail of the photo. When the user clicks on a marker, the photo is displayed in full-screen mode.

Check out a demo of the application we build in this blog post:
https://omed.hplar.ch/geophotos/

The finished project is a static website in the sense that it does not require any server-side processing. You can host the application with any static hosting service.

One requirement for this project is that you have a bunch of photos with embedded location information. On Windows you can check that by right click on a file and open the Properties.

properties

1. Extract Metadata and generate thumbnails

The first thing we need to do is extract the location data and create a JSON file. The JavaScript application we are going to build needs this information to display the markers on Google Maps.

We also need a thumbnail image for each photo. We could load the original photo into the marker, and the browser would automatically scale the picture down to the size of the marker, but that would be a waste of bandwidth, especially when the map displays many markers. Instead, we are going to create small thumbnail images.

These two tasks are done with a Java application. Note that you run this application only once. After that the application is no longer needed to run the finished project. There is no server side processing involved in displaying the map.


The metadata-extractor library provides classes and methods to extract metadata from image files.

    <dependency>
      <groupId>com.drewnoakes</groupId>
      <artifactId>metadata-extractor</artifactId>
      <version>2.13.0</version>
    </dependency>

pom.xml

Because Java does not have a built-in JSON library, I added the following JSON-P library to my project.

    <dependency>
      <groupId>org.glassfish</groupId>
      <artifactId>jakarta.json</artifactId>
      <version>2.0.0-RC2</version>
    </dependency>    

pom.xml

For creating thumbnails, I added the Thumbnailator library.

    <dependency>
      <groupId>net.coobird</groupId>
      <artifactId>thumbnailator</artifactId>
      <version>0.4.11</version>
    </dependency>

pom.xml

With these three libraries in place, the following code creates the thumbnails and the JSON file.

  private static void extractMetadata(JsonGenerator jg, Path photoDir,
      Path thumbnailDir) throws IOException {
    Files.list(photoDir).forEach(photo -> {
      try (InputStream is = Files.newInputStream(photo)) {
        Metadata metadata = ImageMetadataReader.readMetadata(is);

        GpsDirectory gpsDirectory = metadata
            .getFirstDirectoryOfType(GpsDirectory.class);

        ExifIFD0Directory directory = metadata
            .getFirstDirectoryOfType(ExifIFD0Directory.class);
        Date date = directory.getDate(ExifIFD0Directory.TAG_DATETIME);

        if (gpsDirectory != null) {
          GeoLocation geoLocation = gpsDirectory.getGeoLocation();
          if (geoLocation != null && !geoLocation.isZero()) {
            if (!Files.exists(thumbnailDir.resolve(photo.getFileName()))) {
              Thumbnails.of(photo.toFile()).size(36, 36)
                  .toFiles(thumbnailDir.toFile(), Rename.NO_CHANGE);
            }

            jg.writeStartObject();
            jg.write("lat", geoLocation.getLatitude());
            jg.write("lng", geoLocation.getLongitude());
            jg.write("img", photo.getFileName().toString());
            if (date != null) {
              jg.write("ts", (int) (date.getTime() / 1000));
            }
            jg.writeEnd();
            jg.flush();
          }
        }
      }
      catch (IOException | ImageProcessingException e) {
        e.printStackTrace();
      }

    });

  }

Extract.java

The program iterates over each photo, extracts latitude, longitude and the timestamp of the photo and writes it as JSON into a text file.

The JSON file we get from this program looks like this:

[
   {
    "lat":14.081791666666666, 
    "lng":98.20677222222223,
    "img":"IMG_20200311_102648.jpg",
    "ts":1583922409
    },
    ...
]

Creating thumbnails with the Thumbnailator library is a one-liner where you specify the path to the source file, the target size, and the output file. The size() method creates images that fit within a rectangle of a specified size, and it preserves the aspect ratio of the original image. So the result of this operation is a new image with either a width or a height of 36 pixels.

   Thumbnails.of(photo.toFile()).size(36, 36).toFiles(thumbnailDir.toFile(), Rename.NO_CHANGE);

That concludes the pre-processing of our photo library. We now have a JSON file and a bunch of thumbnails. Let's continue with the web application.

2. Project Setup

Create a new project with npm init. Then add these development dependencies to your project.

npm install parcel-bundler -D
npm install @babel/core -D
npm install @babel/plugin-transform-runtime -D
npm install bread-compressor-cli -D
npm install local-web-server -D
npm install rimraf -D

Add the following scripts to package.json.

  "scripts": {
    "prestart": "rimraf dist/*.*",
    "start": "parcel --port 8080 src/index.html",
    "prebuild": "rimraf dist/*.*",
    "build": "parcel build src/index.html --no-source-maps --public-url ./",
    "postbuild": "bread-compressor dist !*.jpg !*.jpeg",
    "serve-dist": "ws --hostname localhost -d dist -p 8080 -o --log.format stats"
  },

package.json

We are using npm start during development and npm run build when we want to create a production build. We can test the production build locally with npm run serve-dist


Create the file .babelrc in the root of your project and paste this code into it.

{
  "plugins": ["@babel/plugin-transform-runtime"]
}

.babelrc

We tell Babel here to use this plugin, which enables the re-use of Babel's injected helper code to save on code size.


Next, create the directory structure and copy the assets into the project.


Inside the src folder, create the files index.html, main.css, and main.js.

cd src
touch index.html
touch main.css
touch main.js

Optional: Copy a favicon.ico into the src folder.


Paste this code into the index.html file.

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>GeoPhoto</title>
  <link rel="stylesheet" type="text/css" href="main.css">
  <link rel='shortcut icon' type='image/x-icon' href='favicon.ico' />
</head>

<body>
  <script src="main.js"></script>
</body>

</html>

Our project is set up, and we can start implementing the functionality.

3. Google Maps

Every project that integrates the Google Maps API must request an API key.

Follow the steps on this page to get a key:
https://developers.google.com/maps/documentation/javascript/get-api-key


Then import the library in index.html. Make sure that the library loads before main.js. Some of our JavaScript code depends on the Google Maps API.

  <script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAZjJ216B4aJGdXTwXNevmXesob9RUSlPc"></script>
  <script src="main.js"></script>

index.html

Add a <div> to the body of the page. This is where the Google Maps API displays the map.

<div id="map"></div>

Add this code to main.css.

#map {
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 10px;
  right: 10px;
}

main.css

We want the map to cover the whole browser window with a margin of 10 pixels.


In main.js we add the following code

let map;
loadMap();

function loadMap() {
  const latLng = new google.maps.LatLng(14.0290853, 98.0161546);

  const mapOptions = {
    center: latLng,
    zoom: 10,
    mapTypeId: google.maps.MapTypeId.HYBRID
  }

  map = new google.maps.Map(document.getElementById('map'), mapOptions);
}

Change the centre of the map according to your needs.

At this point, you can test the application. Start Parcel with npm start and open the URL http://localhost:8080 in a browser. If everything is configured correctly, you should see the map.

4. Markers

One of the goals is to display the thumbnail as the marker icon instead of the usual Google Maps marker icon. I found these two blog posts that explain how to do this:


Copy the HTMLMapMarker.js file based on these blog posts into the src folder.

Add the following code to main.css. This is the CSS that styles the marker icon.

/* Outside white border */
.asset-map-image-marker {
  background-color: gold;
  border-radius: 5px;
  cursor: pointer !important;
  height: 40px;
  margin-left: -20px; /* margin-left = -width/2 */
  margin-top: -50px; /* margin-top = -height + arrow */
  padding: 0px;
  position: absolute;
  width: 40px;
}

/* Arrow on bottom of container */
.asset-map-image-marker:after {
  border-color: #ffffff transparent;
  border-style: solid;
  border-width: 10px 10px 0;
  bottom: -10px;
  content: '';
  display: block;
  left: 10px;
  position: absolute;
  width: 0;
}

/* Inner image container */
.asset-map-image-marker div.image {
  background-position: center center;
  background-size: cover;
  border-radius: 5px;
  height: 36px;
  margin: 2px;
  width: 36px;
}

main.css


We can now start adding markers for each of our photos to the map. This is not a problem when you only add a few markers. But as soon as you add hundreds or thousands of markers, Google Maps becomes sluggish. To solve this issue, we add the MarkerClustererPlus to our project:

npm install @google/markerclustererplus

This plugin clusters markers together and displays a special marker icon which displays the number of markers that are clustered together. With each zoom level and location, you get a different clustered view. A click on a cluster icon zooms the map to this location.

cluster plugin

The cluster plugin requires 5 special PNG images. You find them in the folder node_modules/@google/markerclustererplus/images.
Copy m1.png to m5.png into your dist/assets folder and rename them to 1.png, 2.png, ...


We want to display the photo timestamp as a tooltip on the marker. In our JSON file, we store the timestamp as seconds since 01.01.1970 00:00:00. To format the timestamp we add the date-fns library to our project, which provides the format() function.

npm install date-fns

With everything in place, we implement the code that loads the JSON file we created earlier and displays the markers on the map.

In main.js import HTMLMapMarker, MarkerClusterer from the cluster plugin, and the format function from date-fns.

import { HTMLMapMarker } from './HTMLMapMarker.js';
import MarkerClusterer from '@google/markerclustererplus';
import format from 'date-fns/format'

main.js

Add the markers constant. This is an array that holds our markers. Call the function loadPhotos() after displaying the map.

let map;
const markers = [];

loadMap();
loadPhotos();

Add the code for loadPhotos() to main.js.

async function loadPhotos() {
  const response = await fetch('assets/photos.json');
  const photos = await response.json();
  for (const photo of photos) {
    drawMarker(photo);
  }
  new MarkerClusterer(map, markers, { imagePath: 'assets/', minimumClusterSize: 20 });
}

function drawMarker(photo) {
  const marker = new HTMLMapMarker({
    photo: photo.img,
    latlng: new google.maps.LatLng(photo.lat, photo.lng),
    html: `<div class="asset-map-image-marker"><div title="${photo.ts ? format(new Date(photo.ts * 1000), 'yyyy-MM-dd HH:mm') : ''}" class="image" style="background-image: url(assets/thumbnails/${photo.img})"></div></div>`
  });
  markers.push(marker);

The loadPhotos() function is responsible for loading the photos.json file from the server and creating a marker object for each entry in the JSON file. When all markers are created, the program passes the markers to the cluster plugin. We tell the plugin to show only a cluster icon if there are more than 20 markers visible at one location. The plugin supports many more options. Check out the documentation.

Each marker is an HTMLMapMarker object, where we specify the location (latlng) and the HTML code (html) that the browser should display. You can display any HTML with HTMLMapMarker, not just images.


We reached a stage where you can test your application again. Start Parcel with npm start and open http://localhost:8080.

5. Image Gallery

We now see a bunch of markers on the map, but there is currently no way for the user to see the original photo. This missing functionality is what we are going to implement in this section.

We also have to solve another issue. When you took many pictures from one location, you see many markers on one spot. Even when you zoom to max level, it's very difficult or even impossible to click on all markers. To solve that, we show the photo in full screen when the user clicks on a marker, but then we also give him the possibility to scroll through all photos that are currently visible on the map.

For this purpose, we install an image gallery library. I choose lightGallery for this project.

Install the main library and a few plugins.

npm install lightgallery.js
npm install lg-autoplay.js
npm install lg-fullscreen.js
npm install lg-zoom.js

Check out the lightGallery project page to learn more about the library.


In main.css import the CSS from lightGallery

@import '../node_modules/lightgallery.js/dist/css/lightgallery.css';

main.css


In index.html, add a <div> to the body. This is where lightGallery adds HTML to display the gallery.

  <div id="lightgallery"></div>

index.html


In main.js import the library

import 'lightgallery.js';
import 'lg-fullscreen.js';
import 'lg-autoplay.js';
import 'lg-zoom.js';

main.js

Then change the drawMarker() function. We add a listener that gets triggered when the user clicks on a marker. The handler code dynamically creates a gallery with all visible photos.

function drawMarker(photo) {
  const marker = new HTMLMapMarker({
    photo: photo.img,
    latlng: new google.maps.LatLng(photo.lat, photo.lng),
    html: `<div class="asset-map-image-marker"><div title="${photo.ts ? format(new Date(photo.ts * 1000), 'yyyy-MM-dd HH:mm') : ''}" class="image" style="background-image: url(assets/thumbnails/${photo.img})"></div></div>`
  });

  marker.addListener('click', () => {
    const el = document.getElementById('lightgallery');
    const lg = window.lgData[el.getAttribute('lg-uid')];
    if (lg) {
      lg.destroy(true);
    }
    lightGallery(el, {
      dynamic: true,
      dynamicEl: visiblePhotos(photo)
    });
  });

  markers.push(marker);
}

main.js

To get an array of all visible photos, add the following function to main.js.

function visiblePhotos(photo) {
  const bounds = map.getBounds();
  const result = [{ src: `assets/photos/${photo.img}` }];
  for (const marker of markers) {
    if (bounds.contains(marker.getPosition())) {
      if (photo.img !== marker.photo) {
        result.push({ src: `assets/photos/${marker.photo}` });
      }
    }
  }
  return result;
}

main.js

This method creates an array of all visible photos. First, it adds the photo that the user clicked on.
Then it gets the latitude/longitude bounds of the current viewport with map.getBounds(). Next, it loops over all markers and checks with bounds.contains() if the position of the marker is inside the bounds of the map.

6. Build

Everything is implemented. We can now build and deploy the application.
For creating a production build issue, the command npm run build.

You can test the production build on your computer with npm run serve-dist.
This starts an HTTP server on port 8080.

If everything is okay, you can deploy the contents of the dist folder to any HTTP server. You can deploy the application to one of the many Static Web Hosting providers if you don't manage your own server on the Internet. A web search with "Static Web Hosting" lists many of providers you can choose from.


That concludes this tutorial about creating a JavaScript application that presents your photos on Google Maps.