10 Interactive Visualisation
This chapter is about creating interactive visualisations from R, and is organised into three parts:
Shiny apps for data interactivity (Sections 10.1–10.4):
ggplot2alone produces static images. Shiny wraps those images in a web application where selecting a different input — a year, a variable name — instantly redraws the plot.Interactive maps with
leaflet(Section 10.5): Staticggplot2maps cannot be panned or zoomed.leafletproduces interactive maps that live in the browser: users can zoom, pan, and hover over individual features to reveal information. This part covers theleafletAPI through a global dataset of submarine cables.Combining Shiny and
leaflet(Section 10.6): Shiny provides interactivity with the data (filtering, selecting variables);leafletprovides interactivity with the map (zoom, pan, hover). Combining the two gives full interactivity for geospatial visualisations.
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 hover over features directly in the browser,
with no Shiny server required. This section illustrates the leaflet API
using a global dataset of submarine internet cables.
10.5.1 The data
The dataset is a GeoJSON file of 849 submarine internet cables as LINESTRINGs
(source: https://github.com/lifewinning/submarine-cable-taps). Download
submarine_cables.geojson from the link above or from Canvas and save it to
your working directory. It is loaded with sf::read_sf, which converts a
GeoJSON file directly into an sf object:
library(sf)
library(dplyr)
cables <- read_sf("submarine_cables.geojson") |>
st_zm(drop = TRUE, what = "ZM")The key columns 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.5.2 A basic leaflet map
The leaflet API mirrors ggplot2’s layered approach. Each layer is added
with a pipe rather than +, but the structural parallel is direct:
ggplot2 |
leaflet |
|---|---|
ggplot(cables) |
leaflet(cables) |
+ geom_sf(aes(colour = color)) |
|> addPolylines(color = ~color) |
+ scale_colour_identity() |
not needed; ~color passes hex strings directly |
background geom_sf(data = europe_map) |
|> addTiles() (OpenStreetMap tiles) |
coord_sf(xlim, ylim) |
map is pannable/zoomable by default |
The ~ (tilde) inside leaflet functions tells leaflet to evaluate an
expression against the data — the same role that aes() plays in ggplot2.
A minimal interactive map for the cables data needs just three calls:
library(leaflet)
leaflet(cables) |>
addTiles() |>
addPolylines(color = ~color, weight = 2, opacity = 0.8)leaflet()initialises the map (analogous toggplot())addTiles()fetches an OpenStreetMap tile layer as the backgroundaddPolylines()draws the cable LINESTRINGs;color = ~colorpasses each cable’s hex string directly, matching the role ofscale_colour_identity()
Unlike ggplot2, the result is already interactive: the user can zoom and pan
across the entire globe.
10.5.3 Hover labels
The label argument in addPolylines() provides a hover tooltip shown
when the cursor moves over a cable. 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 (click-to-reveal), a plainpaste0()is usually enough because popups are produced one at a time; labels are pre-built for all features at once.
10.6 Shiny + leaflet
Wrapping a leaflet map in Shiny lets users control which data is displayed,
combining data interactivity (Shiny) with map interactivity (leaflet). The
key difference from the ggplot2 workflow 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 a cable owner from a dropdown. Only the cables belonging to the selected owner are drawn, while the hover labels and highlight behaviour from the previous section are retained:
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 Owner",
selectInput(
inputId = "owner",
label = "Select owner:",
choices = sort(unique(cables$owners)),
selected = sort(unique(cables$owners))[1]
),
leafletOutput("map", height = 600)
)
server <- function(input, output, session) {
output$map <- renderLeaflet({
cables_sel <- cables |> filter(owners == input$owner)
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)A few things to notice:
- The data is loaded once, outside
server, so the full GeoJSON is read only when the app starts. filter(owners == input$owner)runs insiderenderLeaflet(), so the map re-renders with the filtered cables whenever the user changes the selection.sort(unique(cables$owners))builds the list of choices dynamically from the data; no hard-coded owner names are needed.- The hover label code is identical to the standalone
leafletexample in the previous section. Building the static map first made it straightforward to verify the labels worked before adding the Shiny wrapper.
10.7 Summary
This chapter has covered three layers of interactivity for data visualisation in R:
Part 1 (Shiny +
ggplot2): reactive inputs driveggplot2plots, allowing users to filter or switch between variables without rewriting code. Shiny provides data interactivity.Part 2 (
leaflet): geospatial data rendered as an interactive map that can be zoomed, panned, and hovered over directly in the browser.leafletprovides map interactivity.Part 3 (Shiny +
leaflet): combining the two frameworks gives full interactivity — users can both select what is shown on the map (via Shiny inputs) and explore it spatially (vialeaflet).
The table below summarises which packages are needed for different visualisation requirements:
| Requirement | Packages needed |
|---|---|
| Multivariate data (no map) | shiny + ggplot2 |
| Geospatial data, no map interactivity | shiny + sf + ggplot2 |
| Geospatial data, map interactivity only | leaflet + sf |
| Geospatial data, full interactivity | shiny + leaflet + sf |