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!

Roads to 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 to 800px x 500px and add global variables for start and end

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:

  1. construct a url that will return the coordinates of each place name
  2. pass each resulting url to makeRequest()
  3. set the callback function to populate the start and end 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 called placename, 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 to getGeocodeURL() in a console.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 your initMap() function like this (feel free to change the placename 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:

ORS

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 in url)

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 an if 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 from doRouting() 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 to makeRequest() and set the callback to a function called routeToMap(). make sure that this is inside your if 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 called route

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 the start and end 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):

Route Screenshot

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 the summary property, which is an array containing 1 element, which is an object with two properties: distance and duration

route object

Within routeToMap(), store the distance and duration 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.

route object

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:

Directions Table

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:

Unstyled Directions Table

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

Finished!

Some of the material on these pages is derived from the excellent Leaflet Tutorials and Mozilla Developers websites. Mozilla Developers by Mozilla Contributors is licensed under CC-BY-SA 2.5.