We’re going to discuss the issues that can occur during the integration of a Spring Boot + Thymeleaf web application with the Google Maps API in order to achieve an employee location tracking system.
Intro
Recently, I had to integrate with Google Maps API for a job-related task. I had a great experience working with Google Maps API both on the backend and frontend, but it was not without problems and a lot of research. In fact, I did learn a lot and, for this very reason, I decided to share some of the most interesting pitfalls that I fell into.
To show you what I learned, I assembled a small no-brainer application. It is a Spring Boot/Thymeleaf web application with the mission to display on a map the employees from an organization. It can display a particular employee on the map, all the employees on the map, and during the creation of an employee, it allows picking an address from a pinpoint on a map. The features that I decided to implement are for the sake of tutorial purposes and they may not make a lot of sense sometimes. The app can be found here: employee-locator.
Disclaimer: I am not a JavaScript professional, and I am sure that the code that I am going to present to you may be refactored/implemented in a more elegant manner than I did, and for that I am sorry.
Okay, so let’s get started.
Setup
Basically, what you need to do to get started with google maps API is setting up a developer account and for that, you’ll have to set up a billing account. After getting the API key, make sure that you enable Geocoding API
, Maps JavaScript API
, and PlacesAPI
.
TIP: Another thing to mention here that I learned the hard way is the restrictions. The Java dependency google-maps-services
requires that there is no restriction on the API key for it to work. So, from this point, you either leave this key without restrictions, or you can create a key for the web part (restrict it however you want) and one for Java and leave it with no restrictions.
Having the key gives you the access to work with Google Maps API on the frontend side, to call the Google Maps API on the backend side you’ll need the following dependency:
[code language=”xml” firstline=”0″]
<dependency>
<groupId>com.google.maps</groupId>
<artifactId>google-maps-services</artifactId>
<version>0.17.0</version>
</dependency>
[/code]
TIP: You’ll need to include the API key in the pages that you are going to use the maps. Here are the scripts that I included to get the most out of the Maps API for my needs:
[code language=”html” firstline=”0″]
<script th:src="@{/polyfill.js}"></script>
<script th:src="@{/markerclusterer.js}"></script>
<script async th:src="’https://maps.googleapis.com/maps/api/js?key=’yourKey&libraries=places’"></script>
[/code]
TIP: Before getting into the hands-on examples, there is one thing that still has to be mentioned, even if it is out of the scope of this application and the examples that I’m going to give. If you have enabled on your application the Content-Security-Policy
header, you might need the following lines (on my real project I needed them):
[code language=”java” firstline=”0″]
script-src ‘self’ https://fonts.gstatic.com https://developers.google.com https://maps.googleapis.com https://maps.gstatic.com https://fonts.googleapis.com;
font-src ‘self’ https://fonts.gstatic.com https://fonts.googleapis.com;
img-src ‘self’ https://fonts.gstatic.com https://developers.google.com https://maps.googleapis.com https://maps.gstatic.com https://fonts.googleapis.com;
style-src ‘self’ https://fonts.gstatic.com https://developers.google.com https://maps.googleapis.com https://maps.gstatic.com https://fonts.googleapis.com;
[/code]
General overview of the domain
On the backend, almost everything is centered around one entity – the employee. The employee class has some fields describing an actual employee like his name or email. For this tutorial, there is also the address field and the geolocation field.
For the employee entity, I also created the corresponding repository, service, and controller to be able to persist/fetch employees from the database (PostgreSQL) all the way up to the thymeleaf HTML pages.
Why did I decide to persist the geolocation fields in the database?
TIP: Not sure if this is a tip or not, but from my point of view, always calling the Geocoding API/Places API to fetch the coordinates that need to be displayed on the map, in the long run, is going to cost a lot and for this very reason the coordinates are computed on the backed during the fetch time of the employees and before passing them to the controller. Also, there is the geoProcesed
field which will ensure that an employee’s address is going to be computed only once. In this manner from the billing point of view mostly we’ll pay just for the displaying of the coordinates via Maps JavaScript API, but not for computing every time.
All the mentioned logic happens in the GeoLocationServiceImpl
.
[code language=”java” firstline=”0″]
@Service
@Slf4j
public class GeoLocationServiceImpl implements GeoLocationService {
private GeoApiContext geoApiContext; //1
@Autowired
public GeoLocationServiceImpl(@Value("${gmaps.api.key}") String apiKey) {
geoApiContext = new GeoApiContext.Builder().apiKey(apiKey)
.maxRetries(2)
.connectTimeout(10L, TimeUnit.SECONDS)
.build();
}
@Override
public Optional<GeoLocation> computeGeoLocation(String fullAddressLine) { //2
final PlacesSearchResponse placesSearchResponse;
try {
placesSearchResponse = PlacesApi.textSearchQuery(geoApiContext,
fullAddressLine).await();
log.info("Processing address line using PlacesApi.textSearchQuery {}", fullAddressLine);
if (placesSearchResponse != null && placesSearchResponse.results.length > 0) {
log.info("Obtained following predictions using PlacesApi.textSearchQuery {}",
Arrays.toString(placesSearchResponse.results));
final GeocodingResult[] geocodingResults = GeocodingApi.newRequest(geoApiContext)
.place(placesSearchResponse.results[0].placeId)
.await();
log.info("Processing address line using GeocodingApi.newRequest {}", fullAddressLine);
if (geocodingResults != null && geocodingResults.length > 0) {
log.info("Obtained following geocoding results using GeocodingApi.newRequest {}",
Arrays.toString(geocodingResults));
final String placeId = geocodingResults[0].placeId;
final double latitude = geocodingResults[0].geometry.location.lat;
final double longitude = geocodingResults[0].geometry.location.lng;
final GeoLocation geoLocation = new GeoLocation(latitude, longitude);
log.info("Computed following coordinates using GeocodingApi.newRequest {}", geoLocation);
return Optional.of(geoLocation);
} else {
log.warn("No coordinates found using GeocodingApi.newRequest {}", fullAddressLine);
}
} else {
log.warn("No coordinates found using PlacesApi.textSearchQuery {}", fullAddressLine);
}
} catch (ApiException | InterruptedException | IOException e) {
log.error("Encountered error [{}] using GoogleMapsApi for address {} : {}", e.getMessage(), fullAddressLine, e);
}
return Optional.empty();
}
}
[/code]
Let’s look at what happens in there.
Firstly (1) you’ll notice the GeoApiContext geoApiContext
which is the entry point for calling the Google Geo APIs. The documentation states that:
GeoApiContext
works best when you create a single GeoApiContext
instance or one per API key and reuse it for all your Google Geo API queries. This is because each GeoApiContext
manages its own thread pool, back-end client, and other resources.
Now, the single instance of the GeoApiContext
is guaranteed by the fact that this service is managed by Spring and by default every bean in Spring is a singleton, which plays out kinda nice here.
Next is the creation of GeoApiContext
via the builder, which offers many ways of configuring it, but the main thing that you are going to need is the API key which I provided via the .properties
file. Also, I configured the max retries of every request to 2 and the connection timeout to 10 seconds. You can configure lots of different things like a proxy if your deployed app runs through a proxy.
Then there is the actual method (2) that computes the coordinates from a String address.
In my case it uses both the GeocodingAPI
and PlacesAPI
, from the documentation:
Geocoding API – Geocoding is the process of converting addresses (like “1600 Amphitheatre Parkway, Mountain View, CA”) into geographic coordinates (like latitude 37.423021 and longitude -122.083739)
PlacesAPI – Performs a text search for places. The Google Places API enables you to get data from the same database used by Google Maps and Google+ Local.
TIP: Usually, just invoking the GeocodingApi
with its address would suffice if you are dealing with simple addresses, but if you need something more complex like a search by the postal code or some broken/incomplete addresses, even addresses with mistakes I found PlacesAPI
+ GeocodingAPI
to work best.
Basically, the method is performing a text search through more than 100 million businesses and points of interest (from documentation) via the PlacesAPI
, it takes the placeId
from the first result (which I believe to be the most accurate) and passes it to the GeocodingAPI
, to request a GeocodingResult
by that placeId
. From that point, the GeoLocation
is being constructed out of the obtained result, in particular, this piece of the code:
[code language=”java” firstline=”0″]
final double latitude = geocodingResults[0].geometry.location.lat;
final double longitude = geocodingResults[0].geometry.location.lng;
final GeoLocation geoLocation = new GeoLocation(latitude, longitude);
[/code]
A thing to mention here is that the entire google-maps-services
suite is just a client wrapping the HTTP requests to the https://maps.googleapis.com.
In my case for the PlacesApi
the call to https://maps.googleapis.com/maps/api/place/textsearch/json and for the GeocodingAPI
the https://maps.googleapis.com/maps/api/geocode/json are being wrapped with all the provided request parameters.
Please notice that I used the .await()
after both the requests that I made, but there is also the option to use the .awaitIgnoreError()
. The difference between these 2 builder methods is that the .awaitIgnoreError()
is ignoring the exceptions while performing the request and errors returned by the server and the .await()
requires a try-catch since it doesn’t ignore the exceptions. Both methods will perform the request synchronously.
TIP: I strongly advise you to use the .awaitIgnoreError()
since in production many things can happen and it will be hard to debug not knowing the reason for a null
response.
There is also a way to perform the requests asynchronously by setting a callback on the request handling the result and the failure with the .setCallback()
method.
Now that we understand where the coordinates are coming from we can move on to the visual stuff.
The main page
Please excuse my UI/UX skills, the main page is pure HTML with some bootstrap and nothing fancy. It displays all the employees in the system with the ability to view each one of them on the map or all of them at once. Also, there is a button for employee creation/adding.
View an employee on the map
By clicking on the address of an individual employee the following modal will appear.
Okay, what is going on here. The first thing to be mentioned is the modal body:
[code language=”html” firstline=”0″]
<div class="modal-body">
<div id="employee-map" style="width:100%; height: 550px; margin-bottom: 30px;"></div>
</div>
[/code]
Which contains a div
with the id employee-map
. This div is going to be referenced as the holder of my map with the address marker of the clicked employee. When clicking the address of an individual employee the following JavaScript function will be invoked showIndividualMapModal(element)
.
[code language=”javascript” firstline=”0″]
function showIndividualMapModal(element) {
//…
var map = new google.maps.Map(document.getElementById(’employee-map’), {
zoom: 15,
center: {lat: 50.5039, lng: 4.4699}
}); //1
const legend = getEmployeesLegend(); //2
map.controls[google.maps.ControlPosition.RIGHT_TOP].push(legend);
if (longitude !== 0.0 && latitude !== 0.0) {
var markers = [];
var latLng = new google.maps.LatLng(latitude, longitude); //3
map.setCenter(new google.maps.LatLng(latitude, longitude)); //4
let fillColor = getFillColorByRole(role);
let strokeColor = getStrokeColorByJobPosition(position);
const marker = new google.maps.Marker({
position: latLng,
icon: getDefaultSvgMarker(fillColor, strokeColor) //5
});
let employeeDescription = getEmployeeDescription(fullName, role, position, address); //6
const infoWindow = new google.maps.InfoWindow({
content: employeeDescription
}); //7
(function (m, infoWindow) {
google.maps.event.addListener(m, ‘click’, function (evt) {
infoWindow.setContent(employeeDescription);
infoWindow.open(map, m);
});
})(marker, infoWindow); //8
markers.push(marker);
var markerCluster = new MarkerClusterer(map, markers, {
imagePath: ‘https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m’
}); //9
}
}
[/code]
- The previously mentioned
div
is being initialized as a map. - A custom legend is being created containing all the employee role colors and the job position colors. There is not much to discuss here as you can view the code on GitHub or get it from the official documentation. But, still, an interesting thing to mention is that if you want the default google style looking legend paste this CSS to your legend div
"border-radius: 2px; background: white; padding: 3px; margin: 10px; box-shadow: rgb(0 0 0 / 30%) 0px 1px 4px -1px; diplay:none"
. Then the programmatically created legend is being added to the map. - The coordinates holder
LatLng
is being created with already computed coordinates from the employee. - The map is centered according to the employee`s coordinates.
- The custom looking marker is being created which takes a position and an icon:
- position is of type LatLng, therefore the previously created holder may be passed here right-away.
- icon, you can omit it if you want the default google-maps icon, but if you want a custom one you’ll have to provide one. An option to choose would be providing an SVG icon and this is exactly what I did in the
getDefaultSvgMarker(fillColor, strokeColor)
.[code language=”java” firstline=”0″]
path: svgMarkerPath, // svg path of your icon
strokeWeight: 4, // the weight of the stroke
fillColor: fillColor, // the color of the marker
fillOpacity: 0.9, // the transparency of the marker
scale: 1.7, // size of the marker
strokeColor: strokeColor, // stroke of the marker
anchor: {x : 12, y : 22}, // grabbing point of the markerlet svgMarkerPath = "M12,2C8.1,2,5,5.1,5,9c0,5.3,7,13,7,13s7-7.8,7-13C19,5.1,15.9,2,12,2z"; // the actual icon
[/code]
TIP: I think special care here is needed regarding the SVG marker path, which I found to be a real headache, maybe because of my little knowledge of vector graphics. My advice here is to either draw it yourself or pick one from the ones that I left inmaps-common.js
on my GitHub repository.
Another headache that gave me nightmares is the anchor which I described above as to be the grabbing point of your marker. Without a correct anchor, your marker will simply float on the map during the zoom in/zoom out and won’t have a fixed position to the given coordinates. My solution was to load the SVG in the adobe illustrator and pick the coordinates of the anchor from there.
- Custom programmatically created HTML to serve as the body for the info window of the marker.
- Creating the info window.
- Assigning a click listener to the marker responsible for the opening of the info window.
- Creating the
marker clusterer
by providing themap
,markers
array populated with our marker, and theimagePath
to the nice icons that will be displayed. At this point, there is no much sense in using the marker clusterer, but it won’t harm. Much more sense will be brought to the marker clusterer when used to display all the employees.
TIP: The marker clusterer divides the map into squares according to the current zoom level, groups the markers into each square grid, and displays a nice icon with the number of grouped markers assisted by a color that reflects the concentration of markers. The idea of themarker clusterer
is to improve the overall performance of the map when it is overloaded with markers and to ease the cognitive burden put on a user who has to dive through thousands of markers on a single map.
Viewing all employees on the map
Viewing all the employees on the map can be viewed also in a modal by clicking the corresponding button which will invoke the showCollectiveMapModal(element)
.
[code language=”javascript” firstline=”0″]
function showCollectiveMapModal(element) {
$(‘#allEmployeesDialog’).modal(‘show’);
var map = new google.maps.Map(document.getElementById(’employees-map’), {
zoom: 2,
center: {lat: 50.5039, lng: 4.4699}
});
const legend = getEmployeesLegend();
map.controls[google.maps.ControlPosition.RIGHT_TOP].push(legend);
$.getJSON(‘http://localhost:8080/api/employees’, function (employees) {
if (employees.length > 0) {
var markers = [];
for (var i = 0; i < employees.length; i++) {
var latLng = new google.maps.LatLng(employees[i].geoLocation.latitude, employees[i].geoLocation.longitude);
let fillColor = getFillColorByRole(employees[i].role);
let strokeColor = getStrokeColorByJobPosition(employees[i].jobPosition);
const marker = new google.maps.Marker({
position: latLng,
icon: getDefaultSvgMarker(fillColor, strokeColor)
});
let employeeDescription = getEmployeeDescription(
employees[i].fullName,
employees[i].role,
employees[i].jobPosition,
employees[i].fullAddress
);
const infoWindow = new google.maps.InfoWindow({
content: employeeDescription
});
(function (m, infoWindow, idx) {
google.maps.event.addListener(m, ‘click’, function (evt) {
infoWindow.setContent(employeeDescription);
infoWindow.open(map, m);
});
})(marker, infoWindow, i);
markers.push(marker);
}
var markerCluster = new MarkerClusterer(map, markers, {
imagePath: ‘https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m’
});
}
});
}
[/code]
This function repeats all the steps showIndividualMapModal(element)
is doing, but in a loop for each employee out of an array of employees retrieved via jQuery’s getJson()
.
The interesting thing to see here is the marker clusterer
in action:
Different zoom levels, different information displayed.
Creating an employee
The last feature that I want to show you, as it too has some puzzles, is the creation of the employee. Creation/Adding of an employee does nothing uncommon, except for the fact that it lets the user pick an employee’s address from google maps.
The first thing to notice here is the search box which is providing suggestions for different locations. Another thing is the marker with the info window allowing to pick that address as the employee’s address.
This modal can be triggered by clicking the add employee button which will invoke the initCreateEmployeeMap(element)
.
[code language=”javascript” firstline=”0″]
function initCreateEmployeeMap(element) {
$(‘#createEmployeeDialog’).modal(‘show’);
var map = new google.maps.Map(document.getElementById(‘create-employee-map’), {
zoom: 2,
center: {lat: 50.5039, lng: 4.4699}
});
const input = document.createElement(‘input’);
input.type = ‘text’;
input.className = ‘controls’;
input.id = ‘pac-input’;
input.style = ‘margin-top: 20px;’;
input.placeholder = ‘Search’;
var modal = document.getElementById(‘createEmployeeDialog’);
modal.appendChild(input);
var pacContainerInitialized = false;
$(‘#pac-input’).keypress(function () {
if (!pacContainerInitialized) {
$(‘.pac-container’).css(‘z-index’, ‘9999’);
pacContainerInitialized = true;
}
});
const searchBox = new google.maps.places.SearchBox(input);
map.controls[google.maps.ControlPosition.TOP_LEFT].push(input);
map.addListener(‘bounds_changed’, () => {
searchBox.setBounds(map.getBounds());
});
let markers = [];
searchBox.addListener(‘places_changed’, () => {
const places = searchBox.getPlaces();
if (places.length == 0) {
return;
}
markers.forEach((marker) => {
marker.setMap(null);
});
markers = [];
const bounds = new google.maps.LatLngBounds();
places.forEach((place) => {
if (!place.geometry || !place.geometry.location) {
console.log(‘Returned place contains no geometry’);
return;
}
const marker = new google.maps.Marker({
map,
position: place.geometry.location,
icon: getDefaultSvgMarker(‘black’, ‘white’),
title: place.name
});
let addressDescription =
‘<div style="align-content: center; align-items: center; text-align: center;" id="content">’ +
‘<h5 class="formattedAddress">’ +
place.formatted_address +
‘</h5>’ +
‘<div id="bodyContent">’ +
"<button style=’font-size: 0.8em; margin-bottom: 5px;’ class=’btn btn-primary’ type=’button’ onclick=’populateAddressFields(this)’>Pick address</button>" +
‘</div>’ +
‘</div>’;
const infoWindow = new google.maps.InfoWindow({
content: addressDescription
});
(function (m, infoWindow) {
google.maps.event.addListener(m, ‘click’, function (evt) {
infoWindow.setContent(addressDescription);
infoWindow.open(map, m);
});
})(marker, infoWindow);
markers.push(marker);
if (place.geometry.viewport) {
bounds.union(place.geometry.viewport);
} else {
bounds.extend(place.geometry.location);
}
});
map.fitBounds(bounds);
});
}
[/code]
I am not going to describe all the lines from this function as it is copy-paste from the official Google Maps API documentation with minor modifications to fit my needs, for example the info window which allows to pick a marker’s address and populate with it the address form’s fields. What I want to discuss here are some of the pitfalls that you may fall into when dealing with the search box and a modal together.
TIP: Firstly, notice that I created the search box programmatically which will solve the issue of it being unable to be initialized during consecutive triggers of the modal.
[code language=”javascript” firstline=”0″]
const input = document.createElement("input");
input.type = "text";
input.className = "controls";
input.id = "pac-input";
input.style = "margin-top: 20px;";
input.placeholder = "Search";
var modal = document.getElementById("createEmployeeDialog");
modal.appendChild(input);
[/code]
TIP: Another thing to pay attention to is this piece of code which I found to solve another modal/search box issue:
[code language=”javascript” firstline=”0″]
var pacContainerInitialized = false;
$(‘#pac-input’).keypress(function () {
if (!pacContainerInitialized) {
$(‘.pac-container’).css(‘z-index’, ‘9999’);
pacContainerInitialized = true;
}
});
[/code]
The google maps will pick up the previously created input it will add some dynamic CSS to it under the pac-container
class which will set the z-index
lower than the modal`s z-index
and this will result in hidden behind the modal suggestions. The above-mentioned piece of code solves this issue by looking at the created input after it is fully initialized and sets the z-index
to 9999
to the pac-container
class.
Other than that, everything else is well explained in the documentation.
Conclusion
That is it. I want to say that I had a pleasant experience working with the Google Maps API and I want to mention that they did a tremendous job documenting almost everything and providing lots of tutorials. Feel free to check them here: https://developers.google.com/maps/documentation.
In case you missed it, the full source code is here: employee-locator.
Thank you for your attention and happy coding!