Reading through spreadsheets can sometimes be boring. It is useful to be able to generate high-quality, interactive maps to display your work in R so that others can enjoy exploring it. One of the easiest ways to do this is with Leaflet. Leaflet is a package in R which allows you to quickly build interactive maps in a Java Script interface.
You don’t need to be massively confident in either R or HTML in order to make a decent map following this guide.(I have 0 experience doing any form of Computer Science/Coding/Web Development).
For this guide, I’m going to build a map which displays the results of the 2024 UK General Election.
Firstly, we need some data on the shapes we want on the map.
I found some data on the shapes of UK Parliamentary Constituencies from the ONS Open Geography Portal (Link). These shapes are called Polygons. (Note: I’m going to avoid going into unnecessary detail in explaining how Geospatial objects work in R. It isn’t strictly necessary to understand what’s going on in this guide.)
On this website, download the geoJSON files for the map.
We also need some data on the 2024 election results. I found this data on the House of Commons Library website (Link). On this website, download the “Detailed results by constituency (csv)” file.
Save both of these files in a folder along with a new R script.
In the R script, firstly install and load the necessary packages for this project.
I’m going to be using the Tidyverse package, as well as the sf package, the leaflet package and the htmlwidgets package. Ensure these are installed and loaded before proceeding.
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.1.4 ✔ readr 2.1.5
## ✔ forcats 1.0.0 ✔ stringr 1.5.1
## ✔ ggplot2 3.5.1 ✔ tibble 3.2.1
## ✔ lubridate 1.9.4 ✔ tidyr 1.3.1
## ✔ purrr 1.0.2
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(sf)
## Linking to GEOS 3.12.2, GDAL 3.9.3, PROJ 9.4.1; sf_use_s2() is TRUE
library(leaflet)
library(htmlwidgets)
Loading the CSV should be easy by now. Use read.csv and save it as a Data Frame called details. Ignore my error message here and insert the path to your file.
details <- read.csv("path/to/your/file.csv")
## Error in file(file, "rt"): cannot open the connection
Loading the geoJSON file is just as simple. In this case, we are going to use the sf function, st_read. Use st_read to load the geoJSON file and save it as an object called boundaries. Again, ignore my error message here.
boundaries <- st_read("path/to/your/file.geojson")
## Error: Cannot open "path/to/your/file.geojson"; The file doesn't seem to exist.
## Reading layer `Westminster_Parliamentary_Constituencies_July_2024_Boundaries_UK_BUC_4872633423108313063' from data source `C:\Users\MatthewGStubbs\Desktop\Local Files\University\Year 3\EC349 Data Science for Economists\R Guides for Neil\Westminster_Parliamentary_Constituencies_July_2024_Boundaries_UK_BUC_4872633423108313063.geojson'
## using driver `GeoJSON'
## Simple feature collection with 650 features and 9 fields
## Geometry type: MULTIPOLYGON
## Dimension: XY
## Bounding box: xmin: -8.649996 ymin: 49.88234 xmax: 1.763706 ymax: 60.86087
## Geodetic CRS: WGS 84
We first want to create a very simple map to test whether our geospatial files are working properly. This can be done by using the leaflet function. Create an object called map which uses the boundaries data. Pipe this into a function called addTiles() - this will add a background to our map and can be removed if desired. Then, Pipe this into a function called addPolygons().
Call this object and it will display the map in viewer section on R studio (Note: This may take a few seconds to load).
map <- leaflet(data = boundaries)%>%
addTiles()%>%
addPolygons()
map
Although this map does show where all the constituencies are, it is not very useful as it does not provide any other information. We need to add data to this map.
If we examine the structure of the boundaries object and the details data frame, we observe that they both have a similar looking ID variable.
str(boundaries)
## Classes 'sf' and 'data.frame': 650 obs. of 10 variables:
## $ FID : int 1 2 3 4 5 6 7 8 9 10 ...
## $ PCON24CD : chr "E14001063" "E14001064" "E14001065" "E14001066" ...
## $ PCON24NM : chr "Aldershot" "Aldridge-Brownhills" "Altrincham and Sale West" "Amber Valley" ...
## $ PCON24NMW: chr " " " " " " " " ...
## $ BNG_E : int 484716 404720 374132 440478 497309 450035 611218 392654 495172 438918 ...
## $ BNG_N : int 155270 301030 389051 349674 118530 356564 142572 398807 216598 232636 ...
## $ LONG : num -0.786 -1.932 -2.39 -1.398 -0.616 ...
## $ LAT : num 51.3 52.6 53.4 53 51 ...
## $ GlobalID : chr "b6eea658-887c-43bd-81a6-e879ccb73f1f" "d1f29811-2c62-4dcb-b0dc-06db905c7d6a" "5345726c-b6b4-4d12-a310-8b2ab27bccee" "4c232f33-f30b-4526-bfec-d5aa8840fe74" ...
## $ geometry :sfc_MULTIPOLYGON of length 650; first list element: List of 1
## ..$ :List of 1
## .. ..$ : num [1:19, 1:2] -0.775 -0.744 -0.731 -0.729 -0.746 ...
## ..- attr(*, "class")= chr [1:3] "XY" "MULTIPOLYGON" "sfg"
## - attr(*, "sf_column")= chr "geometry"
## - attr(*, "agr")= Factor w/ 3 levels "constant","aggregate",..: NA NA NA NA NA NA NA NA NA
## ..- attr(*, "names")= chr [1:9] "FID" "PCON24CD" "PCON24NM" "PCON24NMW" ...
str(details)
## 'data.frame': 650 obs. of 32 variables:
## $ ONS.ID : chr "W07000081" "S14000060" "S14000061" "S14000062" ...
## $ ONS.region.ID : chr "W92000004" "S92000003" "S92000003" "S92000003" ...
## $ Constituency.name : chr "Aberafan Maesteg" "Aberdeen North" "Aberdeen South" "Aberdeenshire North and Moray East" ...
## $ County.name : logi NA NA NA NA NA NA ...
## $ Region.name : chr "Wales" "Scotland" "Scotland" "Scotland" ...
## $ Country.name : chr "Wales" "Scotland" "Scotland" "Scotland" ...
## $ Constituency.type : chr "County" "Burgh" "Burgh" "County" ...
## $ Declaration.time : logi NA NA NA NA NA NA ...
## $ Member.first.name : chr "Stephen" "Kirsty" "Stephen" "Seamus" ...
## $ Member.surname : chr "Kinnock" "Blackman" "Flynn" "Logan" ...
## $ Member.gender : chr "Male" "Female" "Male" "Male" ...
## $ Result : chr "Lab hold" "SNP hold" "SNP hold" "SNP gain from Con" ...
## $ First.party : chr "Lab" "SNP" "SNP" "SNP" ...
## $ Second.party : chr "RUK" "Lab" "Lab" "Con" ...
## $ Electorate : int 72580 75925 77328 70058 70199 78553 70268 70680 74025 75790 ...
## $ Valid.votes : int 35755 42095 46345 38188 36666 48544 40912 41201 51452 43392 ...
## $ Invalid.votes : int 79 115 178 170 95 179 156 74 184 135 ...
## $ Majority : int 10354 1760 3758 942 7547 5683 4294 6122 4174 8794 ...
## $ Con : int 2903 5881 11300 12513 1696 14081 15901 3127 16624 7892 ...
## $ Lab : int 17838 12773 11455 3876 18871 19764 11607 18039 20798 18395 ...
## $ LD : int 916 2583 2921 2782 725 4052 1755 1151 4727 2065 ...
## $ RUK : int 7484 3781 3199 5562 2971 8210 9903 3804 4961 9601 ...
## $ Green : int 1094 1275 1609 0 0 2155 1746 1421 3699 1926 ...
## $ SNP : int 0 14533 15213 13455 11324 0 0 11917 0 0 ...
## $ PC : int 4719 0 0 0 0 0 0 0 0 1938 ...
## $ DUP : int 0 0 0 0 0 0 0 0 0 0 ...
## $ SF : int 0 0 0 0 0 0 0 0 0 0 ...
## $ SDLP : int 0 0 0 0 0 0 0 0 0 0 ...
## $ UUP : int 0 0 0 0 0 0 0 0 0 0 ...
## $ APNI : int 0 0 0 0 0 0 0 0 0 0 ...
## $ All.other.candidates : int 801 1269 648 0 1079 282 0 1742 643 1575 ...
## $ Of.which.other.winner: int 0 0 0 0 0 0 0 0 0 0 ...
It is called PCON24CD in the boundaries object and ONS.ID in the details Data Frame. This is the Office for National Statistics identifier for each constituency. This is useful as it allows us to join the data together. Join the boundaries and details objects into a single map_data object using a left join.
map_data <- left_join(boundaries, details, by = c("PCON24CD" = "ONS.ID"))
This is nice but it would also be good to have the colour of each party that won a seat displayed in the map. I manually found the colours associated with each UK political party on Wikipedia (Link) and saved them in a data frame using the code below. I recommend copying this code into your file if you are following along.
parties <- unique(details$First.party)
colour <- c( "#E4003B", "#FDF38E", "#0087DC", "#12B6CF", "#FAA61A", "#D46A4C", "#326760","#2AA82C", "#DCDCDC", "#02A95B", "#005B54", "black","#F6CB2F", "#0C3A6A", "#48A5EE")
party_colours <- cbind(parties, colour)%>%
data.frame()
The name of the political parties is common between the party_colours Data Frame and the map_data object so we can use another left_join.
map_data_2 <- left_join(map_data, party_colours, by = c("First.party" = "parties"))
We can now go back to the map, add the colours and fiddle with the graphics settings a bit. Change the data argument over to map_data_2. Add the following arguments inside the addPolygons function: fillColor, fillOpacity, color and weight (Note: the English spelling of colour will cause an error here so the American spelling must be used – Bloody Nora!). The fillColor and color arguments are for the insides of the polygon and the borders respectively. In order to colour based on something in the data a ~ needs to be used before calling. This applies more generally. Weight controls the weight of the borders and fillOpacity controls the Opacity of the colours – 1 being fully opaque.
map <- leaflet(data = map_data_2)%>%
addTiles()%>%
addPolygons(fillColor = ~colour,
fillOpacity = 0.75,
color = "black",
weight = 0.25)
map
This map is now much prettier. Next we will add some information to it.
It is possible to add Popups so that when you click on each individual constituency, you learn more details about it. This is technically quite simple to do but require some knowledge of HTML (knowledge I don’t really have) so I’d recommend using ChatGPT (other LLMs are available) to help with the construction of this. The popup argument needs to be a character which has text written in HTML style to be displayed. See below my attempt to get each constituency to display its name, the winning party and the name of the MP.
map <- leaflet(data = map_data_2)%>%
addTiles()%>%
addPolygons(fillColor = ~colour,
fillOpacity = 0.75,
color = "black",
weight = 0.25,
popup = paste0("<b>Constituency: </b>", map_data_2$PCON24NM, "<br>",
"<b>Party: </b>", map_data_2$First.party, "<br>",
"<b>MP Name: </b>", map_data_2$Member.first.name,
" ", map_data_2$Member.surname))
map
This then creates an interactive popup that allows you to visualise the information present in the original data set. Top tip: Create your popups in the original data set when cleaning and assign it to a variable called something like popup_text to call in this function. This prevents it becoming too complicated to look at and can enable more interesting popups.
I have added a more enhanced version of this map below which I have created through enhancing the features of this data.
## Reading layer `Westminster_Parliamentary_Constituencies_July_2024_Boundaries_UK_BGC_1778715659092224182' from data source `C:\Users\MatthewGStubbs\Desktop\Electoral Boundaries\Westminster_Parliamentary_Constituencies_July_2024_Boundaries_UK_BGC_1778715659092224182.geojson'
## using driver `GeoJSON'
## Simple feature collection with 650 features and 9 fields
## Geometry type: MULTIPOLYGON
## Dimension: XY
## Bounding box: xmin: -116.1487 ymin: 5352.6 xmax: 655653.8 ymax: 1220302
## Projected CRS: OSGB36 / British National Grid
## [1] "<strong>Constituency Name:</strong> Aberafan Maesteg<br><strong>Winning Party: </strong><span style=\"color:#E4003B;\">Labour</span><br><strong>MP Name:</strong> Stephen Kinnock<br><strong>Turnout: </strong>49% <br>Labour: 50%<br>Reform UK: 21%<br>Plaid_Cymru: 13%<br>Conservative: 8%<br>Green: 3%<br>Liberal Democrat: 3%<br>Other: 2%<br>"