10 Interactive Visualisation

This chapter is about creating interactive visualisations from R, and is organised into three parts:

  1. Shiny apps for data interactivity (Sections 10.110.4): ggplot2 alone 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.

  2. Interactive maps with leaflet (Section 10.5): Static ggplot2 maps cannot be panned or zoomed. leaflet produces interactive maps that live in the browser: users can zoom, pan, and hover over individual features to reveal information. This part covers the leaflet API through a global dataset of submarine cables.

  3. Combining Shiny and leaflet (Section 10.6): Shiny provides interactivity with the data (filtering, selecting variables); leaflet provides 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 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 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 consortium
  • geometry: 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 to ggplot())
  • addTiles() fetches an OpenStreetMap tile layer as the background
  • addPolylines() draws the cable LINESTRINGs; color = ~color passes each cable’s hex string directly, matching the role of scale_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; leaflet requires this form (rather than a plain character vector) to render tags in labels. For popups (click-to-reveal), 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 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 inside renderLeaflet(), 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 leaflet example 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 drive ggplot2 plots, 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. leaflet provides 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 (via leaflet).

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