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 server function receives three arguments: input (current values of all UI controls), output (a list to write rendered outputs into), and session (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)")
Monthly US unemployment count in 2000.

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:

  1. Filter first — create a named data subset for the selected input.
  2. 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; each column(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 same input$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 to ggplot().
  • 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 name
  • LGD2014_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 to ggplot())
  • addTiles() fetches an OpenStreetMap tile layer as the background; tiles are downloaded on demand and update as the user zooms in
  • addPolygons() draws the SDZ boundaries from the geometry column

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 what colorNumeric() and addPolygons() 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 consortium
  • geometry: 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()
Submarine cables around the UK and North Sea, each drawn in its own colour.

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; leaflet requires this form (rather than a plain character vector) to render tags in labels. For popups, a plain paste0() 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 inside renderLeaflet(), 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 ggplot2 examples in the first half of this chapter.