In this blog post, I show you how to present your holiday photos on Google Maps. We will 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 picture 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 opening the 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. 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. So 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.
<groupId>com.drewnoakes</groupId>
<artifactId>metadata-extractor</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
Because Java does not have a built-in JSON library, I added the following JSON-P library to my project.
<groupId>org.glassfish</groupId>
<artifactId>jakarta.json</artifactId>
<version>2.0.1</version>
</dependency>
</dependencies>
For creating thumbnails, I added the Thumbnailator library.
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
<dependency>
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(ExifDirectoryBase.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();
}
});
}
The program iterates over each photo extract 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 -D
npm install @parcel/compressor-gzip @parcel/compressor-brotli -D
npm install local-web-server -D
- parcel for building and bundling our JavaScript application
- local-web-server for starting a webserver to test the production build locally
Add the following scripts to package.json
.
"scripts": {
"start": "parcel",
"build": "parcel build",
"serve-dist": "ws --hostname localhost -d dist -p 8080 -o --log.format stats"
},
We use 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 .parcelrc
in the root of your project and paste this code into it.
{
"extends": ["@parcel/config-default"],
"compressors": {
"*.{html,css,js,svg,map}": [
"...",
"@parcel/compressor-gzip",
"@parcel/compressor-brotli"
]
}
}
Here we tell Parcel to pre-compress the assets with Brotli and gzip.
Next, create the directory structure and copy the assets into the project.
- Create a
src
,dist
anddist/assets
folder - Copy the
photos.json
file into thedist/assets
folder - Copy the thumbnails into
dist/assets/thumbnails
- Copy the photos into
dist/assets/photos
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 type="module" 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 type="module" src="main.js"></script>
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;
}
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 center 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:
- https://blackatlascreative.com/blog/custom-clickable-google-map-markers-with-images/
- https://levelup.gitconnected.com/how-to-create-custom-html-markers-on-google-maps-9ff21be90e4b
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;
}
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 @googlemaps/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.
The cluster plugin requires 5 special PNG images.
You find them in the folder node_modules/@googlemaps/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. So, 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 '@googlemaps/markerclustererplus';
import format from 'date-fns/format'
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 more
than 20 markers are 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 take 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 allow him to scroll through all images 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';
In index.html
, add a <div>
to the body. This is where lightGallery adds HTML to display the gallery.
<div id="lightgallery"></div>
In main.js
import the library
import 'lightgallery.js';
import 'lg-fullscreen.js';
import 'lg-autoplay.js';
import 'lg-zoom.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);
}
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;
}
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 providers you can choose from.
That concludes this tutorial about creating a JavaScript application that presents your photos on Google Maps.