This practical covers interactive geospatial visualisation using leaflet and
Shiny. There are two worked examples:
For each example you will first build a standalone leaflet map (no Shiny
needed — run the code directly in the console or in an R script), then wrap
it in a Shiny app that adds a dropdown selector. This mirrors the
static first, then reactive principle from the notes.
leaflet mapDownload the Northern Ireland Super Data Zone boundaries from NISRA at
https://www.nisra.gov.uk/publications/super-data-zone-boundaries-gis-format
(choose the ESRI Shapefile option), or from Canvas. Extract the files into a
folder geography-sdz2021-esri-shapefile/ in your working directory.
Load the NI SDZ shapefile, transform it to WGS84 (which leaflet
requires), and inspect the result.
library(sf)
ni_sdz <- read_sf("geography-sdz2021-esri-shapefile/SDZ2021.shp") |>
st_transform(4326)
names(ni_sdz)
st_crs(ni_sdz)$epsg
Answer: The shapefile has columns SDZ2021_cd, SDZ2021_nm, DEA2014_cd, DEA2014_nm, LGD2014_cd, LGD2014_nm, Area_ha, Perim_km, and geometry. After st_transform(4326) the CRS is EPSG 4326 (WGS84), which leaflet requires.
The shapefile contains only boundary and administrative metadata. The
accompanying census dataset ni-census-sdz2021.csv (available on Canvas)
records population counts and density per Super Data Zone. If you wanted to
create a choropleth map (colouring zones by population density), you would
need to join the two datasets on SDZ2021_cd.
Build a minimal leaflet map of the NI SDZ polygons using leaflet(),
addTiles(), and addPolygons().
Before writing the leaflet code, think about the equivalent static map
you would create with ggplot2: what function draws polygon features, and
what provides the background? How does the leaflet approach mirror that
structure?
library(leaflet)
leaflet(ni_sdz) |>
addTiles() |>
addPolygons()
Answer: The ggplot2 equivalent would be ggplot(ni_sdz) + geom_sf() with a rnaturalearth background added via a separate geom_sf() layer. In leaflet, addTiles() replaces the background layer and addPolygons() replaces geom_sf(). The result is already pannable and zoomable, unlike the static ggplot2 output.
Run this in the console (not inside a Shiny app). The result should be a pannable, zoomable map of Northern Ireland with all Super Data Zone boundaries drawn.
Add a popup to the map so that clicking a polygon reveals the SDZ name
and district. Use paste0() to build the HTML string.
leaflet(ni_sdz) |>
addTiles() |>
addPolygons(
color = "steelblue",
fillOpacity = 0.4,
weight = 1,
popup = ~paste0(
"<b>", SDZ2021_nm, "</b><br>",
"District: ", LGD2014_nm, "<br>",
"DEA: ", DEA2014_nm
)
)
Click any Super Data Zone to verify the pop-up appears. Note that popup
(click to reveal) and label (hover tooltip) can be used interchangeably;
for polygons, clicking is often more convenient than hovering.
Write a Shiny app that provides a selectInput for LGD2014_nm (the 11
NI councils) and filters ni_sdz to the selected district inside
renderLeaflet(), showing the pop-ups from question 3 for the filtered
data.
Load the data once outside the server function. Inside
renderLeaflet(), use filter(LGD2014_nm == input$district).
library(shiny)
library(leaflet)
library(dplyr)
library(sf)
ni_sdz <- read_sf("geography-sdz2021-esri-shapefile/SDZ2021.shp") |>
st_transform(4326)
ui <- fluidPage(
title = "NI Super Data Zones",
selectInput(
inputId = "district",
label = "Select Local Government District:",
choices = sort(unique(ni_sdz$LGD2014_nm)),
selected = "Belfast"
),
leafletOutput("map", height = 600)
)
server <- function(input, output, session) {
output$map <- renderLeaflet({
ni_sel <- ni_sdz |> filter(LGD2014_nm == input$district)
leaflet(ni_sel) |>
addTiles() |>
addPolygons(
color = "steelblue",
fillOpacity = 0.4,
weight = 1,
popup = ~paste0(
"<b>", SDZ2021_nm, "</b><br>",
"District: ", LGD2014_nm, "<br>",
"DEA: ", DEA2014_nm
)
)
})
}
shinyApp(ui = ui, server = server)Try changing the selectInput to use DEA2014_nm (District Electoral
Areas) instead of LGD2014_nm. How does the number of choices and the
granularity of the filtered map change?
Answer: Replacing LGD2014_nm with DEA2014_nm gives around 80 choices instead of 11, each covering a smaller geographic area. The filtered maps show fewer polygons but at finer resolution, making it easier to inspect individual Super Data Zones within a single electoral area.
leaflet mapDownload submarine_cables.geojson from
https://github.com/lifewinning/submarine-cable-taps or from Canvas and save
it to your working directory.
Load the cables GeoJSON file with read_sf(). Drop the Z/M dimensions
with st_zm(). Inspect the key columns (name, color, length, rfs,
owners).
library(sf)
cables <- read_sf("submarine_cables.geojson") |>
st_zm(drop = TRUE, what = "ZM")
names(cables)
head(cables$owners)Build a minimal standalone leaflet map of the cables using
addPolylines(). Use color = ~color to colour each cable with its own
hex string (no palette function needed). Do not add any labels or
pop-ups yet.
Before writing the leaflet code, think about the equivalent static map
with ggplot2: what function draws linestring features? What argument
maps the color column to the line colour, and what scale ensures the hex
strings are used directly?
library(leaflet)
leaflet(cables) |>
addTiles() |>
addPolylines(color = ~color, weight = 2, opacity = 0.8)
Answer: The ggplot2 equivalent is ggplot() + geom_sf(data = cables, aes(colour = color)) + scale_colour_identity(). In leaflet, addPolylines(color = ~color) replaces both geom_sf() and scale_colour_identity() — the tilde formula passes the hex strings directly without a palette mapping. The result is immediately interactive: no fixed bounding box is needed as the user can zoom and pan across the globe.
Extend the map with hover labels. Follow the pattern from the notes: use
highlightOptions() to thicken the hovered cable, and wrap the label HTML
strings with lapply(..., htmltools::HTML). Include the cable name,
length, rfs, and owners in the label.
leaflet(cables) |>
addTiles() |>
addPolylines(
color = ~color,
weight = 2,
opacity = 0.8,
highlightOptions = highlightOptions(
weight = 5,
bringToFront = TRUE
),
label = ~lapply(
paste0(
"<b>", name, "</b><br>",
"Length: ", length, "<br>",
"Ready: ", rfs, "<br>",
"Owners: ", owners
),
htmltools::HTML
)
)
Verify that hovering over a cable highlights it and shows the tooltip.
The rfs column records the ready-for-service date as a string (e.g.
"March 2001" or "1989"). A selectInput for rfs lets users explore
which cables became operational in a particular period.
Write a Shiny app that provides a selectInput for rfs (use
sort(unique(cables$rfs)) for the choices), filters the cables to the
selected value inside renderLeaflet(), and draws the filtered cables with
colour, highlight, and hover labels from question 8.
The structure is identical to Example 1: replace addPolygons() with
addPolylines() and filter on rfs instead of LGD2014_nm.
library(shiny)
library(leaflet)
library(dplyr)
library(sf)
cables <- read_sf("submarine_cables.geojson") |>
st_zm(drop = TRUE, what = "ZM")
ui <- fluidPage(
title = "Submarine Cables by Ready-for-Service Date",
selectInput(
inputId = "rfs_date",
label = "Select ready-for-service date:",
choices = sort(unique(cables$rfs)),
selected = sort(unique(cables$rfs))[1]
),
leafletOutput("map", height = 600)
)
server <- function(input, output, session) {
output$map <- renderLeaflet({
cables_sel <- cables |> filter(rfs == input$rfs_date)
leaflet(cables_sel) |>
addTiles() |>
addPolylines(
color = ~color,
weight = 3,
opacity = 0.9,
highlightOptions = highlightOptions(
weight = 6,
bringToFront = TRUE
),
label = ~lapply(
paste0(
"<b>", name, "</b><br>",
"Length: ", length, "<br>",
"Ready: ", rfs, "<br>",
"Owners: ", owners
),
htmltools::HTML
)
)
})
}
shinyApp(ui = ui, server = server)Explore a few different rfs values. Do any dates have many cables? Do
some have only one? What does this tell you about the history of submarine
cable deployment?
Answer: Most early dates (1989–1995) have only one or two cables, reflecting the small number of international connections at the time. Dates in the early 2000s often have several cables, corresponding to the telecom boom. Selecting a recent date may show no cables if no cable in the dataset has that exact rfs string — a reminder that real data requires cleaning before analysis.
Key concepts from this practical:
leaflet map first (outside Shiny) to verify
the geometry renders correctly before adding interactivity.addPolygons(); for line data, use addPolylines().
Both accept color, popup, label, and highlightOptions().leaflet in Shiny by replacing plotOutput/renderPlot with
leafletOutput/renderLeaflet. Load data once outside server.leaflet requires WGS84 (EPSG:4326). Use st_transform(4326) for
any data not already in that CRS.Functions introduced:
| Function | Purpose |
|---|---|
leaflet() |
Initialise a leaflet map |
addTiles() |
Add OpenStreetMap background tiles |
addPolygons() |
Draw polygon/multipolygon features |
addPolylines() |
Draw linestring features |
highlightOptions() |
Configure hover highlight behaviour |
leafletOutput() |
Leaflet output placeholder in Shiny UI |
renderLeaflet() |
Render a leaflet map in Shiny server |
st_transform(4326) |
Reproject to WGS84 for leaflet |