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 and now ‘Sat Navs’. 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.

So directions are 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. 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 placename is geocoded into coordinates so that the software understands where you are talking about.

Behind the scenes, geocoding is typically 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’ somewhat…), 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 today. There are many different online geocoders available, but we will be using the excellent OpenRouteService platform, which is based upon the place names and road network 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, end, startName and endName

Our first task is going to be to create a functioning geocoder, for which we are going to construct a functioning request to the Nominatim web service - the geocoder that is used by (and is based upon) OpenStreetMap.

Add some string values to the startName and endName variables inside your initMap() function, and then make a call to startGeocoding(). I chose to calculate this from between my home village (Nether Kellet) and my office (Manchester), but you can use any places that you like.

Chaining Asynchronous Functions

Because we will be making calls to a web service using an XMLHttpRequest object, we will be using our usual makeRequest function this week

Add the usual makeRequest function to your code

As we know, makeRequest works by getting , and then making a call to a callback function when the data has been successfully downloaded. This time, however, we need to make two requests (as we have two placenames to geocode), so we will need to chain our functions together in order to make sure that they all execute in the right order. Fortunately, this is not very difficult, all that we need to do is separate our task into three sections, with a break each time that we call makeRequest:

  1. Geocode startName (a call to makeRequest)
  2. Store the resulting coordinates in the start global variable, then geocode endname (another call to makeRequest)
  3. Store the resulting coordinates in the end global variable

Between each of these steps,we must wait for the data to be retrieved, hence why we separate the process into three steps, with each step being called as the callback of the previous. In this case, therefore, function 1 will call makeRequest, setting function 2 as the callback, and function 2 will, in turn, call makeRequest, setting function 3 as the callback. in this way, the functions are guaranteed to run one after the other and so the code will execute in the correct order. This can be achieved very easily like this:

/**
 * Step 1 - geocode the start location
 */
function startGeocoding(){

    // geocode start location...
    makeRequest(url, continueGeocoding);
}

/**
 * Step 2 - store the start location and geocode the end location
 */
function continueGeocoding(data){

    // record the start locaion
    start = [data[0].lon, data[0].lat];

    // geocode end location...
    makeRequest(url, finishGeocoding);
}

/**
 * Step 3 - store the end location and start routing
 */
function finishGeocoding(data){

    // record the end location
    end = [data[0].lon, data[0].lat];
}

For this to work, we clearly need to replace url with a correct url to return data from Nominatim. to do this we can make a small convenience function:

/**
 * Generate and return a Nominatim geocoding URL for a given placename
 */
function getGeocodeURL(placename) {
	return [
        "https://nominatim.openstreetmap.org/?format=json&limit=1&q=",
        placename
    ].join("");
}

In this function, 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 odd 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 works, and make sure that you understand the getGeocodeUrl function

Unlike previous data requests that we have done, which simply lead to some data that can be retrieved, 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 queried

If you wish, you can learn a little more about the Nominatim parameters in the documentation.

Add in the above two snippets and edit them to replace the url parameter in the two calls to makeRequest() using calls to getGeocodeUrl(). Print out the results to the console to see if it works

Finally, as a key functionality of our map is dependent upon another service, we should make sure that it is properly referenced.

Add the following to 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 both data and resources, meaning that once again this job is not well suited to the browser and so should be undertaken on a server instead, with the results simply passed to the server. 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 the services are free and they need to keep track of how much you are using their services - which is done using an API Key - a unique code that you submit along with your request in order to identify yourself. To do this 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 hsa a wide variety of functionality (worth bearing in mind for Assessment 2…). It 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. replace API_KEY with your OpenRouteService API Key and place a call to this function inside finishGeocoding()

/**
* Calculate the route between start and end global variables
 */
function doRouting() {

    //construct a url out of the required options for OpenRouteService
    var url = [

        // these bits are fixed or imported from the start and variables above 
        'https://api.openrouteservice.org/directions?',
        'api_key=','API_KEY',	// TODO: SET YOUR API KEY HERE
        '&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'

    ].join("");	//join the array with no delimiter
}

Log the resulting url to the console and paste it into the browser. Did it work? If so, add the code to submit the url to makeRequest() and set the callback to a function called routeToMap()

The next step should be pretty obvious…

Create a function called routeToMap() that adds the route to the map as well as a marker at the start and end point and zooms to the same location. 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 another look at the data in the console, then (still within routeToMap()) extract the distance and duration of the whole journey and store them in variables called distance and direction respectively

Dynamically Generating HTML Elements

Great! Now we have some more information, but it’s still not quite enough. The best route planning websites contain turn-by-turn instructions, and so should ours! all of the information that we need is contained right there in our dataset. 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
var 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
var 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";
    }
}

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() is 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 (var i = 0; i < segments.length; i++){
					
    // loop through each step of the current segment
    for (var 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

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>.

One Last Thing…

In this latter half of the course I will provide you with some additional optional content to challenge yourself if you wish. Here is the first:

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

Also…

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.