7. Route Planning with Leaflet Maps
A quick note about the practicals...
Remember to use Mozilla Firefox to do these practicals. Please do NOT use Microsoft Internet Explorer / Edge, this is not suitable for web development. Coding should be done in Notepad++
You do not have to run every bit of code in this document. Read through it, and have a go where you feel it would help your understanding. If I explicitly want you to do something, I will write an instruction that looks like this:
This is an instruction that tells you something to either think about, or do.
Shortcuts: Part 1 Part 2
In which we explore geocoding and route calculations in order to make our very own turn-by-turn route planner!
Route Planning
Much of the early progress in web GIS was made for the purpose of route planning, as people moved away from the idea of having a road atlas in the car, and more towards using CD-ROM-based route planners, web-based route planners, ‘Sat Navs’, and now routing apps based on web services (e.g. the Google Maps app). There is now a tremendous amount of value in increasingly efficient routing algorithms and complete datasets of roads, speed limits and even real-time traffic conditions - all at the global scale!
You can clearly see the provenance of route planning in the history of Google Maps, the directions tools were very prominent in the early days, and even the cartography was widely recognised to be reminiscent of the popular A-Z road atlases.
Directions are therefore an important part of Web GIS , and have even been used in art projects, such as the Roads to Rome project by Moovel lab, which is created by overlaying the route from 486,713 start points in Europe to the centre of Rome!
Before we get too carried away, however, let’s have a bash at making a more ‘normal’ route planner using Leaflet. There are two components to this: Geocoding (the process of turning place names into coordinates) to define the start and end locations, and Route Finding (the process of calculating the route between the places) to get the route and corresponding directions to add to the map and web page.
Let’s start with Geocoding:
Part 1
Geocoding
Geocoding is, quite simply, the process of taking a place name and converting it into coordinates. Though the process can be fraught with issues, it is an incredibly useful way of allowing users (who typically will not know coordinates…) to interact with Web GIS systems using place names instead (with which they hopefully will be familiar). Think about when you are trying to find a particular place and you type its name into Google Maps, the first thing that happens is that your place name is geocoded into coordinates so that the software understands where you are talking about.
Behind the scenes, geocoding is little more than just searching a database of place names with corresponding coordinates and returning the closest match. Accordingly, to be an effective geocoder, a website needs to have access to a tremendously large dataset of placenames that is often referred to as a gazetteer. As most people do not have the time or resources to do this for themselves (and because doing so would be ‘reinventing the wheel’…), we typically use external geocoding services in order to do our gecoding, whereby we send our placename data to a server, and it returns all of the information that it has about that place, including the coordinates.
This, therefore, is what we will be doing in the first part of today. There are many different online geocoders available, but we will be using the excellent Nominatim service, which is based upon the place names that exist within OpenStreetMap. Let’s get right to it then…
Make a new
.html
file using the usual template, set the map dimensions to800px
x500px
and add global variables forstart
andend
Using web services
Geocoding using Nominatim is an example of using a web service, which is where you send a http request and receive some data back. Functionally it is exactly the same as retrieving some data from the web - exactly like we did to get data from the police in week 4! Because it is exactly the same - we can use the same function to do it: makeRequest()
!
Add the usual
makeRequest()
function to your code
As we know, makeRequest()
works by retrieving data from a url, and then passing the resulting data to a callback function that it calls when the data has been successfully downloaded. In this case, we want to geocode two different place names using the following steps:
- construct a url that will return the coordinates of each place name
- pass each resulting url to
makeRequest()
- set the callback function to populate the
start
andend
global variables with the resulting coordinates
Simple, right? Let’s start with creating the url’s:
Create a function called
getGeocodeURL()
that takes in a single argument calledplacename
, then populate the function with this line of code:
return [
"https://nominatim.openstreetmap.org/?format=json&limit=1&q=",
placename
].join("");
In this line, you can see that I have taken the unusual step of creating an array and using its built in join method in order to concatenate the String, rather than using the +
concatenation operator. Whilst this is not essential (and may seem a little unnecessary in this example), it is a convenient way of joining strings together as it keeps the code a little tidier when there are many components to join (and often executes a little faster!).
Remind yourself how the join function of the array object works, and make sure that you understand how the
getGeocodeURL()
function works and what it returns - Add a call togetGeocodeURL()
in aconsole.log()
statement to see if you were right
Now let’s look at the resulting URL - as with all web services, this one requires several parameters to be encoded into the url in order to tell the service what you want. Here is a rundown of the parameters included in the above request:
Parameter | Purpose |
---|---|
format |
The format in which you would like the resulting data to be encoded (normally JSON or XML) |
limit |
The number of results that should be returned (where the placename is not unique) |
q |
The placename to be geocoded |
If you wish, you can learn a little more about the Nominatim parameters in the documentation.
Now, add two calls to
makeRequest()
to yourinitMap()
function like this (feel free to change theplacename
values to somewhere else!):
// geocode start location
makeRequest(getGeocodeURL("Nether Kellet"), function(data){
// record the start location and try to start routing
start = [data[0].lon, data[0].lat];
console.log(start);
});
// geocode end location
makeRequest(getGeocodeURL("Oxford Road, Manchester"), function(data){
// record the end location and try to start routing
end = [data[0].lon, data[0].lat];
console.log(end);
});
As you can see, the url
argument of makeRequest()
is being populated inline with a call to the getGeocodeURL()
function (as opposed to be first stored in a variable then passed as an argument), and the callback
function is also being created inline (as opposed to being declared elsewhere then passed as an argument). This is an efficient way to code for values and functions that are only going to be used once, as there is no point creating a variable if you are never going to use it again. It is worth remembering that for your assessment…
In each case, the callback function simply stores the resulting coordinates in start
and end
global variables respectively, and then writes the result to the console so that you can see if it worked:
Run the code and see if you get some sensible coordinates back
Finally, as a key functionality of our map is dependent upon another service, we should make sure that it is properly attributed:
Add the following to the string in the attribution section of the
tileLayer
constructor:
Geocoding by <a href="https://nominatim.org">Nominatim</a>.
Part 2
Routing with OpenRouteService
Now we have effective geocoding, it is time to build in some routing functionality. Route calculations are incredibly complex and take up large amounts of data and resources, meaning that this job is not well suited to the browser and so should be undertaken on a server instead. We will, therefore once again be handing off the actual computation to a web service, this time called OpenRouteService.
Signing up to OpenRouteService
Unlike Nominatim, you need to register to use OpenRouteService, as not all of their services are free and they need to keep track of how much you are using. This is done using an API Key, which is an unique code that you submit along with your request in order to identify yourself.
Go to the sign up page and fill in your details. Follow the instructions and then keep an eye on your email so that you can click the confirmation link.
Once you have done this, click here to login and go to the TOKENS tab, where you will see a section entitled Request a Token.
Set the Token type to FREE (using the dropdown menu) and set the Token name to GIS and the Web, click CREATE TOKEN.
This will create an API Key for you, which you will be able to see in the middle of the screen like this:
Keep this page open, you will need the key later!
Constructing a Query using the OpenRouteService API
Okay, now you’re all signed up it’s time to start routing! The OpenRouteService API is very complex and has a wide variety of functionality (worth bearing in mind for Assessment 2…). It also contains lots of settings that you can use in order to ensure that your resulting route is well suited to your purposes. This is where the use of array joins instead of concatenation operators (+
) can come in very handy!
Add the following function to your code and replace
API_KEY
with your OpenRouteService API Key
/**
* Calculate the route between start and end global variables
*/
function doRouting() {
//construct a url out of the required options for OpenRouteService
const url = [
// these bits are fixed or imported from the start and variables above
'https://api.openrouteservice.org/directions?',
'api_key=', 'API_KEY', //CHANGE THIS!!
'&coordinates=', start[0].toString(),',',start[1].toString(),
'%7C', end[0].toString(),',',end[1].toString(),
// these are the options, a comprehensive list is available at:
// https://openrouteservice.org/dev/#/api-docs/directions/get
'&profile=', 'driving-car',
'&preference=', 'fastest',
'&format=', 'geojson',
'&units=', 'km',
'&geometry_format=', 'geojson'
];
}
Now edit the function so that the url array is concatenated into a single string using the
.join()
function of the array object (and the result is still stored inurl
)
Add a call to
doRouting()
at the end of each of the inline callbacks for your geocodes
All good so far, but hang on…
We are now calling doRouting()
twice - once when the start
location is geocoded, and once when the end
location is geocoded. Clearly we can only calculate a route when we know both the start
and end
locations so, we need to make sure that this function only runs if both variables are populated, and to complicate matters, because the geocoding requests are asynchronous, we don’t even know what order they will be populated in (as it isn’t necessarily in the order that we requested them!).
If we run it when only one of them is populated, we will get one of these errors (depending on which comes back first):
TypeError: start is undefined
TypeError: end is undefined
Fortunately, there is an easy way to prevent the code from running unless both variables are populated - using a conditional statement (if
)!
Place all of the code in the
doRouting()
function inside anif
statementwith the following condition:
(start && end)
The operator &&
means AND in JavaScript (||
means OR), and because we know that empty variables are falsy values in JavaScript, we know that the code in this conditional statement will only run if there is a value stored in start
AND there is also a value stored in end
. Therefore, the first time a geocoding callback calls doRouting()
(when we are missing a value), nothing will happen, whereas the second time (when we have both sets of coordinates) it will run. Clever eh!
Log the resulting
url
fromdoRouting()
to the console and paste it into the browser. Did it work? Make sure that your code works (prints out one working routing url) and that you understand this - if you aren’t sure, ask!
Once we have all that sorted, there is just one more line to add to doRouting
:
Add a line to the that passes the resulting
url
tomakeRequest()
and set the callback to a function calledrouteToMap()
. make sure that this is inside yourif
statement!
Handling your routing data
Now all of that is sorted, the next step should be pretty obvious.
Create a function called
routeToMap()
that takes in a single argument calledroute
From here we are in familiar territory - you already know how to take some geojson (in this case stored in route
) and add it to the map so let’s do that:
Fill in
routeToMap()
so that it adds the route to the map, and then add markers at thestart
andend
locations.
Remember that printing the dataset to the console allows you to examine it, revealing exactly how you access each part of the result…
Once we have our geometry on the map - you should be looking at something like this (in this case roughly my journey to work):
Now we have the geometry of the route, it’s time to get a little more information…
Take a look at the
route
GeoJSON object in the console, find the thesummary
property, which is an array containing 1 element, which is an object with two properties:distance
andduration
…
Within
routeToMap()
, store thedistance
andduration
values in variables of the same name
The trick to this is looking at each step through the dataset and saying is it an array (like features
), or an object (like properties
) I’ll give you a hint, the answer to each one starts with (note how we are accessing the properties and arrays…):
route.features[0].properties...
Now locate the
segments
array and store this in another variable, again with the same name
If you have a look in the segments
array, you can see that it contains all of the journey information associated with the route. In the case of my route, the whole thing is in one segment - but yours might be more. Either way, each element in the segment array is an object containing three properties: distance
(the distance of this segment in km), duration
(the journey duration for this segment in seconds) and steps
(an array containing turn-by-turn instructions for each step of the journey). This steps
array is the information that your sat-nav would read out for you as you drive.
Dynamically Generating HTML Elements
Now that we have all of this great information, we can give it to the user as well as the route itself! All of the information that we need is contained right there in our dataset (and now in your variables). To present it, I want to produce a table of instructions that looks like this:
To do this we will dynamically construct an HTML <table>
element using JavaScript, populating it with data as we go. This means that we do not require any prior knowledge about the dataset in order to create our element, and it is a very useful approach to producing dynamic web pages.
First lets just have a look at how it’s done, then I’ll ask you to try implementing it…
No copying and pasting for now please! Read through and wait for the instruction.
Firstly, lets add an extra <div>
straight after the map that we can populate with the information:
<!-- This is where the table of directions will go -->
<div id='directions'></div>
Now, (still in the routeToMap()
function) we need to extract the segments of the journey from the dataset:
// get the description of the route
const segments = route.features[0].properties.segments;
Then, we need to initialise an HTML String that we are going to use to build our <table>
element.
// open HTML table and add header
let directionsHTML = [
"<table><th>Directions (",
getDistanceString(distance),
", <i>",
getDurationString(duration),
"</i>)</th>"
].join("");
This snippet acts to open the <table>
element and then add the table header <th>
element, which contains the distance
and direction
values. Note the use of getDistanceString()
and getDirectionString()
. As you can see from the dataset, these values are presented in km and seconds respectively. This is not likely to be conducive to clear or useful instructions, as you would have to be very fast to be able to cover more than a kilometere in a time that would be conventionally be described in seconds (normally less than a minute).
To make our website as user friendly as possible, therefore, I have constructed the getDistanceString()
and getDirectionString()
functions to convert the values to appropriate units (m
, km
/ seconds
, minutes
, hours
) depending upon the magnitude of the values passed to them.
For example , see getDurationString()
below:
/**
* Returns a sensible duration string for a given duration in seconds
*/
function getDurationString(duration){
// hours
if (duration > 3600) {
// hours and minutes
return Math.floor(duration / 3600).toFixed(0) + "hrs " +
Math.floor((duration % 3600) / 60).toFixed(0) + "mins";
// minutes
} else if (duration > 60) {
// minutes
return Math.ceil(duration / 60).toFixed(0) + "mins";
// seconds
} else {
// seconds only
return Math.ceil(duration).toFixed(0) + "secs";
}
}
What does the
.toFixed()
function of a Number object do?
As you can see, the workflow is as follows:
- If the duration is more than an hour, then it is given in hours and minutes
- Otherwise, if the duration is between a minute and an hour, then it is given in minutes (rounded up to the nearest minute)
- Otherwise, if the duration is less than a minute, then it is given in seconds
As you might expect, the workflow for getDistanceString()
would be similar:
- If the distance is more than a kilometre, then it is given in km (to one decimal place)
- Otherwise, if the distance is less than a km, then it is given in metres (rounded up to the nearest metre)
Now that is sorted, it is time to populate the table. To do this, we simply need to loop through the array of route segments
that you extracted earlier, and then through each element in the steps
list for each segment, giving us access to the turn-by-turn instructions. Once again, we simply use the data for each section to add a row <tr>
to the table made up of a single column <td>
containing the distance (in bold) and the duration (in italics). In each iteration of the nested loop a new row is added to the directionsHTML
string using the concatenation +=
operator.
// loop through the description for each segment of the journey
for (let i = 0; i < segments.length; i++){
// loop through each step of the current segment
for (let j = 0; j < segments[i].steps.length; j++){
// add a direction to the table
directionsHTML += [
"<tr><td><b>",
segments[i].steps[j].instruction,
"</b> (",
getDistanceString(segments[i].steps[j].distance),
", <i>",
getDurationString(segments[i].steps[j].duration),
"</i>)</td></tr>"
].join("");
}
}
Finally, once the table has been completed, with a row for each instruction, the closing </table>
tag should be added and the resulting HTML string added to the directions
<div>
.
// close the table
directionsHTML += "</table>";
// load the directions into the div
document.getElementById('directions').innerHTML = directionsHTML;
Make sure that all makes sense (ask if not!)
When you understand it, implement the above code in the
routeToMap()
function
You will need to implement
getDistanceString()
yourself use the outline below to get you started:
/**
* Returns a sensible distance string for a given distance in km
*/
function getDistanceString(distance){
//is it more than 1km?
if () {
//if so, use km
return ;
} else {
// if not, use m
return ;
}
}
If all goes to plan, you should have some lovely directions like this:
Okay, so maybe not all that lovely, but directions nonetheless! All we need is a little CSS to sort that out:
Style the table so that it looks a little more professional - here’s some CSS to get you started:
/* Style the table */
table {
margin-top: 10px;
font-family: sans-serif;
min-width: 800px;
}
/* Style the table header */
th {
background-color: #000000;
color: white;
padding: 3px;
}
/* Style the table cells */
td {
padding: 3px;
}
/* Style the table rows (only colours the even numbered rows in the table) */
tr:nth-child(even) {
background-color: #f2f2f2;
}
If that all goes to plan, then congratulations, you have your very own route planner!
Once again, be sure to acknowledge the external services that you are using: add the following to the attribution text in your tileLayer
constructor:
Routing by <a href="https://openrouteservice.org/">OpenRouteService</a>.
Some extra ideas…
In this latter half of the course I will provide you with some additional optional content to challenge yourself if you wish. Here are some ideas:
See if you can re-style the route and markers on the map
If you want a little more action, see if you can make this route planner more interactive by accepting the locations in HTML
<input>
fields and calculating the route with a<button>
click
Have a close look at all of the possibilities available at OpenRouteService and think about how you might apply these types of route analysis to future GIS and the Web Projects