So. You want to make a web map. Don't worry; it's easy! This is an introduction to web maps using Leaflet. It was written by Andy Woodruff, Ryan Mullins and Cristen Jones for Maptime Boston, but you don't need to be with us to follow along. So let's go!
Leaflet is an open-source JavaScript library for interactive web maps. It's lightweight, simple, and flexible, and is probably the most popular open-source mapping library at the moment. Leaflet is developed by Vladimir Agafonkin (currently of MapBox) and other contributors.
What Leaflet does: "Slippy" maps with tiled base layers, panning and zooming, and feature layers that you supply. It handles various basic tasks like converting data to map layers and mouse interactions, and it's easy to extend with plugins. It will also work well across most types of devices. See Anatomy of a Web Map for an introduction to the most common kinds of web maps, which is what Leaflet is good for.
What Leaflet does not do: Provide any data for you! Leaflet is a framework for showing and interacting with map data, but it's up to you to provide that data, including a basemap. Leaflet is also not GIS, although it can be combined with tools like CartoDB for GIS-like capabilities. If you need total freedom of form, interaction, transitions, and map projections, consider working with something like D3.
How this tutorial works: It's structured around examples that progressively build upon one another, starting from scratch and ending with slightly advanced techniques. It assumes a basic knowledge of HTML and JavaScript, or at the very least assumes the will to tinker with the code to better understand what it does—and how to use it for your own work. It won't explain every little object or array, but will contain plenty of links. Many code blocks show only a snippet of code, highlighting the changes over previous examples. Click the "View this example on its own" link underneath a map to see complete code. For thorough documentation, see the Leaflet site.
BEFORE YOU START!
The simple map above requires only a few things:
<div>
element to hold the mapheight
style specified for the map div.<div>
<html>
<head>
<title>A Leaflet map!</title>
<link rel="stylesheet" href="./Leaflet-1.0.3/leaflet.css"/>
<script src="./Leaflet-1.0.3/leaflet.js"></script>
<style>
#map{ height: 100% }
</style>
</head>
<body>
<div id="map"></div>
<script>
// initialize the map
var map = L.map('map').setView([42.35, -71.08], 13);
// load a tile layer
L.tileLayer('http://tiles.mapc.org/basemap/{z}/{x}/{y}.png',
{
attribution: 'Tiles by <a href="http://mapc.org">MAPC</a>, Data by <a href="http://mass.gov/mgis">MassGIS</a>',
maxZoom: 17,
minZoom: 9
}).addTo(map);
</script>
</body>
</html>
Want to follow along? Download this starter file.
Let's focus on the code in that <script>
tag near the end. What did we do?
map
variableL.map()
to initialize the map object, passing it the id of the div where we want the map to gosetView()
method to center the initial map view on Boston (latitude 42.35, longitude -71.08, and zoom level 13)L.tileLayer()
to create a base layer of map tiles, specifying a URL template for the tile images. In this case we're using MAPC's basemap, but there are many options out there. {z}/{x}/{y} is a template that Leaflet uses to find tiles at the correct zoom, x, and y coordinates. (See Anatomy of a Web Map). We also specified a few options:
addTo()
method to add this tile layer to the map we just createdSometimes maps use multiple tile layers at once. The map above adds MAPC's bike lane layer, which looks like vector data but is actually made up of PNG images with transparency.
Just pile on additional layers the same way as the first one. As we'll see later, other types of layers are added in a very similar manner.
// base map
L.tileLayer('http://tiles.mapc.org/basemap/{z}/{x}/{y}.png',
{
attribution: 'Tiles by <a href="http://mapc.org">MAPC</a>,
Data by <a href="http://mass.gov/mgis">MassGIS</a>',
maxZoom: 17,
minZoom: 9
}).addTo(map);
OOO
// bike lanes
L.tileLayer('http://tiles.mapc.org/trailmap-onroad/{z}/{x}/{y}.png',
{
maxZoom: 17,
minZoom: 9
}).addTo(map); CCC
Sometimes base tiles are all you need, but usually your web map will show some specific data besides general reference features. Generally these data will be displayed as vector features, in contrast to the raster base map. Vector features come in three varieties:
This is Maptime Boston, so obviously we're going to make a #ratmap for this example. Below is a map of reported "rodent activity" in Boston from the Mayor's Hotline.
View this example on its ownRodents everywhere! The data come from the City of Boston's open data site. We have converted the original CSV to GeoJSON ahead of time using QGIS. GeoJSON is the de facto standard data type for web maps, and Leaflet has built-in methods to make it easy to map GeoJSON data. For more about GeoJSON, start with its Wikipedia article.
<html>
<head>
<title>A Leaflet map!</title>
<link rel="stylesheet" href="./Leaflet-1.0.3/leaflet.css"/>
<script src="./Leaflet-1.0.3/leaflet.js"></script>
OOO <script src="jquery-2.1.1.min.js"></script> CCC
<style>
#map{ height: 100% }
</style>
</head>
<body>
<div id="map"></div>
<script>
// initialize the map
var map = L.map('map').setView([42.35, -71.08], 13);
// load a tile layer
L.tileLayer('http://tiles.mapc.org/basemap/{z}/{x}/{y}.png',
{
attribution: 'Tiles by <a href="http://mapc.org">MAPC</a>, Data by <a href="http://mass.gov/mgis">MassGIS</a>',
maxZoom: 17,
minZoom: 9
}).addTo(map);
OOO
// load GeoJSON from an external file
$.getJSON("rodents.geojson",function(data){
// add GeoJSON layer to the map once the file is loaded
L.geoJson(data).addTo(map);
});
CCC
</script>
</body>
</html>
Okay, so what's new here? We've added a couple of files but only a few more lines of code for the #ratmap.
getJSON()
method to load the rodent file. We pass this method two things: 1) the path to the rodent file, which in this case is just the file name because it's in the same directory, and 2) a function that will run once the file has been loaded and parsed. The data
argument in that function represents the JSON data that jQuery reads from our external file.L.geoJson()
to create a vector layer from GeoJSON, passing it the same data
, and again using addTo()
to put the layer on the map.Phew. There were some new pieces to understand here, but the code remains very simple. jQuery is one of several ways to load GeoJSON data. For other options, see this post and this one from Lyzi Diamond. We're only going to focus on GeoJSON in this tutorial, but check out Leaflet Omnivore by Tom MacWright for a plugin that makes it easy to load various data types. Need to convert your data to GeoJSON? Try some of these options: QGIS, OGRE, Shape Escape, mapshaper, geojson.io.
Our rodents showed up as default blue markers in the map above. But, although the markers aren't ugly, defaults are rarely a good idea. Besides, this is a #ratmap, so let's see some rats!
View this example on its ownEek! Everybody tuck your pants into your socks.
$.getJSON("rodents.geojson",function(data){
OOO var ratIcon = L.icon({
iconUrl: 'rat.gif',
iconSize: [50,40]
}); CCC
L.geoJson(data OOO ,{
pointToLayer: function(feature,latlng){
return L.marker(latlng,{icon: ratIcon});
}
} CCC ).addTo(map);
});
Leaflet is flexible and smart. As we saw in the previous example, it will draw maps just fine by default, but here we've specified some options to override a default. There are two main additions:
L.icon()
to define the icon we're going to use for the rodent points. We have given it an object with a couple of options. Many options are available, but we just need two for now.
iconUrl
is the path to the image file, in this case rat.gif, which sits in the same directory as the HTML page.iconSize
is a two-number array of the pixel width and height of the icon. Leaflet needs to know the size in order to position the icon properly. This property could be used to scale the images, but here we are just using the actual pixel dimension of the PNG.L.geoJson
has been passed an options object. We have given it just one option, a pointToLayer
function. When pointToLayer
is defined, Leaflet uses it to determine how to convert a point feature into a map layer. pointToLayer
always accepts two arguments: the GeoJSON feature, and a LatLng
object representing its location. (We don't need to figure these out; Leaflet will automatically pass them to the function.) pointToLayer
needs to return some kind of Leaflet layer. We'll cover several layer types later, but right now we're using a Marker, which is also what the default blue symbols are. Our function returns L.marker()
to create a new Marker, which is passed:
pointToLayer
behind the scenes. This is the location of the point feature.icon
defined as the ratIcon
object we created in the previous step.The true power of web maps is in interaction, and not just panning and zooming. Let's make the #ratmap a bit more useful by adding popups showing the address and date of each rodent report. The dancing rats are kind of distracting, so let's use a static image to render them by replacing the GIF with rat.png (designed by Aha-Soft). Click on the somber rat icons below to see the address and date of each report.
View this example on its own
pointToLayer: function(feature,latlng){
OOO var marker = L.marker(latlng,{icon: ratIcon});
marker.bindPopup(feature.properties.Location + '<br/>' + feature.properties.OPEN_DT);
return marker; CCC
}
Piece of cake. Before returning the Marker in pointToLayer, we just need to use the bindPopup()
method to enable the popup on click. bindPopup()
only needs to be given the content that is to appear in the popup. In this case, we pass it an HTML string: the Location and OPEN_DT properties from the GeoJSON feature, and a line break im between. Leaflet handles the interaction and everything else. Like most Leaflet objects, though, we could customize the popup if we wanted to.
Great. We've got a functional #ratmap now. Let's open the door to more advanced mapping techniques by moving beyond point data. Here's a map with Boston neighborhood polygons underneath our rodents.
View this example on its ownThese polygons were loaded from another GeoJSON file with minimal effort. By default, Leaflet renders polygon and line data as SVG (Scalable Vector Graphics) paths, making interaction and styling easy. We'll get to that in a moment (that blue doesn't look great!), but first the simple code to load this layer.
OOO $.getJSON("neighborhoods.geojson",function(hoodData){
L.geoJson( hoodData ).addTo(map);
}); CCC
$.getJSON("rodents.geojson",function(data){
var ratIcon = L.icon({
iconUrl: 'rat.png',
iconSize: [60,50]
});
L.geoJson(data,{
pointToLayer: function(feature,latlng){
var marker = L.marker(latlng,{icon: ratIcon});
marker.bindPopup(feature.properties.Location + '<br/>' + feature.properties.OPEN_DT);
return marker;
}
}).addTo(map);
});
Polygon and line GeoJSON data is added in the same basic way as points. (In fact, all three could be loaded from a single GeoJSON file.) We just repeat the step from a few examples back, using neighborhoods.geojson this time.
Savvy coders at this point may notice that the two asynchronous $.getJSON
requests are not guaranteed to finish in the order that the layers need to be stacked. That is, we don't want rodents drawing first, underneath the neighborhoods. Don't worry, and remember that Leaflet is clever. If you dig into your web inspector, you will find that the map consists of several panes. SVG paths, such as our neighborhoods, will always be drawn in the overlay pane, which is beneath the marker pane into which things like our rat markers were drawn. Thus it doesn't matter which of our two layers draws first; they will both be drawn in the right place. (If we had multiple polygon or point layers, however, we would need to be more careful.)
Okay. Let's do something about that default blue. Let's make these polygons useful by turning them into a choropleth layer. The neighborhoods GeoJSON file contains numbers for the number of rodent incidents per square mile in each neighborhood, calculated in QGIS. So here's a #ratdensitymap.
View this example on its own
$.getJSON("neighborhoods.geojson",function(hoodData){
L.geoJson( hoodData OOO , {
style: function(feature){
var fillColor,
density = feature.properties.density;
if ( density > 80 ) fillColor = "#006837";
else if ( density > 40 ) fillColor = "#31a354";
else if ( density > 20 ) fillColor = "#78c679";
else if ( density > 10 ) fillColor = "#c2e699";
else if ( density > 0 ) fillColor = "#ffffcc";
else fillColor = "#f7f7f7"; // no data
return { color: "#999", weight: 1, fillColor: fillColor, fillOpacity: .6 };
},
onEachFeature: function( feature, layer ){
layer.bindPopup( "<strong>" + feature.properties.Name + "</strong><br/>" + feature.properties.density + " rats per square mile" )
}
} CCC ).addTo(map);
});
At this point we begin to see the power of combining built-in Leaflet features with our own code and logic. Leaflet provides convenient methods of styling those polygons, but it's up to us to figure out what styles to use. The code above creates a simple data classification and assigns ColorBrewer colors based on feature values.
style
function. When this is defined, Leaflet uses it to determine what style to apply to each polygon based on the GeoJSON feature data. The function takes one argument, which is that feature
. It needs to return an object with any path styles that we want to override defaults. The function here uses a series of if ... else
statements to find where the neighborhood's density property falls within a pre-defined classification, and assigns a fill color accordingly. It then returns an object with that fill color and several other styles defined. When Leaflet adds features in this layer to the map, it will run each of them through this style function and apply the results.onEachFeature
is a more general-purpose function that Leaflet will invoke for each feature as it is added to the map. It takes two arguments: the GeoJSON feature
, and the actual map layer
(the polygon, in this case). Here we use it to bind a popup to each polygon, much like what we did for the rodents layer. However, for the rodents we could do this in the pointToLayer
function, which we were already using to make custom markers. pointToLayer
only applies to point features and thus is not available to this polygon layer. onEachFeature
is used instead for popups (among other things).style
with onEachFeature
accomplishes something similar to what pointToLayer
did for the rodents. style
provides some instructions for how to turn the GeoJSON feature into a map layer, and onEachFeature
provides some instructions for what to do with that layer.The beauty of Leaflet being open source—and of the particular way it's written—is that it's functionality and features can be extended and customized to your heart's content. There's a whole slew of plugins people have written to extend the Leaflet core, most of them very easy to drop right into your project. We'll use a popular one, Leaflet.markercluster. A map cluttered with rat symbols isn't great, and this plugin will help make it more readable and usable. Poke around on the map below.
View this example on its own
<link rel="stylesheet" href="./Leaflet-1.0.3/leaflet.css"/>
OOO <link rel="stylesheet" href="./Leaflet.markercluster-1.0.5/MarkerCluster.css"/> CCC
<script src="./Leaflet-1.0.3/leaflet.js"></script>
OOO <script src="./Leaflet.markercluster-1.0.5/leaflet.markercluster.js"></script> CCC
<script src="jquery-2.1.1.min.js"></script>
...
<script>
...
OOO var rodents = CCC L.geoJson(data,{
pointToLayer: function(feature,latlng){
var marker = L.marker(latlng,{icon: ratIcon});
marker.bindPopup(feature.properties.Location + '<br/>' + feature.properties.OPEN_DT);
return marker;
}
});
OOO var clusters = L.markerClusterGroup();
clusters.addLayer(rodents);
map.addLayer(clusters); CCC
...
</script>
Look at all that added functionality from just a few lines of code! The plugin takes care of figuring out clusters, displaying them, and breaking them apart as you zoom in. (This is just the simplest implementation; there are many options you can explore.)
rodents
variable instead of immediately adding it to the map. Its options remain the same, but we need to save it to a variable in order to use it later.L.markerClusterGroup()
, a function that is part of the plugin, and assign it to a clusters
variable.But what if we didn't like choropleths or icons, or didn't need to know the location and details of every single rat? Let's add a heatmap! We can use a plugin made for this purpose (Leaflet.heat).
View this example on its own
<link rel="stylesheet" href="./Leaflet-1.0.3/leaflet.css"/>
<link rel="stylesheet" href="./Leaflet.markercluster-1.0.5/MarkerCluster.css"/>
<script src="./Leaflet-1.0.3/leaflet.js"></script>
<script src="./Leaflet.markercluster-1.0.5/leaflet.markercluster.js"></script>
OOO <script src="./Leaflet.heat-0.2.0/leaflet-heat.js"></script> CCC
<script src="jquery-2.1.1.min.js"></script>
...
<script>
...
$.getJSON("rodents.geojson",function(data){
OOO var locations = data.features.map(function(rat) {
var location = rat.geometry.coordinates.reverse();
location.push(0.5);
return location;
});
var heat = L.heatLayer(locations, { radius: 35 });
map.addLayer(heat); CCC
});
...
</script>
Here we loaded and initialized yet another plugin and used it to compute and display a heatmap
$.getJSON
call that had fetched the rodents.geojson
data and rendered rat icons. To create a heatmap from the rat data, we needed to have an array of rats, where each rat is represented by an array of latitude, longitude, and intensity.locations
, and used the array Map function to take all of the rats from the data.features
array, get the coordinates (in latitude, longitude order... which is the reverse of how it is in the GeoJSON file), and add in a value for intensity since it's required by the plugin. So each rat becomes a [latitude, longitude, intensity]
array.We'll leave it there for now. We've already gone from nothing to a somewhat fancy map, but there is much more you can do with Leaflet. Tinker with what we've done here to better understand how everything works and how different options affect the map. Also think about good map design—this tutorial has only been about technology and hasn't addresssed (or demonstrated) good design. Going forward, have a look at some of these other Leaflet and mapping resources: