9. Participatory GIS 2 (Rasters)


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 address the use of raster data with Leaflet, and continue to build or skills in PGIS and Firebase.

Part 1

Getting Set Up

This week we are going to make another type of PGIS, one that is designed to help the collaborative design of routes. This is based upon a live research project by our very own Timna Denwood, who is helping the Isle of Barra (in the outer Hebrides) to design a new network of footpaths. She is doing this using Leaflet and Firebase, and so we are going to make a simplified version of her interface today in order to gain some more PGIS expeience, as well as gain some experience analysing raster data in the browser.

This is what we are aiming for:

To acheive this, we will be using elevation data in JSON format, and the A* (“a star”) routing algorithm, for which we will be using a version of this open source library that I have (slightly) edited to make it a little more functional.

Before we do anything, let’s get set up with the basics - here are some quick fire instructions to get us going:

Make a new web page using the usual template, name it something like week9.html

Add the following import lines to the <head>, just after you import Leaflet:

<!-- Load Proj4js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.5.0/proj4.js"></script>

<!-- Load Turf -->
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@5/turf.min.js"></script>

<!-- Load Firebase -->
<script src="https://www.gstatic.com/firebasejs/4.6.1/firebase.js"></script>

<!-- Load the astar library -->
<script src='http://geographicalinformation.science/code/astar.js'></script>

<!-- Load the elevation data -->
<script src='http://geographicalinformation.science/maps/data/barra.js'></script>

barra.js is a file that I have prepared for you. It contains all of the elevation data for the island in a variable called data, which contains a number of properties, each of which gives some important information about the dataset:

var data = {
	tl: [60751.202, 809581.428],
	bl: [60751.202, 793431.428],
	tr: [74651.202, 809581.428],
	br: [74651.202, 793431.428],
	resolution: 50,
	proj: "+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 +x_0=400000 +y_0=-100000 +ellps=airy +towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489 +units=m +no_defs",
	getWidth: function(){ return this.data.length; },
	getHeight: function(){ return this.data[0].length; },
	data: [ ... ]
}

Add the following additional global variables to your code

myDb, points, markers;

In initMap(), initialise points and markers as empty arrays

Change your map.setView() arguments view the isle of Barra:

.setView([56.98094722327509,-7.493614270972837], 12);

Change your L.tileLayer constructor to use a basemap that is better suited to this week’s project:

// this adds the basemap tiles to the map
L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
  maxZoom: 17,
  attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
}).addTo(map);

Add an initDb() function to your code just like last week, except this time we will connect to a new data object called "paths" instead of "clicks" that we used last week (obviously you can call it anything that you want, paths is just a suggestion):

/**
 * Initialise the database into the global myDb variable
 */
function initDb() {

  // initialize Firebase (this is copied and pasted from your firebase console)
  var config = {
    //
    // PASTE YOUR CREDENTIALS HERE
    //
  };
  firebase.initializeApp(config);

  // sign in anonymously - this helps stop your database being abused
  firebase.auth().signInAnonymously().catch(function(error) {
    console.log(error.code);
    console.log(error.message);
  });

  // create a global reference to your 'clicks' database
  myDb = firebase.database().ref().child('paths');
}

Make sure that you have updated the config object with your own credentials (you have them in last week’s code)

Add a call to initDb() at the beginning of your initMap() function

Add an addInfoBox() function to your code (as in week 3 and week 4), and a corresponding function call at the appropriate place in initMap():

/**
 * Create the info box in the top right corner of the map
 */
function addInfoBox(){

  // create a Leaflet control (generic term for anything you add to the map)
  info = L.control();

  // create the info box to update with population figures on hover
  info.onAdd = function (map) {

    // add a div to the control with the class info
    this._div = L.DomUtil.create('div', 'info');

    // add content to the div
    this._div.innerHTML = "[INSERT CODE HERE]";

    // prevent clicks on the div from being propagated to the map
    L.DomEvent.disableClickPropagation(this._div);

    //return the div
    return this._div;
  };

  // add the info window to the map
  info.addTo(map);
}

Replace [INSERT CODE HERE] with the necessary code to add three (HTML <button> elements)[https://www.w3schools.com/jsref/event_onclick.asp], which should say Save, Undo and Clear Map and call the functions saveToDb(), undo() and clearMap() respectively.

Add saveToDb(), undo() and clearMap() to your code as empty functions (i.e. functions that don’t do anything yet)

Working with elevation data

As we have discussed in the lecture, it is quite unusual to work with raster data (such as elevation data) in the browser for a number of reasons. However, it is actually quite possible to do so as long as you are not covering too large an area, which makes a small Island like Barra perfectly suited to this type of project!

Before we go any further, let’s refresh our minds about the properties of the elevation data object:

Property Description
data.tl The coordinates of the top left corner of the dataset
data.bl The coordinates of the bottom left corner of the dataset
data.tr The coordinates of the top right corner of the dataset
data.br The coordinates of the bottom right corner of the dataset
data.resolution The resolution of the dataset in coordinate units (metres)
data.proj The Proj4 string for the projection of the dataset (osgb)
data.getWidth() A function that returns the width of the dataset
data.getHeight A function that returns the height of the dataset
data.data A 2D Array containing elevation data in the form data.data[x][y]

As we also know from the lecture, there is a bit of maths to do in order to be able to relate WGS84 (longitude, latitude) coordinates to the corresponding location in a 2D array of elevation data. It works like this:

Spherical (WGS84) Coordinates ↔ Projected (OSGB) Coordinates ↔ Array Position (Image)

So to go from spherical (latitude longitude) coordinates to an array position, you need to first project the data, then work out the corresponding array position. Conversely, to go from an array position to a spherical coordinates, you first have to work out the projected coordinates of the location, then transform them to the corresponding longitude, latitude position.

Re-read the paragraph and make sure that you understand it before doing the next bit!!

In order to acheive this, we firstly need to think way back to week 2 as we need need to build a Proj4js object to transform between the Coordinate Reference System used by Leaflet (WGS84) and the one used by our dataset (The British National Grid). Fortunately, the latter is defined for us in our data object that is contained within barra.js. It is accessible using the .proj property of data.

Add a global variable called transformer

In initMap(), initialise transformer with a Proj4js object capable of transforming between "+proj=longlat +datum=WGS84 +no_defs" and data.proj

Here are a set of functions to complete these various conversions

/**
 * convert osgb (projected) coordinates to image (array) 
 */
function osgb2image(coord) {
  return [ 
    parseInt((coord[0] - data.bl[0]) / data.resolution), 	
    (data.getHeight() - parseInt((coord[1] - data.bl[1]) / data.resolution)) - 1 
  ];
}

/**
 * convert image (array) coordinates to osgb (projected)
 */
function image2osgb(px) {
  return [
    data.bl[0] + (px[0] * data.resolution), 
    data.bl[1] + ((data.getHeight() - px[1]) * data.resolution)
  ];
}

/**
 * transform osgb (projected) coordinates to wgs84 (spherical)
 */
function osgb2wgs84(osgb) {
  return transformer.inverse(osgb);
}

/**
 * transform wgs84 (spherical) coordinates to osgb (projected)
 */
function wgs842osgb(lngLat) {
	
}

/**
 * Convert from image (array) coordinates to osgb (projected) and then wgs84 (spherical)
 */
function image2wgs84(px) {
  return osgb2wgs84(image2osgb(px));
}

/**
 * convert from wgs84 (spherical) to osgb (projected) to image (array) coordinates
 */
function wgs842image(lngLat) {
 
}

Add the above functions to your code, filling in the blanks as you go so that they all have return statements (this might be helpful…).

Once you have done that, we need to check that our maths is correct. To do this we will add a test function to our code:

/**
 * Test all of the transformation functions
 */
function TEST_transforms(arrayPos) {
  
  // log the array location
  console.log(arrayPos);
  
  // calculate and log the corresponding osgb location
  var projectedPos = image2osgb(arrayPos);
  console.log(projectedPos);
  
  //calculate and log the corresponding wgs84 location
  var sphericalPos = osgb2wgs84(projectedPos);
  console.log(sphericalPos);
  
  //back to osgb
  var projectedPos2 = wgs842osgb(sphericalPos);
  console.log(projectedPos2);
  
  //back to array
  console.log(osgb2image(projectedPos2));
}

This simply takes in an array position, converts it to projected (osgb) coordinates, then to spherical (wgs84) coordinates, then back to projected, then back to array. If it ends up where it started, it works!

Add the above function to your code and then add the snippet below to initMap() in order to test your maths

// test that the transformations are working 
// this passes the middle cell in the array, but any valid array coordinates will do
console.log( TEST_transforms([data.getWidth()/2, data.getHeight()/2]) );

If you have done it all correctly, it should print out this (you can tell that it works, because it runs each of the functions in turn and gets back top where it started):

Array [ 139, 162 ]
Array [ 67701.20199999999, 801531.428 ]
Array [ -7.472784116437587, 56.98501270121852 ]
Array [ 67701.2026762822, 801531.4277881242 ]
Array [ 139, 162 ]

If that works then great job! We are ready to add some interactivity to the map…

Part 2

Route Calculation with the astar library

Preparing the astar object

In order to be able to use the astar library, we need to do a little preparation by creating a Graph object (which will hold our elevation data).

Declare a global variable called graph

Add the following snippet to initMap() to initialise a Graph object with our elevation data:

// build the graph for the astar library
graph = new Graph(data.data);

Adding a click listener

Now we have all of the building blocks in place for a raster-based web GIS, we can start adding some functionality:

Add a click listener for the map (at an approproate place in your code) that calls an anonymous function

Inside the function, create a variable a and populate it with the array coordinates that correspond to the coordinates of the click event like so:

// get the array location of the click (remember to reverse the coordinates!)
var a = wgs842image([e.latlng.lng, e.latlng.lat]);

Then, add the following if statement to only allow points that are inside the extent of the dataset and have a non-zero value in the dataset (the astar library treats 0 values as barriers):

// validate the click location
if (a[0] > 0 &&	a[1] > 0 && 			//reject any points outside the dataset
    a[0] < data.getWidth() && 	
    a[1] < data.getHeight() && 
    data.data[a[0]][a[1]] > 0) {	// reject and points with value 0 (barriers)

} else {
  alert("Sorry, I can't go there");
}

Note the use of the && (and) operator to chain multiple conditions together in the if statement:

If the click location has passed validation, we are going to ‘snap’ our point to the nearest location in the dataset (the bottom left corner of a cell in the raster). This is important as if we don’t do this, the location won’t be exactly on the route. To do this, you simply need to convert the wgs84 click coordinates to an array position, and then back to wgs84 again:

Add the following snippet to your code:

// snap the location to the dataset
var snap = image2wgs84(wgs842image([e.latlng.lng, e.latlng.lat]));

Now, push the location to the points array

Then, construct a L.marker and push it to the markers array and the map - remember that you will need to convert the longitude,latitude coordinate to latitude,longitude for the L.marker constructor!

Finally, add a call to a function called getRoute() and define that function in your code

Calculating the route

Okay so now we have a click listener that adds a new coordinate to an array called points and then calls a function called getRoute() each time a valid click is made on the map. Clearly, the first time the map is clicked there will only be one coordinate pair in the points array, meaning that we cannot draw a route (as a route would require at least two points…). Therefore the first thing that we need to do is make sure that the function only runs if there are two or more elements in the points array.

Add an if statement to perform this test

The next job will be to loop through each pair of points and get the path between them using the getPath() function, adding the results to an array called routes.

Add the following loop to your code and create the getPath() function

// get new route between all points
for (var i=0, route=[]; i < points.length-1; i++) {
  route.push(getPath(points[i], points[i+1]));
}

As you can see, this loop declares the route array as well as the loop control variable (i) and then runs until i < points.length-1. This is because in each iteration of the loop it creates a path betwen the current point (points[i]) and the next one (points[i+1]), meaning that it would break if it ran on the last point in the array (as there would be no ‘next’ point to which you can calculate a path).

Populate the getPath() code with this function, adding the missing code to startPoint, endPoint and return turf.lineString( ) (Hint: printing p1, p2 and path to the console might help):

/**
 * Get the path between two points as turf linestring
 */
function getPath(p1, p2) {

  // convert the wgs84 coordinates to array coordinates
  var startPoint = ;
  var endPoint = ;

  // get the locations from the graph.grid object (from the astar library)
  var start = graph.grid[startPoint[0]][startPoint[1]];
  var end = graph.grid[endPoint[0]][endPoint[1]];

  // get the route using the astar library
  var result = astar.search(graph, start, end);

  // check that it worked...
  if (result.length > 1) {

    // convert from array locations to wgs84 coordinates and return 
    // init the path array with start location
    for (var i = 0, path = [p1]; i < result.length; i++) {
      path.push(image2wgs84([ result[i].x, result[i].y ]));
    }

    // return the route as a linestring
    return turf.lineString(    );

  // ...otherwise alert and undo
  } else {
    alert("Sorry, I can't get there from here...");
    undo();
  }
}

As you can see, the above code makes use of the astar library by converting the two longitude, latitude cordinate pairs (p1 and p2) to array cordinates, using the astar object to calculate the route and then converting each point in the route back to longitude, latitude coordinates before returning them in the form of a GeoJSON LineString (with a little help from turf).

Now, if you look back to getRoute() you will see that the path between each pair of clicks (representing each section of the total route) is calculated using getPath() and stored in an array called route. Once all of the sections have been completed, this array can be converted to a featureCollection using turf and then added to the map using an L.geoJson() constructor in the usual way.

Add the final two steps to getRoute():

// convert the resulting route array into a feature collection 
routeCollection = turf.featureCollection(route);

// convert to leaflet GeoJSON object and add to the map
geojson = L.geoJson(routeCollection, {
  style: {
    color: 'red', 
    weight: 12, 
    opacity: .7
  }
}).addTo(map);

One last thing…

Have a good look at your getRoute() code. You will see that it is actually adding a new geojson object every single time you click. Clearly this is not ideal, as we only want one route drawn on the map at once!

Add the below snippet as the first line in getRoute() to remove any pre-existing routes from the map before calculating and adding the new one

// remove the route from the map if already there
if (geojson) map.removeLayer(geojson);

Making the buttons work

Now you should have a functional map, which allows you to draw routes using the astar algorithm - which is very cool!

Now the final touch is to make the Save, Undo and Clear Map buttons actually do something by filling in the saveToDb(), undo() and clearMap() functions…

Fortunately all three of these are super easy. Let’s go in reverse order…

ClearMap()

Add the following steps to the function:

  1. If a geojson variable is initialised, remove it from the map

  2. Loop through the markers array and remove all of them from the map

  3. Set markers and points to be empty arrays

undo()

Add the following snippet to the function:

/**
 * Undo the last click
 */
function undo() {

  // make sure there is something to undo
  if (markers.length > 0) {

    // remove the last point from the points array
    points.pop();

    // remove the last marker from the markers array
    map.removeLayer(markers.pop());

    // recalculate the route if there are enough points
    if (markers.length > 1) getRoute();

  } else {
    alert("Nothing to undo!")
  }
}

Note the use of .pop() to remove the last element from an array, and the use of the inline if statement (with no curly braces), which is possible because there is only one line of code to run if the condition evaluates to true (getRoute();).

saveToDb()

Add the following steps to the function:

  1. Check that there is something to actually upload (by testing if geojson is initialised)
  2. As with last week, use a myDb.push() to load the geojson data into the database
  3. If the upload is successful, run clearMap()

Finally

Have a play and see if the buttons all work and data is uploading to your database! If so, then CONGRATULATIONS - you’re done!!

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.