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()
, initialisepoints
andmarkers
as empty arraysChange 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: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: © <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 yourinitMap()
functionAdd an
addInfoBox()
function to your code (as in week 3 and week 4), and a corresponding function call at the appropriate place ininitMap()
:
/**
* 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 saySave
,Undo
andClear Map
and call the functionssaveToDb()
,undo()
andclearMap()
respectively.Add
saveToDb()
,undo()
andclearMap()
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()
, initialisetransformer
with a Proj4js object capable of transforming between"+proj=longlat +datum=WGS84 +no_defs"
anddata.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 aGraph
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 functionInside 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 (theastar
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
arrayThen, construct a
L.marker
and push it to themarkers
array and the map - remember that you will need to convert thelongitude,latitude
coordinate tolatitude,longitude
for theL.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 tostartPoint
,endPoint
andreturn turf.lineString( )
(Hint: printingp1
,p2
andpath
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:
-
If a geojson variable is initialised, remove it from the map
-
Loop through the
markers
array and remove all of them from themap
-
Set
markers
andpoints
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 inlineif
statement (with no curly braces), which is possible because there is only one line of code to run if the condition evaluates totrue
(getRoute();
).
saveToDb()
Add the following steps to the function:
- Check that there is something to actually upload (by testing if
geojson
is initialised) - As with last week, use a
myDb.push()
to load thegeojson
data into the database - 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!!