Congratulations on making your first interactive web map with Leaflet last chapter! Chapter 5 continues to build on Chapter 4 by introducing Leaflet interaction operators. Chapter 5 includes four lessons and ends with Activity 6 requiring you to implement pan, zoom, retrieve, and sequence on scaled proportional symbols.
- In Lesson 1, we introduce pseudocoding in support of scaling your proportional point symbols to your spatiotemporal dataset.
- In Lesson 2, we introduce the pan, zoom, and retrieve operators that are well-supported in Leaflet.
- In Lesson 3, we describe the more involved process for implementing the sequence controls to view your spatiotemporal data.
- In Lesson 4, we provide some background for implementing the remaining interaction operators introduced in class in a Leaflet slippy map. This material is for your reference only, as only pan, zoom, retrieve, and sequence are included in the Activity 6. However, we encourage you to return to this lesson as you develop more complex interactive maps.
After this chapter, you should be able to:
- Create proportional symbols on your Leaflet map based on your spatiotemporal data
- Implement pan, zoom, and retrieve with styled popups
- Sequence through your spatiotemporal data with step and slider UI controls
For Activity 5, you used the Leaflet pointToLayer
function to convert the point features in your custom GeoJSON file into circle markers placed atop a slippy basemap of your choice. While this code uses the spatial data in your GeoJSON, it does not yet utilize the temporal sequence of attributes you collected for your Leaflet map.
The next step in developing your Leaflet map is to scale dynamically the radius of those circle markers based on your GeoJSON file, turning them into proportional symbols that represent the attribute data through the visual variable size. Although we continue to work with the MegaCities.geojson dataset in the following examples, you should apply these instructions to your own dataset.
Before starting on the proportional symbols, take a second to think through the task at hand. Pseudocoding describes the higher-level outlining of computational steps necessary to perform the task. Pseudocoding before coding clarifies the logic behind potential coding solutions, breaking down big, abstract problems into small, manageable pieces. You then can use the console to confirm that your code is "working" at each of these smaller stages to reduce unexpected bugs. Example 1.1 provides one possible pseudocoding outline for implementing proportional symbols; as with all programming, there always are a number of sometimes equally viable pseudocoded solutions for achieving a goal.
//GOAL: Proportional symbols representing attribute values of mapped features
//STEPS:
//Step 1. Create the Leaflet map--already done in createMap()
//Step 2. Import GeoJSON data--already done in getData()
//Step 3. Add circle markers for point features to the map--already done in AJAX callback
//Step 4. Determine the attribute for scaling the proportional symbols
//Step 5. For each feature, determine its value for the selected attribute
//Step 6. Give each feature's circle marker a radius based on its attribute value
Note that in the pseudocode above, we already have accomplished the first three steps in Chapter 4. Thus, we can assess that we are about half way to completing the proportional symbol scaling. Accordingly, pseudocoding also helps to assess workload and progress on a development project, such as your final project.
As we complete the remaining three steps, we leave our pseudocode in our script as comments describing the tasks within it.
While we could keep adding script to the AJAX callback function to create the proportional symbols, the formatting might get a little unwieldy as we lengthen the anonymous callback function. Instead, let's modularize our code by creating a new function that creates proportional symbols, called from within the callback (Example 1.2).
//Step 3: Add circle markers for point features to the map
function createPropSymbols(data){
//create marker options
var geojsonMarkerOptions = {
radius: 8,
fillColor: "#ff7800",
color: "#000",
weight: 1,
opacity: 1,
fillOpacity: 0.8
};
//create a Leaflet GeoJSON layer and add it to the map
L.geoJson(data, {
pointToLayer: function (feature, latlng) {
return L.circleMarker(latlng, geojsonMarkerOptions);
}
}).addTo(map);
};
//Step 2: Import GeoJSON data
function getData(){
//load the data
$.getJSON("data/MegaCities.geojson", function(response){
//call function to create proportional symbols
createPropSymbols(response);
});
};
Currently, the code within createPropSymbols()
applies the same, static options to create the L.circleMarker
layer as the Using GeoJSON with Leaflet tutorial from Chapter 4. Note that one of these options is the radius of the circle, which we can resize dynamically in the createPropSymbols()
function to scale the point symbols.
Step 4 of our pseudocode determines the attribute for scaling the proportional symbols. We describe how to sequence through all of your attributes in Lesson 3, but for now just pick one attribute and assign it to a local attribute
variable to hold the name of the GeoJSON attribute used for dynamic scaling (e.g., "Pop_2015"
in Example 1.3).
//Example 1.2 line 1...Step 3: Add circle markers for point features to the map
function createPropSymbols(data){
//Step 4. Determine the attribute for scaling the proportional symbols
var attribute = "Pop_2015";
Step 5 is a little trickier, iterating through each feature in your GeoJSON to get its value for the Pop_2015
attribute. The pointToLayer
anonymous function already iterates through each feature to turn the marker into a circle. Thus, we can determine the value of each feature's Pop_2015
attribute within the pointToLayer
function (Example 1.4).
//Example 1.3 line 1...Step 3: Add circle markers for point features to the map
function createPropSymbols(data){
//Step 4: Determine which attribute to visualize with proportional symbols
var attribute = "Pop_2015";
...
//Example 1.2 line 13...create a Leaflet GeoJSON layer and add it to the map
L.geoJson(data, {
pointToLayer: function (feature, latlng) {
//Step 5: For each feature, determine its value for the selected attribute
var attValue = Number(feature.properties[attribute]);
//examine the attribute value to check that it is correct
console.log(feature.properties, attValue);
//create circle markers
return L.circleMarker(latlng, geojsonMarkerOptions);
}
}).addTo(map);
};
In Example 1.4, we assign the attribute value for a specific feature to the new variable attValue
, using JavaScript's Number()
method to force-type any string values to numbers, which is the required data type for the radius
option. If you use console.log()
to look at each feature
object, you will see that the attribute we want lies within the feature's properties
object. Since our chosen attribute name ("Pop_2015"
) is stored in the attribute
variable, we use this variable to access the value of that attribute in the feature.properties
object.
Before moving onto the next step in the pseudocode, use a console.log
statement to compare each feature's properties
object with the value assigned to attValue
(Figure 1.1), reducing the possibility of an error at this stage.
Figure 1.1: The Firefox console showing each feature's properties object followed by the Pop_2015
value
Finally, Step 6 in our pseudocoding sets the circle marker radius based on the selected attribute. Proportional symbol maps represent each feature's attribute value as the area of that feature's circle. Thus, we must derive our symbol radius from the area of the circle.
However, this calculation results in mathematical scaling, or a direct relationship between the feature attribute to the symbol area. We know that we want to use perceptual scaling to account for our systematic underestimation of sizes as they grow larger using the Flannery scaling ratio.
Example 1.5 illustrates the steps needed to implement Flannery scaling for our symbols. Because Flannery scaling requires the minimum value of our data, we first call the new function calcMinValue()
from our callback function, passing in our response
from getJSON()
. In calcMinValue()
, we loop through our array of cities, adding all of our data values to a single flat array and calculating the minimum. We then call a new function calcPropRadius()
function from from our calcPropSymbols()
function, using the Flannery formula to calculate the radius based on each cities' population. We use a couple new methods here, including Math.min()
, String()
, and destructuring()
. Refer to the W3Schools or Mozilla Developer Network documentation for additional information on these methods..
//declare map variable globally so all functions have access
var map;
var minValue;
//step 1 create map
function createMap(){
//create the map
map = L.map('map', {
center: [0, 0],
zoom: 2
});
//add OSM base tilelayer
L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>'
}).addTo(map);
//call getData function
getData(map);
};
function calculateMinValue(data){
//create empty array to store all data values
var allValues = [];
//loop through each city
for(var city of data.features){
//loop through each year
for(var year = 1985; year <= 2015; year+=5){
//get population for current year
var value = city.properties["Pop_"+ String(year)];
//add value to array
allValues.push(value);
}
}
//get minimum value of our array
var minValue = Math.min(...allValues)
return minValue;
}
//calculate the radius of each proportional symbol
function calcPropRadius(attValue) {
//constant factor adjusts symbol sizes evenly
var minRadius = 5;
//Flannery Apperance Compensation formula
var radius = 1.0083 * Math.pow(attValue/minValue,0.5715) * minRadius
return radius;
};
//Step 3: Add circle markers for point features to the map
function createPropSymbols(data){
//Step 4: Determine which attribute to visualize with proportional symbols
var attribute = "Pop_2015";
//create marker options
var geojsonMarkerOptions = {
fillColor: "#ff7800",
color: "#fff",
weight: 1,
opacity: 1,
fillOpacity: 0.8,
radius: 8
};
L.geoJson(data, {
pointToLayer: function (feature, latlng) {
//Step 5: For each feature, determine its value for the selected attribute
var attValue = Number(feature.properties[attribute]);
//Step 6: Give each feature's circle marker a radius based on its attribute value
geojsonMarkerOptions.radius = calcPropRadius(attValue);
//create circle markers
return L.circleMarker(latlng, geojsonMarkerOptions);
}
}).addTo(map);
};
//Step 2: Import GeoJSON data
function getData(map){
//load the data
$.getJSON("data/MegaCities.geojson", function(response){
//calculate minimum data value
minValue = calculateMinValue(response);
//call function to create proportional symbols
createPropSymbols(response);
});
};
$(document).ready(createMap);
The areas of the circles on the map are now proportional to our data (Figure 1.2)!
With the proportional symbols correctly scaling based on your GeoJSON file, we now can make your slippy map interactive! The first two of these operators—zoom and pan—are automatically implemented by default on any Leaflet map. Zoom describes a change in map scale, typically accompanied with a change in map detail, and pan describes a change in the map centering.
The zoom operator has a high level of flexibility by default in Leaflet: it can be performed with the zoom control buttons on the map, with a mouse wheel, by pinching on a touch-enabled device, by double-clicking on the map, by holding the Shift key while clicking and dragging the mouse across the map (i.e., a rubberband zoom), or by using the + and - buttons on a keyboard. However, the zoom operator has limited freedom, constrained to 20 interlocking scales with the current scale defined by Leaflet's the zoom
property introduced last chapter. The strategy of 20 preset scales enables a tileset to approximate a user experience of a "map of everywhere", as each additional scale would require additional processing and storage.
The pan operator is less flexible by default in Leaflet, performed by clicking and dragging the map with a mouse (grab & drag), dragging the map with a finger on a touch-enabled device, or using the arrow keys on a keyboard. However, the pan operator is completely free (hence the term "slippy" map), enabling the map to be recentered on any location.
Leaflet's zoom and pan interactions can be modified from the default behavior using the L.map
object's interaction options. For the zoom operator, the touchZoom
, scrollWheelZoom
, doubleClickZoom
, boxZoom
, zoomControl
, and keyboard
options can each be set to false
in the map options object to disable them when the map is instantiated, constraining zoom interaction. The pan operator just has one dedicated map option—dragging
—which may be set to false to disable panning except by keyboard (setting keyboard
to false
disables both zooming and panning by keyboard). Unlike zoom, pan does not come with its own UI control in Leaflet, although there are several plug-ins that implement additional Leaflet UI controls for panning and zooming.
Retrieve describes the request for details on demand about a map feature of interest. Retrieve can be implemented in multiple ways, including a dynamic label on hover (not mobile-friendly), an information window opening near the feature within the map, or an information panel docked to the side of the map. Because the UI controls for our Leaflet map are relatively simple, we recommend implementing retrieve with the mobile-friendly information panel using the Leaflet Popup
class.
You already used Leaflet popups within the onEachFeature
function in the Using GeoJSON with Leaflet tutorial. Since the pointToLayer
function also iterates through every feature, we can use the same logic to bind popups to each L.circleMarker
layer created in the pointToLayer
anonymous function in Example 1.5. To avoid having too much code within the pointToLayer
anonymous function, let's move the code for creating the circle markers (including the attribute
variable and options
variable) to a separate function called pointToLayer()
so that we can add retrieve popups directly after creating each new circle marker (Example 2.1). Replace the anonymous function within the createPropSymbols()
function with a call to the new pointToLayer()
function.
//function to convert markers to circle markers
function pointToLayer(feature, latlng){
//Determine which attribute to visualize with proportional symbols
var attribute = "Pop_2015";
//create marker options
var options = {
fillColor: "#ff7800",
color: "#000",
weight: 1,
opacity: 1,
fillOpacity: 0.8
};
//For each feature, determine its value for the selected attribute
var attValue = Number(feature.properties[attribute]);
//Give each feature's circle marker a radius based on its attribute value
options.radius = calcPropRadius(attValue);
//create circle marker layer
var layer = L.circleMarker(latlng, options);
//build popup content string
var popupContent = "<p><b>City:</b> " + feature.properties.City + "</p><p><b>" + attribute + ":</b> " + feature.properties[attribute] + "</p>";
//bind the popup to the circle marker
layer.bindPopup(popupContent);
//return the circle marker to the L.geoJson pointToLayer option
return layer;
};
//Add circle markers for point features to the map
function createPropSymbols(data, map){
//create a Leaflet GeoJSON layer and add it to the map
L.geoJson(data, {
pointToLayer: pointToLayer
}).addTo(map);
};
You then can use JavaScript String methods to format the information in the popup, making it more human-readable and matching your map style (Example 2.2).
//build popup content string starting with city...Example 2.1 line 24
var popupContent = "<p><b>City:</b> " + feature.properties.City + "</p>";
//add formatted attribute to popup content string
var year = attribute.split("_")[1];
popupContent += "<p><b>Population in " + year + ":</b> " + feature.properties[attribute] + " million</p>";
Figure 2.1 previews the popup in the browser. Each popup is created by Leaflet using HTML <div>
elements. Accordingly, you can use the inspector to determine how to access and style the popup using CSS.
The popup content has the class name leaflet-popup-content
, so we can use this class in our style.css stylesheet to change the popup content style to our liking. For instance, Example 2.3 reduces the default margins of the <p>
elements within the popups.
.leaflet-popup-content p {
margin: 0.2em 0;
}
You also may want to offset the popup based on its radius
so that it does not cover the proportional symbol when activated, inhibiting reading of the original symbol size (Example 2.4). Do not offset it too far, however, as you will lose graphic association between the information window and the selected feature (Figure 2.3).
layer.bindPopup(popupContent, {
offset: new L.Point(0,-options.radius)
});
Now that you have a handle on using attribute data to dynamically symbolize map features and populate popups for a single attribute, we now can sequence across every attribute in the dataset. The sequence operator generates an ordered set of related map states displaying different information (i.e., the sequence) and then changes the currently shown state within the sequence. To implement sequence, we need to create custom UI controls that allow the user to step through the sequence. Let's think through the steps necessary to implement sequence with pseudocode (Example 3.1).
//GOAL: Allow the user to sequence through the attributes and resymbolize the map
// according to each attribute
//STEPS:
//Step 1. Create UI affordances for sequencing
//Step 2. Listen for user input via affordances
//Step 3. Respond to user input by changing the selected attribute
//Step 4. Resize proportional symbols according to each feature's value for the new attribute
There are a number of different types of UI affordances that can be used as controls. We have selected two types of tools with native HTML elements to implement: a slider widget and step buttons. Both allow the user to adjust the sequence in either direction (forward or backward). Note that these widgets work best for depictions of linear time rather than cyclical time, following a timeline metaphor rather than a clock metaphor.
Adding sequence flexibility requires that we provide consistent affordances for both UI solutions, such that when the user clicks a step button, the slider's position is updated accordingly, along with the map symbols. To keep track of the sequence order of our attributes, we need an array of all attribute names. We then can update the sequence using the index value of this array, incremented or decremented in response to step buttons or jumping to a new index position using the slider widget. When the user reaches the end of the sequence, the next step should take them back to the beginning of the sequence (and vice-versa for reverse order).
With these UI design requirements in mind, we can expand our pseudocode (Example 3.2).
//GOAL: Allow the user to sequence through the attributes and resymbolize the map
// according to each attribute
//STEPS:
//Step 1. Create slider widget
//Step 2. Create step buttons
//Step 3. Create an array of the sequential attributes to keep track of their order
//Step 4. Assign the current attribute based on the index of the attributes array
//Step 5. Listen for user input via affordances
//Step 6. For a forward step through the sequence, increment the attributes array index;
// for a reverse step, decrement the attributes array index
//Step 7. At either end of the sequence, return to the opposite end of the sequence on the next step
// (wrap around)
//Step 8. Update the slider position based on the new index
//Step 9. Reassign the current attribute based on the new attributes array index
//Step 10. Resize proportional symbols according to each feature's value for the new attribute
That's quite a few steps, so let's get started on the code!
Step 1 in the pseudocode is creating a slider widget. For now, we can place our sequence controls outside of the map in a separate HTML <div>
element with id #panel
beneath the #map
element in our index.html file (Example 3.3). We will move these onto the map in Chapter 06.
<body>
<div id="map"</div>
<div id="panel"</div>
Next, specify the position of the #panel
relative to the #map
using CSS in style.css (Example 3.4). Note that this type of a panel also can be used for the retrieve operator is an information panel is preferred over a information window due to a large amount of detail content.
#map {
height: 400px;
width: 80%;
display: inline-block;
}
#panel {
width: 16%;
padding: 20px;
display: inline-block;
vertical-align: top;
text-align: center;
line-height: 42px;
}
It makes sense to start a new function called createSequenceControls()
for creating our sequence controls. Since the controls need access to the GeoJSON data, call the createSequenceControls()
function from within the AJAX callback (Example 3.5). Within the function, we can make a simple slider using an HTML <input>
element with the type
attribute set to range
. We also give it a class name range-slider
to access the slider in our stylesheet and with jQuery.
//Step 1: Create new sequence controls
function createSequenceControls(){
//create range input element (slider)
$('#panel').append('<input class="range-slider" type="range">');
};
//Import GeoJSON data
function getData(map){
//load the data
$.ajax("data/MegaCities.geojson", {
dataType: "json",
success: function(response){
minValue = calcMinValue(response);
//add symbols and UI elements
createPropSymbols(response);
createSequenceControls();
}
});
};
Besides setting the type
attribute to range
, we also need to give our slider max
and min
values based on the number of attributes in the sequence. Since our MegaCities.geojson data has seven attributes, the min
value should be 0
(the index of the first attribute) and the max
value is 6
(the index of the seventh attribute). We also need to set an initial value using the value
attribute; if we want our slider to start at the first attribute in the sequence, value
should be 0
. The element's underlying value starts at whatever the value
attribute is set to, but changes when the user moves the slider. Thus, we use value
to reset the currently shown attribute in the sequence when the slider is moved. Finally, the slider's value increments or decrements by 1 with each sequence interaction, so we will set the step
attribute to 1
(Example 3.6).
//Example 3.5...create range input element (slider)
$('#panel').append('<input class="range-slider" type="range">');
//set slider attributes
$('.range-slider').attr({
max: 6,
min: 0,
value: 0,
step: 1
});
Figure 3.1 shows the resulting panel and slider created from the createSequenceControls()
function. The slider is centered horizontally in the panel by adding text-align: center;
to the #panel
styles in style.css (see Example 3.4).
Step 2 of the pseudocode creates the forward and reverse step buttons. For these, we can use HTML <button>
elements (Example 3.7).
//below Example 3.6...add step buttons
$('#panel').append('<button class="step" id="reverse">Reverse</button>');
$('#panel').append('<button class="step" id="forward">Forward</button>');
Give our buttons both a class
attribute ("step"
) and an id
attribute ("forward"
and "reverse"
). We can use the class
attribute to style both buttons together, and the id
attribute to style them individually and to attach individual event listeners. Figure 3.2 shows the resulting step buttons.
Normally sequence UI controls use icons rather than words. You can find and download open source icons at The Noun Project and save them as raster images in your unit-2/img folder. We then can references these icons for our buttons using jQuery's html()
method (Example 3.8).
//replace button content with images
$('#reverse').html('<img src="img/reverse.png">');
$('#forward').html('<img src="img/forward.png">');
Finally, we can adjust the sequence UI styles to make the controls more usable (Example 3.9).
#panel {
width: 25%;
padding: 20px;
display: inline-block;
vertical-align: top;
text-align: center;
line-height: 42px;
}
.range-slider {
width: 55%;
}
.step {
width: 20%;
}
.step img {
width: 100%;
}
#forward {
float: right;
}
#reverse {
float: left;
}
Figure 3.3 shows the styled sequence UI controls.
Step 3 of our sequence pseudocode creates an array to hold all of the attribute names, which defines the sequence order. Because this step requires some processing of the GeoJSON data, call a new function processData()
within the AJAX callback function. We will use this array in our code for other purposes, so define a variable called attributes
to store the array returned by processData()
(Example 3.10). We then add this attributes
array as a parameter to the createPropSymbols()
and createSequenceControls()
functions defined earlier for subsequent use.
//Example 3.5 line 8...load the data
$.ajax("data/MegaCities.geojson", {
dataType: "json",
success: function(response){
//create an attributes array
var attributes = processData(response);
minValue = calcMinValue(response);
createPropSymbols(response, attributes);
createSequenceControls(attributes);
}
});
We then create and return the array within the processData()
function. Start with an empty array, then loop through the attribute names from the properties
object of the first feature in the dataset, and push each attribute name that contains the characters "Pop"
into our array. Your dataset likely uses other attributes keys than those in MegaCities.geojson, perhaps only a given year (e.g., "1985"
instead of "Pop_1985")
, so reformat your attribute keys to include a common prefix string for each of the attributes you wish to include in the spatiotemporal sequence. After successfully building the array, return
the array to the callback function (Example 3.11).
//Above Example 3.10...Step 3: build an attributes array from the data
function processData(data){
//empty array to hold attributes
var attributes = [];
//properties of the first feature in the dataset
var properties = data.features[0].properties;
//push each attribute name into attributes array
for (var attribute in properties){
//only take attributes with population values
if (attribute.indexOf("Pop") > -1){
attributes.push(attribute);
};
};
//check result
console.log(attributes);
return attributes;
};
Figure 3.4 shows the console.log()
statement of the the the attributes
array, confirming that the attribute names are in the correct order for the sequence.
Step 4 of our pseudocode assigns the current attribute based on an index value, initially set to 0
but incremented by our sequence UI controls. As the code stands, our current attribute is assigned a static string value within the pointToLayer()
function (initially defined in Example 2.1). In order to change this string to an array value, we need to pass our attributes
array into our pointToLayer()
function called by the L.geoJson() pointToLayer
option in the createPropSymbols()
function.
Currently, we assign the function name to the the L.geoJson() pointToLayer
option, and the parameters feature
and latlng
are automatically passed to pointToLayer()
. If we want to pass a third parameter—our attributes
array—we need to change our strategy. Instead of passing the function name pointToLayer
(therefore referencing this function directly), we can assign an anonymous function to the option, within which to properly call the pointToLayer()
function with the parameters of our choice.
Recall that the pointToLayer
option expects us to return a Leaflet layer to it, so we must add our pointToLayer()
function call as part of a return
statement to pass each circle marker through to the L.geoJson()
method (Example 3.12).
//Example 2.1 line 34...Add circle markers for point features to the map
function createPropSymbols(data, attributes){
//create a Leaflet GeoJSON layer and add it to the map
L.geoJson(data, {
pointToLayer: function(feature, latlng){
return pointToLayer(feature, latlng, attributes);
}
}).addTo(map);
};
Then we can assign our first attribute
to symbolize as the attributes
array value at index 0
(Example 3.13).
//Example 2.1 line 1...function to convert markers to circle markers
function pointToLayer(feature, latlng, attributes){
//Step 4: Assign the current attribute based on the first index of the attributes array
var attribute = attributes[0];
//check
console.log(attribute);
Not only have we successfully assigned the first value in our array to the attribute
variable, but our map looks a bit different because we are using the "Pop-1985" attribute in the 0
index of the attributes
array instead of the previously hard coded the "Pop-2015" attribute (Figure 3.5).
Step 5 of our pseudocode listens for user interaction with the slider and step buttons. We can add .click
listeners to the step buttons, using the jQuery alias syntax introduced in Chapter 02. The slider <input>
element has an input
event that fires when the user moves the slider thumb or clicks on the slider bar.
The sequence listeners should be placed within the createSequenceControls()
function, after the elements have been added to the DOM (Example 3.14). For now, the contents of the anonymous functions called by both event listeners is a placeholder comment that eventually will include the logic to update the proportional symbols according to the user input.
//Below Example 3.6 in createSequenceControls()
//Step 5: click listener for buttons
$('.step').click(function(){
//sequence
});
//Step 5: input listener for slider
$('.range-slider').on('input', function(){
//sequence
});
In Step 6 of the pseudocode, we change the attribute index based on the user interaction. The slider makes this easy: we can obtain the current slider value using $(this).val()
. Returning to the basics of jQuery, $(this)
references the element that fired the event and.val()
retrieves the slider's current value (Example 3.15). You can watch the index value change in real time by adding a console.log()
statement and manipulating the slider.
//Example 3.14 line 7...Step 5: input listener for slider
$('.range-slider').on('input', function(){
//Step 6: get the new index value
var index = $(this).val();
console.log(index);
});
The step buttons are a bit more complicated. To coordinate the step buttons with the slider, we can obtain the current index using $('.range-slider').val()
and increment or decrement this value depending on which button the user clicked (increment for 'forward'
, decrement for 'reverse'
). We then update the slider with the new value so it continues to track the current index (Example 3.16).
//Example 3.14 line 2...Step 5: click listener for buttons
$('.step').click(function(){
//get the old index value
var index = $('.range-slider').val();
//Step 6: increment or decrement depending on button clicked
if ($(this).attr('id') == 'forward'){
index++;
//Step 7: if past the last attribute, wrap around to first attribute
index = index > 6 ? 0 : index;
} else if ($(this).attr('id') == 'reverse'){
index--;
//Step 7: if past the first attribute, wrap around to last attribute
index = index < 0 ? 6 : index;
};
//Step 8: update slider
$('.range-slider').val(index);
});
Note that in Example 3.16, we implemented Step 7 in our pseudocode using simplified conditional syntax to assign the index 0
if it is incremented past 6
, and to assign it 6
if it is decremented past 0
. This prevents our sequence from going beyond the boundaries of our attribute array and allows it to wrap continuously. We also implemented Step 8 in our pseudocode by simply setting our new index
as the value of the slider, which automatically updates its position.
Step 9 of our pseudocode reassigns the current attribute based on the new index and Step 10 finally resizes our proportional symbols according to the newly assigned attribute. We can accomplish both tasks by defining a new updatePropSymbols()
function to update the symbols and passing it the attributes
array value at the new index as a parameter (Example 3.17). Call this function at the end of both the button click
handler and the slider input
handler.
//Called in both step button and slider event listener handlers
//Step 9: pass new attribute to update symbols
updatePropSymbols(attributes[index]);
Within the updatePropSymbols()
function, we can use Leaflet's L.map() eachLayer()
method to access all of the Leaflet layers currently on the map. These layers include the L.tileLayer
, so we need to select only our L.circleMarker
layers that contain the features we want to update using an if
statement. The if
statement below tests both for the existence of a feature in the layer and the existence of the selected attribute in the layer's feature properties, ensuring the script will not encounter any undefined
values (Example 3.18).
//Step 10: Resize proportional symbols according to new attribute values
function updatePropSymbols(attribute){
map.eachLayer(function(layer){
if (layer.feature && layer.feature.properties[attribute]){
//update the layer style and popup
};
});
};
Finally, we update each circle marker's radius based on the new attribute values and update the popup content with the new data (Example 3.19).
//Example 3.18 line 4
if (layer.feature && layer.feature.properties[attribute]){
//access feature properties
var props = layer.feature.properties;
//update each feature's radius based on new attribute values
var radius = calcPropRadius(props[attribute]);
layer.setRadius(radius);
//add city to popup content string
var popupContent = "<p><b>City:</b> " + props.City + "</p>";
//add formatted attribute to panel content string
var year = attribute.split("_")[1];
popupContent += "<p><b>Population in " + year + ":</b> " + props[attribute] + " million</p>";
//update popup content
popup = layer.getPopup();
popup.setContent(popupContent).update();
};
The script in our if
statement assigns each feature's properties
object to a variable to keep the code tidy. It then resets the layer radius using calcPropRadius()
and the Leaflet layer's setRadius()
method. Finally, we get a reference to our popup, replace its content, and update it.
We now can sequence through every attribute in the GeoJSON file, resymbolizing the proportional symbols and changing the retrieve popup information using our sequencing controls (Figure 3.6).
With what you have learned the past two chapters, you should have the tools to adapt examples and solutions of additional interaction operators. This final lesson provides some general strategies and examples for implementing the remaining work operators in Leaflet. This material is for your reference only, as only pan, zoom, retrieve, and sequence are required for Activity 6.
Reexpress sets or changes the visual isomorph used to display the mapped data. This could involve allowing the user to change the visual variable, for instance using symbol color value instead of size. A more common use of reexpress for interactive web maps is toggling among thematic map types, for instance between a proportional symbol and choropleth map if you mapped enumerated data.
The Leaflet website includes an Interactive Choropleth Map tutorial, allowing you to adapt the sample code to your GeoJSON data (e.g., Figure 4.1). If you implement a choropleth map, you will need to link your GeoJSON attributes to a polygon dataset. Leaflet choropleth maps generally are not recommended for small cartographic scales (i.e., when "zoomed out") because of the area distortion imposed by the Web Mercator projection.
Overlay adjusts the feature types included in the map, allowing the user to toggle layers on and off. You can implement this operator to provide context in support of a story (limited to several clarifying overlays) or enable exploration of relationships between multiple attributes (potentially dozens of overlays).
The Leaflet website includes a Layer Groups and Layers Control tutorial for using its out-of-the-box overlay control (L.control.layers
). The L.control.layers
control applies radio buttons (one selected at a time) for basemap tilesets and checkboxes (compound selection) for vector overlays. Figure 4.2 shows a simple implementation of the Leaflet layers control, while Figure 4.3 shows a more complicated example from the National Park Service geared more toward exploration. Note that to complete the Overlay operator, you need to provide a second data layer, not just another underlying tileset.
Resymbolize sets or changes the design parameters of a map without changing the thematic map type (compared to reexpress). For instance, you could allow the user to change the scaling ratio between proportional and perceptual scaling, or change between proportional symbols and graduated (classed) symbols. You also could allow the user to change the symbol color, the symbol shape, the size of the minimum symbol, and so on. You can implement these resymbolize options by providing a simple <input>
controls and adjusting the layer.setRadius()
or later.setStyle()
methods within an eachLayer()
function in the event handler.
Carto is an alternative web mapping platform to Mapbox that is also based on Leaflet. Carto allows the user to both resymbolize and reexpress the data through its symbol wizard (shown in Figure 4.4, below).
Reproject sets or changes the map projection beyond the scale (zoom) and centering (pan). Due to the nature of continuous slippy map tilesets, reproject generally does not make sense for interactive web maps using Leaflet. The most likely scenario for reproject in Leaflet is to allow the user to switch between a tileset that is Web Mercator projection (EPSG:3857/EPSG:900913) and one in Plate Carrée (EPSG:4326/WGS84/unprojected). The script required to do this is challenging and there are few stable online examples.
Custom projection and the reproject operator is possible with D3, and an important (but complex) task when getting started with your D3 map in Chapter 09 is setting your projection parameters computationally.
The filter operator can be implemented using the filter option of GeoJSON defines a conditional rule for including a feature in the resulting GeoJSON object, as covered in the Using GeoJSON with Leaflet tutorial. While the tutorial shows a filter applied before adding the GeoJSON file to the Leaflet map object, it also can be applied to the GeoJSON object added to the map, enabling filtering based on user interaction events.
Search alters the map to add or highlight a particular feature of interest, and is the conceptual inverse of filter. Luckily, the search operator can be implemented through the Leaflet Search Control plugin. An example plugin implementation is shown in Figure 4.5, below.
You can implement the plugin to enable users to search for mapped features corresponding to particular attributes in your dataset (such as city name), supporting autocomplete and recentering on the selected feature. The plugin also supports unconstrained address or city search using one of many available geocoder APIs.
Arrange manipulates the layout of the map and linked views. Arrange generally is not common on maps for presentation, and thus is rarely implemented with the mobile-friendly Leaflet library.
Arrange is more common for the highly-exploratory coordinated multiview visualization possible with D3, with a Leaflet map perhaps one window among coordinated views. Figure 4.6 shows a project by a former Web Mapping workbook user using the jQuery UI library's Draggable functionality.
Calculate derives new informatoin about a map feature of interest. One way to implement calculate would be to enable your users to perform mathematical operations on your spatiotemporal data, such as mapping the change between two years or calculating the average across years.
A common calculation on slippy maps is distance and area measurement, which can be implemented on Leaflet maps using the Leaflet Draw plugin. This set of tools is included in geojson.io, which we used in Chapter 03. Figure 4.7 shows the plugin example, demonstrating measurement with the Leaflet Draw controls.
- Implement styled retrieve popups.
- Implement the sequence operator with a slider and step buttons.
- Commit the changes to your unit-2 directory and sync with GitHub. Include "Activity 6" in the commit message (Summary). Your assignment will be graded based on what is contained in this commit.
- Zip a copy of your unit-2 repo and upload to the Activity 6 dropbox as a backup.
This work is licensed under a Creative Commons Attribution 4.0 International License.
For more information, please contact Robert E. Roth ([email protected]).