7 Graphics

We talked briefly about renderPlot() in Chapter 3; it’s a powerful tool for displaying graphics in your app. This chapter will show you how to use it to its full extent to create interactive plots, plots that respond to mouse events. You’ll also learn about two important related functions: renderCachedPlot(), which speeds up your app by caching frequently used plots, and renderImage(), which allows you to display existing images.

In this chapter, we’ll need ggplot2 as well as Shiny, since that’s what I’ll use for the majority of the graphics.

7.1 Interactivity

One of the coolest things about plotOutput() is that as well as being an output that displays plots, it can also be an input that responds to pointer events. That allows you to create interactive graphics where the user interacts directly with the data on the plot. Interactive graphics are a powerful tool, with a wide range of applications. I don’t have space to show you all the possibilities, so here I’ll focus on the basics, then point you towards resources to learn more.

7.1.1 Basics

A plot can respond to four different mouse20 events: click, dblclick (double click), hover (when the mouse stays in the same place for a little while), and brush (a rectangular selection tool). To turn these events into Shiny inputs, you supply a string to the corresponding plotOutput() argument, e.g. plotOutput("plot", click = "plot_click"). This creates an input$plot_click that you can use to handle mouse clicks on the plot.

Here’s a very simple example of handling a mouse click. We register the plot_click input, and then use that to update an output with the coordinates of the mouse click:

ui <- basicPage(
  plotOutput("plot", click = "plot_click"),
  verbatimTextOutput("info")
)

server <- function(input, output) {
  output$plot <- renderPlot({
    plot(mtcars$wt, mtcars$mpg)
  }, res = 96)

  output$info <- renderPrint({
    req(input$plot_click)
    x <- round(input$plot_click$x, 2)
    y <- round(input$plot_click$y, 2)
    cat("[", x, ", ", y, "]", sep = "")
  })
}

(Note the use of req(), to make sure the app doesn’t do anything before the first click, and that the coordinates are in terms of the underlying wt and mpg variables.)

The following sections describe the events in more details. We’ll start with the click events, then briefly discuss the closely related dblclick and hover. Then you’ll learn about the brush event, which provides a rectangular “brush” defined by its four sides (xmin, xmax, ymin, and ymax). I’ll then give a couple of examples of updating the plot with the results of the action, and then discuss some of the limitations of interactive graphics in Shiny.

7.1.2 Clicking

The point events return a relatively rich list containing a lot of information. The most important components are x and y, which give the location of the event in data coordinates. But I’m not going to talk about this data structure, since you’ll only need in relatively rare situations (If you do want the details, use this app in the Shiny gallery). Instead, you’ll use the nearPoints() helper, which finds data points near the event, taking care of a bunch of fiddly details.

Here’s a simple example of nearPoints() in action, showing a table of data about the points near the event:

ui <- fluidPage(
  plotOutput("plot", click = "click"),
  tableOutput("data")
)
server <- function(input, output, session) {
  output$plot <- renderPlot({
    plot(mtcars$wt, mtcars$mpg)
  }, res = 96)
  
  output$data <- renderTable({
    nearPoints(mtcars, input$click, xvar = "wt", yvar = "mpg")
  })
}

Here we give nearPoints() four arguments: the data frame that underlies the plot, the input event, and the names of the variables on the axes. If you use ggplot2, you only need to provide the first two arguments since xvar and yvar can be automatically imputed from the plot data structure. For that reason, I’ll use ggplot2 throughout the rest of the chapter. Here’s that previous example reimplemented with ggplot2:

ui <- fluidPage(
  plotOutput("plot", click = "plot_click"),
  tableOutput("data")
)
server <- function(input, output, session) {
  output$plot <- renderPlot({
    ggplot(mtcars, aes(wt, mpg)) + geom_point()
  }, res = 96)
  
  output$data <- renderTable({
    nearPoints(mtcars, input$plot_click)
  })
}

Another way to use nearPoints() is with allRows = TRUE and addDist = TRUE. That will return the original data frame with two new columns:

  • dist_ gives the distance between the row and the event (in pixels).
  • selected_ says whether or not it should be selected (i.e. the logical vector that’s usually returned).

We’ll see an example of that a little later.

7.1.3 Other point events

The same approach works equally well with click, dblclick, and hover: just change the name of the argument. If needed, you can get additional control over the events by supplying clickOpts(), dblclickOpts(), or hoverOpts() instead of a string giving the input id. These are rarely needed, so I won’t discuss them here; see the documentation for details.

You can use multiple interactions types on one plot. Just make sure to explain to the user what they can do: one downside of using mouse events to interact with an app is that they’re not immediately discoverable21.

7.1.4 Brushing

Another way of selecting points on a plot is to use a brush, a rectangular selection defined by four edges. In Shiny, using a brush is straightforward once you’ve mastered click and nearPoints(): you just switch to brush argument and the brushedPoints() helper.

Here’s another simple example that shows which points have been selected by the brush:

ui <- fluidPage(
  plotOutput("plot", brush = "plot_brush"),
  tableOutput("data")
)
server <- function(input, output, session) {
  output$plot <- renderPlot({
    ggplot(mtcars, aes(wt, mpg)) + geom_point()
  }, res = 96)
  
  output$data <- renderTable({
    brushedPoints(mtcars, input$plot_brush)
  })
}

Use brushOpts() to control the colour (fill and stroke), or restrict brushing to a single dimension with direction = "x" or "y" (useful, e.g., for brushing time series).

7.1.5 Modifying the plot

So far we’ve displayed the results of the interaction in another output. But the true beauty of interactivity comes when you display the changes in the same plot you’re interacting with. Unfortunately this requires an advanced reactivity technique that you have yet learned about: reactiveVal(). We’ll come back to reactiveVal() in Chapter 16, but I wanted to show it here because it’s such a useful technique. You’ll probably need to re-read this section after you’ve read that chapter, but hopefully even without all the theory you’ll get a sense of the potential applications.

As you might guess from the name, reactiveVal() is rather similar to reactive(). You create a reactive value by calling reactiveVal() with its initial value, and retrieve that value in the same way as a reactive:

val <- reactiveVal(10)
val()
#> [1] 10

The big difference is that you can also update reactive values, and all reactive consumers that refer to it will recompute. A reactive value uses a special syntax for updating — you call it like a function with the first argument being the new value:

val(20)
val()
#> [1] 20

That means updating a reactive value using its current value looks something like this:

val(val() + 1)
val()
#> [1] 21

Unfortunately if you actually try to run this code in the console you’ll get an error because it has to be run in an reactive environment. That makes experimentation and debugging more challenging because you’ll need to browser() or similar to pause execution within the call to shinyApp(). This is one of the challenges we’ll come back to later in Chapter 16.

For now, let’s put the challenges of learning reactiveVal() aside, and show you why you might bother. Imagine that you want to visualise the distance between a click and the points on the plot. In the app below, we start by creating a reactive value to store those distances, initialising it with a constant that will be used before we click anything. Then we use observeEvent() to update the reactive value when the mouse is clicked, and a ggplot that visualises the distance with point size. All up, this looks something like:

df <- data.frame(x = rnorm(100), y = rnorm(100))

ui <- fluidPage(
  plotOutput("plot", click = "plot_click")
)
server <- function(input, output, session) {
  dist <- reactiveVal(rep(1, nrow(df)))
  observeEvent(input$plot_click,
    dist(nearPoints(df, input$plot_click, allRows = TRUE, addDist = TRUE)$dist_)  
  )
  
  output$plot <- renderPlot({
    df$dist <- dist()
    ggplot(df, aes(x, y, size = dist)) + 
      geom_point() + 
      scale_size_area(limits = c(0, 1000), max_size = 10, guide = NULL)
  })
}

There are two important ggplot2 techniques to note here:

  • I add the distances to the data frame before plotting. I think it’s good practice to put related variables together in a data frame before visualising it.
  • I set the limits to scale_size_area() to ensure that sizes are comparable across clicks. To find the correct range I did a little interactive experimentation, but you can work out the exact details if needed (see the exercises at the end of the chapter).

Here’s a more complicated idea. I want to use a brush to select (and deselect) points on a plot. Here I display the selection using different colours, but you could imagine many other applications. To make this work, I initialise the reactiveVal() to a vector of FALSEs, then use brushedPoints() and ifelse() toggle their values: if they were previously excluded they’ll be included; if they were previously included, they’ll be excluded.

ui <- fluidPage(
  plotOutput("plot", brush = "plot_brush"),
  tableOutput("data")
)
server <- function(input, output, session) {
  selected <- reactiveVal(rep(TRUE, nrow(mtcars)))

  observeEvent(input$plot_brush, {
    brushed <- brushedPoints(mtcars, input$plot_brush, allRows = TRUE)$selected_
    selected(ifelse(brushed, !selected(), selected()))
  })

  output$plot <- renderPlot({
    mtcars$sel <- selected()
    ggplot(mtcars, aes(wt, mpg)) + 
      geom_point(aes(colour = sel)) +
      scale_colour_discrete(limits = c("TRUE", "FALSE"))
  }, res = 96)
 
}

Again, I set the limits of the scale to ensure that the legend (and colours) don’t change after the first click.

7.1.6 Interactivity limitations

Before we move on, it’s important to understand the basic data flow in interactive plots in order to understand their limitations. The basic flow is something like this:

  1. Javascript captures the mouse event.
  2. Shiny sends the javascript mouse event back to R, invalidating the input.
  3. Downstream reactive consumers are recomputed.
  4. plotOutput() generates a new PNG and sends it to the browser.

For local apps, the bottleneck tends to be the time taken to draw the plot. Depending on how complex the plot is, this may take a signficant fraction of a second. But for hosted apps, you also have to take into account the time needed to transmit the event from the browser to R, and then the rendered plot back from R to the browser.

In general, this means that it’s not possible to create Shiny apps where action and response is percieved as instanteous (i.e. the plot appears to update simultaneously with your action upon it). If you need that level of speed, you’ll have to perform more computation in javascript. One way to do this is to use an R package that wraps a JS graphics library. Right now, as I write this book, I think you’ll get the best experience with the plotly package, as documented in the book Interactive web-based data visualization with R, plotly, and shiny, by Carson Sievert.

7.2 Theming

If you’ve heavily customised the style of your app, you may want to also customise your plots to match. Fortunately, this is very easy thanks to the thematic package by Carson Sievert. There are two main ways to use it. Firstly, you can explicitly set a theme defined by foreground, background, and accent colours (and font if desired):

library(thematic)
thematic_on(bg = "#222222", fg = "white", accent = "#0CE3AC")

library(ggplot2)
ggplot(mtcars, aes(wt, mpg)) +
  geom_point() +
  geom_smooth()

These settings will affect all ggplot2, lattice, and base plots until you call thematic_off().

You can also call thematic_on() with font = "auto" and no other arguments to attempt to automatically determine all of the settings from the theme associated with your Shiny app:

thematic_on(font = "auto")

For more details, see https://rstudio.github.io/thematic/articles/Shiny.html.

7.3 Dynamic height and width

The rest of this chapter is less exciting than interactive graphics, but contains material that’s important to cover somewhere.

First, you can make plot size reactive, so it resizes in response to user actions. To do this, supply zero-argument functions to the width and height arguments. These functions should have no argument and return the desired size in pixels. They are evaluated in a reactive environment so that you can make the size of your plot dynamic.

The following app illustrates the basic idea. It provides two sliders that directly control the size of the plot:

ui <- fluidPage(
  sliderInput("height", "height", min = 100, max = 500, value = 250),
  sliderInput("width", "width", min = 100, max = 500, value = 250),
  sliderInput("n", "n", min = 10, max = 100, value = 25),
  plotOutput("plot", width = 250, height = 250)
)
server <- function(input, output, session) {
  output$plot <- renderPlot(
    width = function() input$width,
    height = function() input$height,
    res = 96,
    {
      plot(rnorm(input$n), rnorm(input$n))
    }
  )
}

Note that when you resize the plot, the data stays the same. This is the same behaviour as when you resize a Shiny app that contains a plot with a dynamic height/width.

In real apps, you’ll use more complicated expressions in the width and height functions. For example, if you’re using a faceted plot in ggplot2, you might use it to increase the size of the plot to keep the individual facet sizes roughly the same22.

7.4 Cached plots

If you have an app with complicated plots that take a while to draw, you can get some major performance improvements with plot caching. This is mostly a matter of changing renderPlot() to renderCachedPlot() then thinking carefully about the “cache key” which determines when the cache is used.

The following app uses renderCachedPlot() to speed up the rendering of a scatterplot of the diamonds dataset. If you run the app, you’ll notice the first time you show each plot, it takes a noticeable fraction of a second to render because it has to draw ~50,000 points. But if you re-draw a plot you’ve already seen, it appears instantly because it’s retrieved from the cache.

ui <- fluidPage(
  selectInput("x", "X", choices = names(diamonds), selected = "carat"),
  selectInput("y", "Y", choices = names(diamonds), selected = "price"),
  plotOutput("diamonds")
)

server <- function(input, output, session) {
  output$diamonds <- renderCachedPlot({
    ggplot(diamonds, aes(.data[[input$x]], .data[[input$y]])) + 
      geom_point()
  },
  cacheKeyExpr = list(input$x, input$y))
}

(If the .data syntax is unfamiliar to you, you can learn more in Chapter 12).

You’ll notice one important difference between renderPlot() and renderCachedPlot(): a cached plot also needs a cacheKeyExpr, an expression that uniquely identifies each plot. This is the most important argument to renderCachedPlot() and we’ll discuss it in more detail below. We’ll also cover two other important concepts:

  • The sizing policy, which ensures that plot is shared even when the sizes are a little different.
  • The scoping, which controls how the cache is shared across users and app restarts.

Here we’ll focus on the big picture; for the full details you can refer to the Shiny website.

7.4.1 Cache key

The cacheKeyExpr is the most important argument to renderCachedPlot() because it determines when the cache can be used. It should return an object, usually a list of simple vectors, that determines the “state” of the plot.

How does the cache key work? Before plotting anything, renderCachedPlot() computes the cacheKeyExpr and looks to see if the value appears in the cache. If it does, the cached plot is retrieved and sent to the user. If it doesn’t, the plot is generated, saved to the cache, and then shown to the user.

Some general advice:

  • The best cache keys tend to be small lists made up of reactive inputs or reactives.
  • You can use a small dataset as a cache key, but you should avoid using large datasets because it can be time consuming to look them up in the cache.
  • If you want a plot to invalidate periodically, you can use something like proc.time()[[3]] %/% 3600. This value will change once per hour (3600 s); make it update more or less frequently by changing the denominator.

The cache is also affected by the plot size, and the cache scope, as described below.

7.4.2 Sizing policy

Plots are normally rendered with a variety of sizes, because the default plot occupies 100% of the container width (so each time you resize the app, the plot is redrawn). But that flexibility doesn’t work very well for caching, because even a single pixel difference in the size would mean that the plot couldn’t be retrieved from the cache. To avoid this problem, renderCachePlot() caches plots with fixed sizes, controlled by an exponential rounding strategy. The defaults are carefully chosen to “just work” in most cases, but if needed you can control with the sizingPolicy argument. See more details in the ?sizeGrowthRatio help page.

You may also want to consider setting cached plots to a fixed size with plotOutput(). The default value for height is already fixed at "400px", but width defaults to "100%". If you set width = "400px" every plot will be exactly the same size, and you’ll get the best cache performance.

7.4.3 Scoping

By default, the plot cache is stored in memory, and shared across all users of the app. If needed, you can override these defaults with:

  • cache = "session": the cache lifetime will be tied to a single user. It will be created when the session starts (i.e. when someone first visits your app), and deleted when the session ends.

  • cache = cachem::disk_cache(): shares across multiple users, multiple processes, and app restarts. Beware that restarting the app will no longer clear the cache, so if you change the plotting code, you’ll also need to manually reset the cache by deleting the directory.

It’s also possible to store in a database, or write your own back end. See https://shiny.rstudio.com/articles/plot-caching.html for more details.

7.5 Images

You can use renderImage() if you want to display existing images (not plots). For example, you might have a directory of photographs that you want shown to the user. The following app illustrates the basics of renderImage() by showing cute puppy photos. The photos come from https://unsplash.com, my favourite source of royalty free stock photographs.

puppies <- tibble::tribble(
  ~breed, ~ id, ~author, 
  "corgi", "eoqnr8ikwFE","alvannee",
  "labrador", "KCdYn0xu2fU", "shaneguymon",
  "spaniel", "TzjMd7i5WQI", "_redo_"
)

ui <- fluidPage(
  selectInput("id", "Pick a breed", choices = setNames(puppies$id, puppies$breed)),
  htmlOutput("source"),
  imageOutput("photo")
)
server <- function(input, output, session) {
  output$photo <- renderImage({
    list(
      src = file.path("puppy-photos", paste0(input$id, ".jpg")),
      contentType = "image/jpeg",
      width = 500,
      height = 650
    )
  }, deleteFile = FALSE)
  
  output$source <- renderUI({
    info <- puppies[puppies$id == input$id, , drop = FALSE]
    HTML(glue::glue("<p>
      <a href='https://unsplash.com/photos/{info$id}'>original</a> by
      <a href='https://unsplash.com/@{info$author}'>{info$author}</a>
    </p>"))
  })
}

renderImage() needs to return a list. The only crucial argument is src, a local path to the image file. You can additionally supply:

  • A contentType, which defines the MIME type of the image. If not provided, Shiny will guess from the file extension, so you only need to supply this if your images don’t have extensions.

  • The width and height of the image, if known.

  • Any other arguments, like class or alt will be added as attributes to the <img> tag in the HTML.

You can learn more about renderImage(), and see other ways that you might use it at https://shiny.rstudio.com/articles/images.html.

7.6 Exercises

  1. Make a plot with click handle that shows all the data returned in the input.

  2. Make a plot with click, dblclick, hover, and brush output handlers and nicely display the current selection in the sidebar. Plot the plot in the main panel.

  3. Compute the limits of the distance scale using the size of the plot.

output_size <- function(id) {
  reactive(c(
    session$clientData[[paste0("output_", id, "_width")]],
    session$clientData[[paste0("output_", id, "_height")]]
  ))
}