10 Interactive Visualisation
This chapter is about creating interactive web applications from R.
Interactivity can mean different things: in the first half, we work with apps
where selecting a different input — a year, a variable name — produces a
different plot, combining ggplot2 with the Shiny package. In the second
half, we look at more intuitive interactivity — the kind where the user can
zoom, pan, and hover over individual map features to reveal information — with
a focus on geospatial visualisation using leaflet alongside Shiny.
10.1 Shiny basics
Shiny is an R package for building interactive web applications directly from R, with no web-development knowledge required. A Shiny app has two main components:
- UI (user interface): defines the layout, input controls, and output placeholders shown in the browser.
- Server: R code that reacts to user inputs and produces outputs (plots, tables, text, etc.).
The central idea is reactivity: whenever the user changes an input, the server automatically re-runs the relevant code and updates the displayed output.
10.1.1 Minimal structure
A Shiny app lives in a single file conventionally named app.R:
library(shiny)
ui <- fluidPage(
# input controls and output placeholders go here
)
server <- function(input, output, session) {
# reactive code goes here
}
shinyApp(ui = ui, server = server)fluidPage()creates a responsive, fluid layout.- The
serverfunction receives three arguments:input(current values of all UI controls),output(a list to write rendered outputs into), andsession(session-level information, rarely needed directly). shinyApp()ties the UI and server together and launches the app.
To run the app, open app.R in RStudio and click Run App, or call
shiny::runApp() in the console. The app opens in your default browser or in
the RStudio Viewer pane.
10.2 Toggles and controls
Shiny provides many input widgets that collect user choices and make them
available in the server via the input object:
| Function | Description |
|---|---|
sliderInput() |
A draggable slider for numeric (or date) values |
selectInput() |
A dropdown menu |
checkboxInput() |
A single checkbox (returns TRUE/FALSE) |
checkboxGroupInput() |
A group of checkboxes (returns a character vector) |
radioButtons() |
Mutually exclusive radio buttons |
textInput() |
A free-text entry box |
numericInput() |
A numeric entry box with up/down arrows |
In this module we focus on sliderInput() and selectInput(); the others
behave similarly and are self-explanatory from the table above.
Every widget shares three key arguments:
inputId: the name used in the server to read this input’s value (e.g.input$year).label: the text label shown above the control.- A value-specific argument for the initial value (usually called
value).
10.2.1 sliderInput()
For selecting a year from a numeric range, sliderInput() is the natural
choice:
sliderInput(
inputId = "year",
label = "Select year:",
min = 1968,
max = 2014,
value = 2000, # initial value when the app loads
step = 1 # move one year at a time
)In the server, the currently selected year is read as input$year.
10.2.2 plotOutput() and renderPlot()
To display a plot, pair plotOutput() in the UI with renderPlot() in the
server. The outputId in plotOutput() must match the name used on the left
of output$... in the server:
# in the UI:
plotOutput("my_plot")
# in the server:
output$my_plot <- renderPlot({
# ggplot2 code here
})10.3 A complete example
We now build a Shiny app using ggplot2::economics, which records monthly US
economic indicators from July 1967 to April 2015. The variables include:
date: date of observation (monthly)pce: personal consumption expenditure (billion USD)pop: total US population (thousands)psavert: personal savings rate (%)uempmed: median duration of unemployment (weeks)unemploy: number of unemployed (thousands)
The app provides a slider to select a year and displays a line-and-point plot
of the monthly unemployment count (unemploy) over date for that year.
10.3.1 Step 1: get the static plot right
Before adding interactivity, write the ggplot2 code for a fixed year to
confirm the plot looks correct:
economics |>
filter(year(date) == 2000) |>
ggplot(aes(x = date, y = unemploy)) +
geom_line() +
geom_point() +
labs(x = "Month", y = "Unemployed (thousands)")
Figure 10.1: Monthly US unemployment count in 2000.
Because date is a proper Date column, ggplot2 formats the axis labels
automatically. After filtering to a single year, there are twelve monthly
data points joined by a line.
10.3.2 Step 2: replace the fixed year with a reactive input
The complete app produces two plots side by side, both driven by the same
year slider: the unemployment line-and-point plot from Step 1, and a
scatterplot of the personal savings rate (psavert) against the median
unemployment duration (uempmed) for the same year.
Inside each renderPlot() block, always follow this two-step pattern:
- Filter first — create a named data subset for the selected input.
- Plot second — pass that subset to
ggplot().
This keeps the plot code clean and makes it easy to see exactly what data each plot is using.
library(shiny)
library(ggplot2)
library(dplyr)
library(lubridate)
ui <- fluidPage(
title = "MAS2908 Data Visualisation - Interactivity Demonstration",
sliderInput(
inputId = "year",
label = "Select year:",
min = 1968,
max = 2013,
value = 2000,
step = 1
),
fluidRow(
column(width = 6, plotOutput("unemploy_plot")),
column(width = 6, plotOutput("savings_plot"))
)
)
server <- function(input, output, session) {
output$unemploy_plot <- renderPlot({
econ_year <- economics |> filter(year(date) == input$year)
ggplot(econ_year, aes(x = date, y = unemploy)) +
geom_line() +
geom_point(size = 3) +
theme_bw(20) +
labs(x = "Month", y = "Unemployed (thousands)")
})
output$savings_plot <- renderPlot({
econ_year <- economics |> filter(year(date) == input$year)
ggplot(econ_year, aes(x = psavert, y = uempmed)) +
geom_point(size = 3) +
theme_bw(20) +
labs(x = "Personal savings rate (%)",
y = "Median unemployment duration (weeks)")
})
}
shinyApp(ui = ui, server = server)fluidRow()arranges its children in a horizontal row; eachcolumn(width = 6, ...)takes half the page (Bootstrap uses a 12-unit grid, so two columns of width 6 fill the row).- Both
renderPlot()calls read the sameinput$year: when the slider moves, both plots update simultaneously. - Inside each
renderPlot(), the filtered data is stored in a local object (econ_year) before being passed toggplot(). - Moving to 2008–2009 reveals the sharp rise in unemployment alongside a simultaneous uptick in savings as households cut spending during the financial crisis.
10.4 selectInput()
A sliderInput() is ideal for a continuous numeric range with many possible
values. When the number of choices is smaller, or when choices are text labels
rather than numbers, a selectInput() (dropdown menu) is often clearer.
As an alternative to the year slider above, the year can be chosen from a dropdown list:
selectInput(
inputId = "year",
label = "Select year:",
choices = 1968:2014,
selected = 2000
)The rest of the app — the server function, both renderPlot() calls, and
input$year — is identical. Only the input widget in the UI changes.
This illustrates an important principle: different input types can achieve
the same result, and the choice between them is purely about usability.
One obvious alternative is numericInput(), which displays a text box with
up/down arrows; it too uses input$year in the server without any other
changes.
When deciding which widget to use, let the number of choices guide you:
| Scenario | Recommended widget |
|---|---|
| Continuous numeric range (many values) | sliderInput() |
| Short list of numbers or text (2–5 options) | radioButtons() |
| Longer list (6+ options) | selectInput() |
| Free numeric entry | numericInput() |
| Multiple simultaneous selections | checkboxGroupInput() |
10.5 Interactive maps with leaflet
In Chapter 9, static maps were built with ggplot2 and geom_sf().
leaflet is a companion package that produces interactive maps: users can
zoom, pan, and click on features directly in the browser, with no extra
server-side code. This section takes the Northern Ireland Super Data Zone data
from the practicals and makes it fully interactive.
10.5.1 Data preparation
The NI 2021 Census data was saved as a CSV with a WKT geometry column and is
already in WGS84 (EPSG:4326) — the coordinate reference system that leaflet
requires. Re-read it as follows:
library(sf)
library(dplyr)
library(readr)
ni_sdz <- read_csv("ni-census-sdz2021.csv") |>
mutate(geometry = st_as_sfc(WKT, crs = 4326L)) |>
select(-WKT) |>
st_as_sf()The key variables are:
SDZ2021_nm: Super Data Zone nameLGD2014_nm: Local Government District (one of 11 NI councils)population: total resident population (2021 Census)density: population per hectare
10.5.2 A basic leaflet map
The leaflet API mirrors ggplot2’s layered approach. A minimal interactive
map needs three calls:
library(leaflet)
leaflet(ni_sdz) |>
addTiles() |>
addPolygons()leaflet()initialises the map (analogous toggplot())addTiles()fetches an OpenStreetMap tile layer as the background; tiles are downloaded on demand and update as the user zooms inaddPolygons()draws the SDZ boundaries from thegeometrycolumn
10.5.3 Choropleth: colouring by a variable
To colour polygons by a variable — here, population density — first create a
palette function with colorNumeric(), then pass it to fillColor:
library(leaflet)
pal <- colorNumeric(palette = "viridis", domain = ni_sdz$density)
leaflet(ni_sdz) |>
addTiles() |>
addPolygons(
fillColor = ~pal(density),
fillOpacity = 0.7,
color = "white",
weight = 1
) |>
addLegend(
pal = pal,
values = ~density,
title = "Population<br>density (per ha)"
)The ~ (tilde) inside addPolygons() and addLegend() tells leaflet to
evaluate the expression against the data — the same role that aes() plays in
ggplot2. colorNumeric() returns a function that maps numeric values to hex
colours; colorBin() and colorQuantile() provide binned alternatives.
10.5.4 Making areas clickable
The popup argument accepts an HTML string shown when a user clicks a polygon.
Build it dynamically from the data using paste0():
leaflet(ni_sdz) |>
addTiles() |>
addPolygons(
fillColor = ~pal(density),
fillOpacity = 0.7,
color = "white",
weight = 1,
popup = ~paste0(
"<b>", SDZ2021_nm, "</b><br>",
"District: ", LGD2014_nm, "<br>",
"Population: ", format(population, big.mark = ","), "<br>",
"Density: ", round(density, 1), " per ha"
)
) |>
addLegend(pal = pal, values = ~density,
title = "Population<br>density (per ha)")Clicking any Super Data Zone opens a pop-up card with its name, district,
population, and density. The label argument (not shown) provides a hover
tooltip instead; both can be specified simultaneously.
10.5.5 A complete Shiny + leaflet app
Wrapping a leaflet map in Shiny lets users control what is displayed. The key
difference from renderPlot() / plotOutput() is that leaflet has its own
output/render pair:
ggplot2 workflow |
leaflet workflow |
|---|---|
plotOutput("id") in UI |
leafletOutput("id") in UI |
renderPlot({ ... }) in server |
renderLeaflet({ ... }) in server |
The following app lets the user choose between population and density from a dropdown, then redraws the choropleth and popups accordingly:
library(shiny)
library(leaflet)
library(dplyr)
library(readr)
library(sf)
ni_sdz <- read_csv("ni-census-sdz2021.csv") |>
mutate(geometry = st_as_sfc(WKT, crs = 4326L)) |>
select(-WKT) |>
st_as_sf()
ui <- fluidPage(
title = "NI 2021 Census: Super Data Zones",
selectInput(
inputId = "variable",
label = "Colour by:",
choices = c("Population" = "population",
"Density (per ha)" = "density"),
selected = "density"
),
leafletOutput("map", height = 600)
)
server <- function(input, output, session) {
output$map <- renderLeaflet({
values <- ni_sdz[[input$variable]]
pal <- colorNumeric("viridis", domain = values)
leaflet(ni_sdz) |>
addTiles() |>
addPolygons(
fillColor = ~pal(values),
fillOpacity = 0.7,
color = "white",
weight = 1,
popup = ~paste0(
"<b>", SDZ2021_nm, "</b><br>",
"District: ", LGD2014_nm, "<br>",
"Population: ", format(population, big.mark = ","), "<br>",
"Density: ", round(density, 1), " per ha"
)
) |>
addLegend(pal = pal, values = values,
title = input$variable)
})
}
shinyApp(ui = ui, server = server)A few things to note:
- The data is loaded once, outside the server function, so it is not re-read every time the user changes a selection.
ni_sdz[[input$variable]]extracts the selected column as a plain vector, which is whatcolorNumeric()andaddPolygons()need.- The popups always show both population and density regardless of which variable is coloured, giving users richer context without extra controls.
leafletOutput("map", height = 600)sets the map height in pixels; without an explicit height the map defaults to a small fixed size.
10.6 Submarine cables: a worked example
The sections above introduced leaflet with polygon data (NI Super Data
Zones). Line geometries (LINESTRINGs) work almost identically, but the drawing
function changes from addPolygons() to addPolylines(). This section works
through a complete example — from a static ggplot2 map to a Shiny +
leaflet app — using a global dataset of submarine internet cables.
10.6.1 The data
The GeoJSON file data/submarine_cables.geojson (source:
https://github.com/lifewinning/submarine-cable-taps) contains 849 cables as
LINESTRINGs. It is loaded with sf::read_sf, which converts a
GeoJSON file directly into an sf object:
library(sf)
library(dplyr)
cables <- read_sf("data/submarine_cables.geojson") |> st_zm(drop = TRUE, what = "ZM")The third column is a raw HTML blob carried over from a Google Fusion Table
export; renaming it to comment prevents it from being mistaken for a usable
data variable. The key columns after loading are:
name: cable name, e.g."TAT-14"color: the cable’s display colour as a hex string, e.g."#e85113"length: cable length as a string, e.g."15,428 km"rfs: ready-for-service date, e.g."March 2001"owners: the owning consortiumgeometry: LINESTRING coordinates in WGS84 (EPSG:4326)
10.6.2 Step 1: static plot with ggplot2
Following the same principle as before, write the static version first. The
script cables.R plots the cables around the UK and North Sea using a
background from rnaturalearth:
library(ggplot2)
library(rnaturalearth)
library(rnaturalearthdata)##
## Attaching package: 'rnaturalearthdata'
## The following object is masked from 'package:rnaturalearth':
##
## countries110
europe_map <- ne_countries(
continent = "europe",
returnclass = "sf",
scale = "medium"
)
uk_map <- ne_countries(
scale = "medium",
country = "united kingdom",
returnclass = "sf"
)
ggplot() +
geom_sf(data = europe_map) +
geom_sf(data = uk_map, fill = "white") +
geom_sf(data = cables, aes(colour = color)) +
coord_sf(xlim = c(-15, 8), ylim = c(49.5, 60.5)) +
scale_colour_identity()
Figure 10.2: Submarine cables around the UK and North Sea, each drawn in its own colour.
scale_colour_identity() tells ggplot2 to use the hex string in the color
column directly as the colour, bypassing the usual palette mapping. Each cable
already carries its own display colour in the data.
10.6.3 Step 2: direct leaflet equivalent
Moving from ggplot2 to leaflet for this dataset requires only a few
mechanical substitutions:
ggplot2 |
leaflet |
|---|---|
ggplot() + geom_sf(data = cables, ...) |
leaflet(cables) |> addPolylines(...) |
aes(colour = color) |
color = ~color |
scale_colour_identity() |
not needed; ~color passes hex strings directly |
coord_sf(xlim, ylim) |
map is pannable/zoomable by default |
background geom_sf(data = europe_map) |
addTiles() (OpenStreetMap) |
The basic leaflet equivalent is:
library(leaflet)
leaflet(cables) |>
addTiles() |>
addPolylines(color = ~color, weight = 2, opacity = 0.8)Unlike ggplot2, the result is already interactive: the user can zoom and pan
the entire globe. The ~color formula passes each cable’s hex string directly,
matching the role of scale_colour_identity().
10.6.4 Step 3: hover labels
The label argument in addPolylines() provides a hover tooltip. Because
the tooltip can contain HTML, each label string must be wrapped with
htmltools::HTML() so leaflet renders the tags rather than escaping them.
lapply() applies the wrapper to every row:
library(leaflet)
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
)
)Two things to note:
highlightOptions(weight = 5, bringToFront = TRUE)thickens the hovered cable and brings it to the top of the drawing order, giving clear visual feedback for narrow lines that would otherwise be hard to target.lapply(..., htmltools::HTML)produces a list of HTML objects;leafletrequires this form (rather than a plain character vector) to render tags in labels. For popups, a plainpaste0()is usually enough because popups are produced one at a time; labels are pre-built for all features at once.
10.6.5 Step 4: a Shiny app with regional views
Wrapping the map in Shiny follows exactly the same pattern as the NI example:
replace plotOutput / renderPlot with leafletOutput / renderLeaflet.
The Shiny element here is a selectInput that jumps the view to one of five
pre-defined regions; all 849 cables are always drawn and the hover labels
always work — only the initial viewport changes.
library(shiny)
library(leaflet)
library(dplyr)
library(sf)
cables <- read_sf("data/submarine_cables.geojson") |> st_zm(drop = TRUE, what = "ZM")
# Bounding boxes: c(lng_min, lat_min, lng_max, lat_max)
regions <- list(
"Global" = c(-180, -70, 180, 80),
"UK & North Sea" = c( -15, 49, 8, 61),
"Europe & North Atlantic" = c( -80, 25, 40, 72),
"Indian Ocean" = c( 20, -45, 110, 30),
"Asia & Southeast Asia" = c( 60, -15, 150, 45)
)
ui <- fluidPage(
title = "Global Submarine Cables",
selectInput(
inputId = "region",
label = "Jump to region:",
choices = names(regions),
selected = "Global"
),
leafletOutput("map", height = 600)
)
server <- function(input, output, session) {
output$map <- renderLeaflet({
bbox <- regions[[input$region]]
leaflet(cables) |>
addTiles() |>
fitBounds(
lng1 = bbox[1], lat1 = bbox[2],
lng2 = bbox[3], lat2 = bbox[4]
) |>
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
)
)
})
}
shinyApp(ui = ui, server = server)A few things to notice:
- The data is loaded once, outside
server, so it is not re-read every time the user changes the region selection. fitBounds()sets the initial viewport for the chosen region. Because it is insiderenderLeaflet(), the map re-renders and re-zooms on each selection.- All 849 cables are drawn every time; the region selector only changes the
viewpoint. For very large datasets, filtering the data inside
renderLeaflet()would improve performance, but here the full render is fast enough. - The hover label code is identical to Step 3. Separating the map into
“static first, then reactive” makes it straightforward to verify each
piece works before adding the next — exactly the same principle applied to
the
ggplot2examples in the first half of this chapter.