Water distribution in refugee camps is a critical part of good camp management and is mandated by the SPHERE standards, which sets standards for humanitarian action to promote quality and accountability. The SPHERE handbook sets forth standards such as how much water the resident of a camp should have access to and a maximum distance that one should have to travel to access water: less than 500 meters (see the handbook chapter on water here). A practical GIS analysis of water accessibility in a refugee camp, then, is ensuring that all portions of the camp are within 500 meters of a water distribution point of some sort.
In this exercise we're going to build an interactive map that allows the user to determine which points fall within a specified distance or radius by clicking a map. This type of analysis is also known as buffer analysis. The goal is to provide a tool that camp managers could use to identify parts of a camp that don't have adequate access to water. There are multiple ways to do this; we will be using data from OpenStreetMap (or "OSM"), Mapbox, and turf.js. Our end map will allow a user to click anywhere on a map and find all the water distribution points within a 500 meter radius of the point clicked. To do this we will:
- Add data on water distribution points to a map.
- Create a search radius (buffer) around any clicked events
- Join water point data to buffered polygon and return ‘confirmed’ water points within the selected search radius.
What you will need to finish this tutorial:
- A code editor,
- an OpenStreetMap account,
- a Github account for hosting website and data, and
- a Mapbox account for styling basemaps.
Once you have completed this tutorial you will have a working web map. However, I would like you to think about ways the map could be changed to improve the goal of helping a user identify parts of a camp that do not have appropriate access to water and then make those changes to your map. You will submit a link to your Github repo and the working website that it hosts.
The data for this exercise come from OSM. The DATAREADME.md
file in the data folder introduces the structure of OSM data, how to search OSM for data, downloading data, and then simplifying it. This is a tutorial in its own right that will give you the basic skills to the vast range of data available in OSM.
ADD LINK.
Open a text editor and create a file called index.html. Set up the document by copying and pasting this template code below into your new HTML file.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Mapping Water Points</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<script src='https://api.mapbox.com/mapbox-gl-js/v1.11.1/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v1.11.1/mapbox-gl.css' rel='stylesheet'/>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@5/turf.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
#instructions {
position: absolute;
top: 20px;
left: 20px;
background-color: #fff;
padding: 5px 20px 5px 20px;
border-radius: 10px;
font-family: Lato, sans-serif;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="instructions">
<h2>🚰 Instructions</h2>
<p>Click anywhere on the map to see water sites within a 500m radius. Stand alone water sites will be highlighted in red.</p>
</div>
<script>
// Data showing water points - replace the dummy URL with your own.
var url = 'https://raw.githubusercontent.com/USERNAME/'
mapboxgl.accessToken = 'Your Access Token Goes Here!';
var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/satellite-v9', // imagery basemap - you may choose something else
center: [36.3295, 32.2963], // starting position at Zaatari Camp
zoom: 14, // starting zoom
});
map.on('load', function () {
// Map layers go here!
});
//Click event goes here!
//makeRadius function goes here!
//spatialJoin function goes here!
</script>
</body>
</html>
The variable url
stores a link to the OSM data you created following the instructions in the data folder (LINK). These data should be a feature collection with information about the location of each water point. The data also contains a field type
which has one of three unique values: Free_standing
, Attached_to_WASH_centre
, or Oxfam_water_storage
. When setting the the variable, be sure to point to the page containing raw data and not simply to the Github page that displays the data!
The css #instructions
defines the properties for the instruction pane on the map: feel free to play with the design.
Without an access token, the rest of the code will not work. Find your access token on your Access tokens page or the main page you sign into your Mapbox account.
Copy and paste your access token into the code that reads:
mapboxgl.accessToken = 'Your Access Token Goes Here!';
We need to add three layers to the map - one to display all the water points, one to store water points within the radius, and one for drawing the search radius itself. We will use map.addLayer()
to add each layer to the map and to define how the data is styled. All the layers will be initialized inside of a load event to make sure that the map has finished loading before the layers are added.
First, add a layer that displays all the water points. Remember this goes inside of the 'load' event, underneath where it's marked //Map layers go in here
.
//Map layers go in here!
map.addLayer({
id: 'OSM-water',
source: {
type: 'geojson',
data: url
},
type: 'circle',
paint: {
'circle-color': '#83e8fc',
'circle-radius': 3,
'circle-opacity': 1,
'circle-stroke-width': 1,
'circle-stroke-color': '#0557fa',
}
});
There are a few things happening here. We've given this layer a unique name or id
. In this case it's 'OSM-water'. We've set the type
and data
to be the URL that points to a geojson dataset. Then, we've symbolized the points. You may want to play with the design choices made. For more documentation about the various properties you can use to symbolize point data, see the Mapbox documentation here.
At this point, your map should look something like this:
Next, add a layer to store the information on confirmed water points (those within the selected radius). This layer’s source data is set to an empty feature collection ([]
). When a user clicks on a region, any confirmed water points will be added to this layer. For this layer, set the layer type
to circle
and change the color to differentiate it from the blue symbols that are already shown. Remember this all goes inside of the 'load' event.
// When the map has finished loading, add a new layer that will be empty
// at first, but will eventually house our confirmed water sites.
map.addLayer({
id: 'selected-water',
source: {
type: 'geojson',
data: { "type": "FeatureCollection", "features": [] }
},
type: 'circle',
paint: {
'circle-color': 'red',
'circle-radius': 3
}
});
You may want to use different symbols or play with the design.
Another way to symbolize selected points would be to use an icon instead of a color change. Mapbox Maki icons are a handy design element available in Mapbox: see them all and the documentation here. The Mapbox Maki icon water-15
could be a good one to use. Icons could be loaded here a few ways. You could load the icons to your Mapbox basemap as a custom icon or "sprite", which is a collection of icons (see documentation here). To see an example of how this is used and the code necessary, see the Mapbox Selecting within a distance tutorial, which uses type: 'symbol'
and layout
instead of the type: 'circle'
and paint
we're using in the code below. You can also add an image using map.addImage()
: see Mapbox documentation here for that.
Next, add a layer to draw a search radius on the map (still within the load event). This layer’s source data is also set to an empty feature collection but will soon be filled with information about our search radius. Remember this goes inside of the 'load' event.
// Draw the search radius on the map
map.addLayer({
id: 'search-radius',
source: {
type: 'geojson',
data: { "type": "FeatureCollection", "features": [] }
},
type: 'fill',
paint: {
'fill-color': '#fcf683',
'fill-opacity': 0.5
}
});
Depending on what basemap and data symbology you've chosen, you may want to change the paint
specifications above for the color, opacity, or other characteristics of the buffer circle. For more information on paint properties you can use see the Mapbox documentation.
We need to create search radius around any point on the map that the user clicks. To do this, we need the coordinate information for each click event. This will go under the comment code, //Click event goes here!
Create a click event and store the information about the clicked coordinates in a variable called eventLngLat
. Console.log(eventLngLat)
to view the clicked coordinates in your browser. Find out more about different click events in the Mapbox documentation here. This event should go outside of the load event.
map.on('click', function(e) {
var eventLngLat = [e.lngLat.lng, e.lngLat.lat];
console.log(eventLngLat)
});
After doing this, comment out console.log(eventLngLat)
when finished.
Now we will use the turf.js library to transform our event coordinates into point features and to create a buffer polygon around each point. This will go under the comment code //makeRadius function goes here!
.
Initialize a function called makeRadius()
and give it two parameters: lngLatArray
and radiusInMeters
, like so:
function makeRadius(lngLatArray, radiusInMeters){}
Before we create a buffer, we need to turn our clicked location coordinates into a point feature. Inside of the function add a new variable called point
and set it to turf.point(lngLatArray)
, like so:
var point = turf.point(lngLatArray)
Now that our coordinates are stored as a point feature, we can use turf's buffer function to make a polygon around the clicked point. Add the new buffered variable below the point variable and return it with the following code.
var buffered = turf.buffer(point, radiusInMeters, { units: 'meters' });
return buffered;
Once you've done all of the above, the complete code for the makeRadius()
function should look like:
function makeRadius(lngLatArray, radiusInMeters) {
var point = turf.point(lngLatArray);
var buffered = turf.buffer(point, radiusInMeters, { units: 'meters' });
return buffered;
}
Use the makeRadius()
function to generate the search radius as a GeoJSON polygon by calling it within your 'click' event. This means it will go under var eventLngLat = [e.lngLat.lng, e.lngLat.lat];
. This is also where you commented out console.log(eventLngLat)
The function takes two arguments: a longitude and latitude array (eventLngLat
) and a search radius. We'll use SPHERE's define radius of 500 meters.
var searchRadius = makeRadius(eventLngLat, 500);
Set the search-radius source layer to our newly made radius feature. Below var = searchRadius
(in your click event), add the following:
map.getSource('search-radius').setData(searchRadius);
Save your code and open the HTML document in a browser. When you click on the map, it should look something like this.
In this step we are going to create a function that loops through all the features in our original GeoJSON data. Next, the function we will use turf.booleanPointInPolygon
to filter features that are both inside the buffered region and are confirmed landing sites. This code will go under the comment //spatialJoin function goes here!
function spatialJoin(sourceGeoJSON, filterFeature) {
// Need this line to specify the array since map.getSource in the click event doesn't do this.
sourceGeoJSON = map.querySourceFeatures(sourceLayer);
// Loop through all the features in the source geojson and return the ones that
// are inside the filter feature (buffered radius) and are confirmed landing sites
var joined = sourceGeoJSON.features.filter(function (feature) {
return turf.booleanPointInPolygon(feature, filterFeature) && feature.properties.type === 'Free_standing';
});
return joined;
}
There are a few things happening in the code here. the filter code above requires an array, such as [30, 10]
. And, unfortunately, the map.getSource
we used in the click event doesn't give our code access to the raw data so we use sourceGeoJSON = map.querySourceFeatures(sourceLayer);
here to make sure the array is loaded. For more information about querying external data source features see this Mapbox issue on Github.
The other thing we've done is to specify that the water points we want to highlight are freestanding. We do this by specifying the type
property in the data.
Within the click function, create a new variable called featuresInBuffer
and assign it the value of spatialJoin()
. Remember that the function takes two arguements: a GeoJSON and a filter feature. For this exercise, we are pointing to our source layer containing the GeoJSON and using the searchRadius
as the filter feature.
var featuresInBuffer = spatialJoin('OSM-water', searchRadius);
Set the selected-water
layer to our newly made featuresInBuffer
variable. Below var = featuresInBuffer...
, add the following:
map.getSource('selected-water').setData(turf.featureCollection(featuresInBuffer));
You're map should look something like this.
This tutorial is based on Mapbox's "Selecting within a distance (one to many)" tutorial. I've changed the use-case from finding alien landing sites to using real-world humanitarian data about water availability in refugee camps. I've also changed the code from using in-line GeoJSON to referencing data via a URL.