leaflet
maps in R
We’re going to use R and Leaflet (a Javascript library) to plot schools on a very simple map and show the boundaries of local authority districts. We’re going to colour arbitrarily some of the LADs based on a particular condition and also alter the icon and colour of the markers according to Ofsted result, again arbitrarily.
We’re going to:
Note that the code and the data is arbitrary and for training purposes only. This is intended purely as an introduction; there are probably better ways to do the things I’ve laid out below, but this should help give you the gist. See the Going Further and Resources sections for more detail on how to do these things and find some further examples.
I recommend setting up your project as an RStudio project for easy reproducibility and transferability. Ask if you want more information about this.
We’re going to make heavy use of the dplyr
package for manipulation of our data and our maps. Remember when installing a package, you can use theinstall.packages()
function to download it to your local library (a folder on your machine). You then only need to call the package from the library in future with the library()
function.
library(dplyr) # data manipulation and pipe operator (%>%)
Note also that I’m using the ::
notation in the code below to show detail the package::function()
relationship.
We start with the data for the schools that you want to map. At very least this would have to be a list of school Unique Reference Numbers (URNs) or Local Authority Establishment Numbers (LAESTABs). We can then join school infomration from Get Information About Schools (GIAS), including co-ordinates in the form of eastings and northings.
For this demo, I sampled randomly 500 primary schools and 500 secondaries from the GIAS dataset and selected a few columns of interest. I saved the resulting dataframe object as an RDS file, so we can use the readRDS()
to read the data into our environment.
schools <- readRDS(file = "data/gias_sample.RDS")
Here’s what the dataframe structure looks like:
dplyr::glimpse(schools)
## Observations: 1,000
## Variables: 9
## $ urn <chr> "131570", "116743", "109932", "143371", "106846"...
## $ laestab <chr> "3833929", "8842155", "8692240", "9362880", "372...
## $ sch_name <chr> "Shire Oak VC Primary School", "Walford Primary ...
## $ sch_type <chr> "Voluntary controlled school", "Community school...
## $ phase <chr> "Primary", "Primary", "Primary", "Primary", "Pri...
## $ pupil_count <chr> "208", "196", "276", "633", "249", "93", "210", ...
## $ ofsted_rating <chr> "Good", "Good", "Good", "Requires improvement", ...
## $ easting <int> 428142, 359307, 466035, 504170, 440147, 460189, ...
## $ northing <int> 436290, 220990, 171981, 170873, 392804, 198702, ...
The column names are pretty self-explanatory. Note that the dataset is tidy – one school per row – and that we have unique identifiers (urn
and laestab
) and co-ordinates (easting
and northing
).
Sometimes co-ordinates can’t be matched to schools from GIAS. We can check for any schools that are missing eastings and northings using filter()
and is.na()
. In our case, we have one set of co-ordinates missing for a secondary school:
schools %>%
dplyr::filter(is.na(easting) | is.na(northing))
## # A tibble: 1 x 9
## urn laestab sch_name sch_type
## <chr> <chr> <chr> <chr>
## 1 145502 8034010 Winterbourne International Academy Academy sponsor led
## # ... with 5 more variables: phase <chr>, pupil_count <chr>,
## # ofsted_rating <chr>, easting <int>, northing <int>
So let’s create a new data frame object with complete co-ordinates information and check we have one fewer secondary school:
schools_nona <- schools %>%
dplyr::filter(!is.na(easting) | !is.na(northing))
table(schools_nona$phase)
##
## Primary Secondary
## 500 499
There are many co-ordinate systems in use. The eastings and northings in GIAS are based on the system called the British National Grid (BNG). We’ll reproject our BNG data to latitude and longitude because it’s easier to handle in Leaflet and is the system used for our local authority district layer anyway.
To do this, I’m using some code developed by Alex Singleton, a Professor of Geographic Infomration Science at the University of Liverpool.
First we’ll load the relevant packages:
library(rgdal) # Geospatial Data Abstraction Library functions
library(geojsonio) # deal with json file
library(sp) # deal with spatial data
Let’s isolate the co-ordinate columns into a single dataframe object. I’m going to pre-emptively change the names of these columns to longitude
and latitude
, but note that they haven’t yet been converted; they’re still in the BNG projection.
schools_coords <- schools_nona %>%
dplyr::transmute( # create new columns and drop all the others
easting = as.numeric(as.character(easting)), # make this text column numeric
northing = as.numeric(as.character(northing))
) %>%
dplyr::rename(longitude = easting, latitude = northing) # rename
print(schools_coords)
## # A tibble: 999 x 2
## longitude latitude
## <dbl> <dbl>
## 1 428142 436290
## 2 359307 220990
## 3 466035 171981
## 4 504170 170873
## 5 440147 392804
## 6 460189 198702
## 7 532625 166192
## 8 395782 566384
## 9 515703 148686
## 10 413341 130471
## # ... with 989 more rows
And we’ll isolate the data columns as well.
schools_data <- schools_nona %>%
dplyr::select(-easting, -northing) # select all columns except the coords
print(schools_data)
## # A tibble: 999 x 7
## urn laestab sch_name
## <chr> <chr> <chr>
## 1 131570 3833929 Shire Oak VC Primary School
## 2 116743 8842155 Walford Primary School
## 3 109932 8692240 Calcot Junior School
## 4 143371 9362880 Riverbridge Primary School
## 5 106846 3722023 Meadow View Primary School
## 6 123030 9312455 Stadhampton Primary School
## 7 142246 3063406 St Mary's Catholic Infant School
## 8 122222 9292227 Beaufront First School
## 9 125151 9363044 Powell Corderoy Primary School
## 10 126265 8652191 Salisbury, Manor Fields Primary School
## # ... with 989 more rows, and 4 more variables: sch_type <chr>,
## # phase <chr>, pupil_count <chr>, ofsted_rating <chr>
And now we’ll merge these together into a Spatial Points Data Frane (we saw the polygon version of this when we read in the GeoJSON file of LADs earlier in this document).
schools_spdf <- sp::SpatialPointsDataFrame( # create a SPDF
coords = schools_coords, # the school co-ordinates
data = schools_data, # the school data
proj4string = CRS("+init=epsg:27700") # BNG projection system
) %>%
sp::spTransform(CRS("+init=epsg:4326")) # reproject to latlong system
Here’s what the structure of our SPDF looks like:
dplyr::glimpse(schools_spdf)
## Formal class 'SpatialPointsDataFrame' [package "sp"] with 5 slots
## ..@ data :Classes 'tbl_df', 'tbl' and 'data.frame': 999 obs. of 7 variables:
## .. ..$ urn : chr [1:999] "131570" "116743" "109932" "143371" ...
## .. ..$ laestab : chr [1:999] "3833929" "8842155" "8692240" "9362880" ...
## .. ..$ sch_name : chr [1:999] "Shire Oak VC Primary School" "Walford Primary School" "Calcot Junior School" "Riverbridge Primary School" ...
## .. ..$ sch_type : chr [1:999] "Voluntary controlled school" "Community school" "Community school" "Academy converter" ...
## .. ..$ phase : chr [1:999] "Primary" "Primary" "Primary" "Primary" ...
## .. ..$ pupil_count : chr [1:999] "208" "196" "276" "633" ...
## .. ..$ ofsted_rating: chr [1:999] "Good" "Good" "Good" "Requires improvement" ...
## ..@ coords.nrs : num(0)
## ..@ coords : num [1:999, 1:2] -1.574 -2.593 -1.051 -0.503 -1.397 ...
## .. ..- attr(*, "dimnames")=List of 2
## ..@ bbox : num [1:2, 1:2] -5.27 50.23 1.71 55.76
## .. ..- attr(*, "dimnames")=List of 2
## ..@ proj4string:Formal class 'CRS' [package "sp"] with 1 slot
As before, note the slots are preceded by an @
symbol. If you want to access the phase
column for example, you would need to type schools_spdf@data$phase
. Compare this to schools$phase
, which you’d use to access the phase data in the original data frame.
Now we can bring the LAD polygons and the schools data together into a simple map using the leaflet
package. This package wraps up some functions from the Leaflet JavaScript library.
Much of this material below is derived from the Leaflet for R page from the RStudio team.
First we’ll call the package from our library.
library(leaflet) # leaflet mapping functions
Leaflet maps are built up in layers. Our approach is going to be to do the following:
Here is an artist’s rendering of layers, with the map at back, then boundaries (polygons) overlaid and finally the points:
Using pipe (%>%
) notation, we can build these layers up one-by-one. This is much like building a ggplot object by building up layers using the +
.
Generating the underlying map is easy. We can choose a variety of background maps from a number of providers, but Open Street Map is a good place to start.
map <- leaflet::leaflet() %>%
leaflet::addProviderTiles(providers$OpenStreetMap)
map # show the map
Now we’ll add a layer containing the LAD polygons.
To show the boundaries, we could simply render the edges and leave the area of the polygon clear. We could also pick out individual LADs based on some variable. Arbitrarily, let’s fill any that are over a certain area, given by the st_areashape
variable in the GeoJSON file. We can do this using an ifelse()
statement for the fillOpacity
argument in the addPolygons()
function.
map_lad <- map %>%
leaflet::addPolygons(
data = lads_eng, # LAD polygon data from geojson
weight = 1, # line thickness
opacity = 1, # line transparency
color = "black", # line colour
fillOpacity = ifelse( # conditional fill opacity
test = lads_eng@data$st_areashape > 1E+09, # if area is over this value
yes = 0.5, # then make it half-opaque
no = 0 # otherwise make it entirely transparent
),
fillColor = "red",
label = ~lad17nm # LAD name as a hover label
)
map_lad # show the map
Hover over the polygons in the map above to see the name of the LAD.
So now we’ll add some markers that show where are schools are. Rather than mark all the points with exactly the same marker, we’re going to colour the markers according got Ofsted rating and give them icons that relate to their educaiton phase.
What levels of Ofsted rating do we have?
table(schools_spdf@data$ofsted_rating)
##
## Good Inadequate Outstanding
## 607 20 193
## Requires improvement Serious Weaknesses Special Measures
## 113 1 1
So let’s colour differently three separate groups:
We’re going to use the addAwesomeMarkers()
function rather than the addMarkers()
function. This is because it’s more… awesome. It allows you to tweak a lot of settings with your markers. For example, you can use the awesomeIcons()
function in the icon
argument to select from a range of different icons. By specifying the library, you can name an icon and it will automatically be used. In this example, we’re using the ‘ion’ library and the icons named ’ion-arrow-*-b’. You can preview the icons available in the ‘ion’ library by visitng the ionicons website. You need only to replace the icon
argument in the awesomeIcons()
function with the name of any of the icons shown on the website (click the icon for its name).
map_groups <- map_lad %>%
leaflet::addAwesomeMarkers(
data = schools_spdf, # our spatial points data frame
icon = awesomeIcons(
library = "ion",
icon = ifelse(
test = schools_spdf@data$ofsted_rating == "Primary",
yes = "ion-arrow-down-b", # down arrow for primary
no = "ion-arrow-up-b" # up arrow for secondary
),
iconColor = "white", # the icon's colour
markerColor = ifelse(
test = schools_spdf@data$ofsted_rating == "Outstanding", # if this...
yes = "blue", # ...then colour blue
no = ifelse(
test = schools_spdf@data$ofsted_rating == "Good", # if this...
yes = "lightblue", # ...then colour lightblue
no = "red" # colour remaining schools (Ofsted rating RI, I, SW or SM)
)
)
)
)
map_groups # show the map
This is better than plain markers that are the same for all schools, but it’s difficult to see what’s going on. We can instead introduce the concept of ‘groups’ that can be toggled on or off.
We’re going to do something similar to the map above, but each group of markers will be added separately with its own call to the addAwesomeMarkers()
function.
First we need to subset the data into our categories of interest.
# Outstanding
ofsted_outst <- subset(
x = schools_spdf,
subset = schools_spdf@data$ofsted_rating == "Outstanding"
)
# Good
ofsted_good <- subset(
x = schools_spdf,
subset = schools_spdf@data$ofsted_rating == "Good"
)
# The others
ofsted_other <- subset(
x = schools_spdf,
subset = schools_spdf@data$ofsted_rating %in% c(
"Requires Improvement", "Inadequate",
"Serious Weaknesses", "Special Measures"
)
)
While we’re here, we’re also going to add a function that will populate the on-click marker popups with detail about that individual school. Words in quotes will be rendered verbatim and personalised content will fill the popup where we’ve specified data from the @data
slot of our SPDF. Note that we can use HTML in here too: <br>
inserts a line break.
get_popup_content <- function(ofsted_group_spdf) {
paste0(
"<b>", ofsted_group_spdf@data$sch_name, "</b>",
"<br>LAESTAB: ", ofsted_group_spdf@data$laestab,
"<br>",
"<br>Phase: ", ofsted_group_spdf@data$phase,
"<br>Ofsted: ", ofsted_group_spdf@data$ofsted_rating,
"<br>Pupils: ", ofsted_group_spdf@data$pupil_count, " (2017)"
)
}
And now we’ll create a function for adding the markers. Note that the get_popup_content()
function we defined above is used here in the popup argument to addAwesomeMarkers()
assign_markers <- function(
map_object,
ofsted_group_spdf,
group_name,
marker_col = "blue" # will default to blue
) {
leaflet::addAwesomeMarkers(
map = map_object, # the base map we created first
data = ofsted_group_spdf, # the spatial points data frame
group = group_name, # the name for the marker grouping
popup = ~get_popup_content(ofsted_group_spdf), # our popup function
icon = awesomeIcons(
library = "ion", # the ion set of icons
icon = ifelse( # conditional icon
test = ofsted_group_spdf@data$phase == "Primary",
yes = "ion-arrow-down-b", # primary gets a down arrow
no = "ion-arrow-up-b" # up arrows for secondary schools
),
iconColor = "white",
markerColor = marker_col # you can specify a colout for the marker
)
)
}
Now put it together. See how using out assign_marker()
function – with the get_popup_content()
function inside – helps cut down the code and make it more readable.
The ‘Oustatnding’ group are turned on by default and the other two are off. You can switch them on using the checkboxes in the top right. Note that the more markers that are showing, the slower it is to respond to your interactions.
map_toggle <- map_lad %>%
# add the markers (order is the order the checkboxes will appear on the map)
assign_markers( # marker group 1: 'outstanding' schools
ofsted_group_spdf = ofsted_outst, # the subset we specified earlier
group_name = "Outstanding" # sensible group name
) %>%
assign_markers( # marker group 2: 'good' schools
ofsted_group_spdf = ofsted_good, # the subset we specified earlier
group_name = "Good", # sensible group name
marker_col = "lightblue"
) %>%
assign_markers( # marker group 3: schools performing worse than 'good'
ofsted_group_spdf = ofsted_other, # the subset we specified earlier
group_name = "Other", # sensible group name
marker_col = "red"
) %>%
# controlling the groups
leaflet::addLayersControl(
overlayGroups = c("Outstanding", "Good", "Other"), # add these layers
options = layersControlOptions(collapsed = FALSE) # expand on hover?
) %>%
hideGroup(c("Good", "Other")) # turn these off by default
map_toggle # show the map
The ‘good’ layer is still a bit busy, but we can remedy that.
We can automatically group nearby markers into clusters, shown as coloured circles on the map. The number on the circle indicates the number of markers grouped together into that circle’ (see image below); higher numbers make the circle red, lower numbers make it green. Hovering over the circle shows you the extent of the markers demarcated with a displaying a blue boundary. As you zoom in, the clusters expand into smaller clusters and ultimately into individual markers.
I’ve turned on the ‘Good’ group by default to make the clusters obvious. I’ve also built the points layer directly on top of the base map without the local authority boundaries, again for ease of understanding.
map_clusters <- map %>%
# add the markers (order is the order the checkboxes will appear on the map)
assign_markers( # use our function for adding markers
ofsted_group_spdf = ofsted_outst,
group_name = "Outstanding"
) %>%
leaflet::addAwesomeMarkers( # using addAwesomeMarkers function so you can...
data = ofsted_good,
clusterOptions = markerClusterOptions(), # ...see how clusters are added
group = "Good",
popup = ~get_popup_content(ofsted_good),
icon = awesomeIcons(
library = "ion",
icon = ifelse(
test = ofsted_good@data$phase == "Primary",
yes = "ion-arrow-down-b",
no = "ion-arrow-up-b"
),
iconColor = "white",
markerColor = "lightblue"
)
) %>%
assign_markers( # use our function for adding markers
ofsted_group_spdf = ofsted_other,
group_name = "Other",
marker_col = "red"
) %>%
# controlling the groups
leaflet::addLayersControl(
overlayGroups = c("Outstanding", "Good", "Other"), # add these layers
options = layersControlOptions(collapsed = FALSE) # expand on hover?
) %>%
hideGroup(c("Outstanding", "Other")) # turn these off by default
map_clusters # show the map
You can:
And much more.