Displaying ggplot messages in a Shiny app

Shiny R ggplot

8 January, 2020



Background

It turns out that capturing and displaying in a Shiny app the warning and error messages generated by ggplot isn't as simple as you might think. In order to discover why that is, we need to know a little bit about what happens behind the scenes.

When you call ggplot(), you're not explicitly drawing the plot; you're only creating the ggplot object. This isn't obvious at first glance, because when we call ggplot() in an R console, our plot will appear in RStudio's plot viewer:

library(ggplot2)
ggplot(mtcars, aes(x = mpg, y = hp)) +
    geom_quantile()

The reason for this behaviour is that when we run any command on the R console, the default action is to call print() on the result, so it makes it seem like ggplot() actually draws the plot. In a Shiny app, however, we aren't executing our code on the console: we aren't running print() on the ggplot() output, so we aren't getting access to any messages that ggplot feels like passing on to us.

Back to the console. You will have noticed that ggplot helpfully returned a message for us after we ran the code above: "Smoothing formula not specified. Using: y ~ x". We can demonstrate this little idiosyncrasy of behind-the-scenes printing by trying to suppress that message. Let's just wrap the whole ggplot() call with suppressMessages(), and see what happens.

suppressMessages(
    ggplot(mtcars, aes(x = mpg, y = hp)) +
        geom_quantile())

It didn't work! The message was still printed, because the expression that we typed was implicitly wrapped in a call to print() by the R console, so it looked like this: print(suppressMessages(ggplot(...))) -- and no messages are generated during the creation of the ggplot object itself, only upon printing. To correctly suppress the messages, we would have to do it the other way around: suppressMessages(print(ggplot(...))). This excellent Stack Overflow question and answer talk about the issue in more detail.

So, we've found out that gathering and displaying messages from ggplot within a Shiny app isn't as simple as it might first appear. Like all things in code, however, there are several ways to screw in a lightbulb, and our strategy for now will be to generate a reactive ggplot object containing our plot, and then use that object twice: firstly, to plot it as normal, and secondly -- you guessed it -- to print() the object in order to gather the error messages. We'll create a few lists that we can append messages to, so that we can continue to gather new messages whenever they appear. Let's dive right in and see what the UI will look like.

UI code

Our UI is going to be super simple. All we really want is a ggplot plotOutput, a couple of actionButton outputs to create warnings and errors, a little textOutput to spit out some information, and, of course, a verbatimTextOutput containing all our messages.

ui <- {
    fluidPage(
        # button row
        fluidRow(
            column(width = 6,
                actionButton(
                    inputId = 'create_warning',
                    label = 'Create a warning')),
            column(width = 6,
                actionButton(
                    inputId = 'create_error',
                    label = 'Create an error!'))),
        # info text row
        fluidRow(
            textOutput('info_text')),
        # plot row
        fluidRow(
            plotOutput('my_plot')),
        # message output row
        fluidRow(
            verbatimTextOutput('ggplot_messages'))
    )
}

Of course, our server code is going to be a little bit more complicated.

Simulating errors and warnings

Before we start thinking about how to handle messages, we're going to have to think about how to create them on demand! For warnings, that's pretty easy. If ggplot tries to plot a geom_point() vector containing NA values, it will throw a warning. So, let's create a function that inserts an NA value into a dataframe in a random position.

insert_na <- function(df) {

        # randomly select row and col indices
        row_index <- sample(1:dim(df)[1], 1)
        col_index <- sample(1:dim(df)[2], 1)

        # insert NA in selected position
        df[row_index,col_index] <- NA

        # return list of outputs
        return(
            list(
                df = df,
                row_index = row_index,
                col_index = col_index))
    }

insert_na() will take a dataframe as its argument, and pick a random row and column using sample() in conjunction with dim(). Then, it inserts NA into that random position. Finally, it will return a list containing not just our modified dataframe, but also the row and column indices that point to our NA (just so we can keep track of it later on).

As for generating an error, well... that's easy. **manic laugh** The easiest way I can think of is just give ggplot() something that isn't a dataframe for the data argument, like, say, the number 5.

The server function

Now we're getting down to the business end of things. You could probably do this a million ways, but here's how we're going to tackle it. We'll instantiate plot_data as a reactive values object, and give it a placeholder object that we'll call "text". It'll just tell the user to click a button to get started.

plot_data <- reactiveValues(text = 'Click on a button to get started.')

Next, we're going to populate plot_data with with some actual data. We can do this by clicking either of the buttons. The buttons will be tied to observeEvent() calls, so each time we click a button, Shiny will look for the correct observeEvent() code to run, which will then overwrite plot_data with data containing either a warning or an error. The code for the warning looks like this:

# generate warning upon button click
    observeEvent(input$create_warning, {

        # remove placeholder for app startup
        plot_data$text <- NULL

        # get mtcars with one NA randomly inserted,
        # along with its row and col indices
        new_data <- insert_na(mtcars)

        # populate plot_data reactiveValues
        plot_data$df <- new_data$df
        plot_data$column_with_na = names(mtcars$df)[new_data$col_index]
        plot_data$row = names(mtcars)[new_data$row_index]
        plot_data$col = names(mtcars)[new_data$col_index]
    })

We can see that it calls our custom function insert_na(), and then overwrites the plot_data reactive values. As simple as that is, our observeEvent() for an error is even easier:

# generate error upon button click
    observeEvent(input$create_error, {

        # remove placeholder for app startup
        plot_data$text <- NULL

        # make df not a df -- creates error
        plot_data$df <- 5
    })

You've probably noticed that in each of those calls, we're assigning NULL to plot_data$text. The reason for this is that it's nice to have a bit of information text provided to us, that tells us, for example, where we put the NA, and what ggplot is doing. But, we'd also like to prompt the user to do something when they start the app, and it's convenient for us to have both of those things in a single reactive output. I've called it info_text.

# generate information text to accompany plot
    output$info_text <- reactive({

        # do nothing on app start, otherwise, provide information
        if (!is.null(plot_data$text)) {
            paste(plot_data$text)
        } else {
            paste0(
            "Inserted an NA value into the ",
            plot_data$column_with_na,
            " column to generate a ggplot warning. Plotting ",
            plot_data$row,
            " vs ",
            plot_data$col,
            ".")
       }
    })

You can see that if plot_data$text isn't NULL, then it pastes together a small bit of text telling us where the computer gods decided to put the NA. The only time it tells us anything different is if plot_data$text is not equal to NULL, i.e., on app startup before it's overwritten by either of the observeEvent() calls.

To actually generate the plot, we'll have a simple reactive object that will get the data from plot_data, as well as the row and column names, and make a simple call to ggplot. (Note that we use aes_string, as our x- and y-values are passed in as character strings.)

# generate reactive ggplot object dependent on plot_data
    plot_object <- reactive({

        # retrieve data to plot from plot_data reactiveVal
        df <- plot_data$df
        x = plot_data$row
        y = plot_data$col

        # plot simple graph
        ggplot(df, aes_string(x = x, y = y)) +
            geom_point()
    })

Our message handler is going to be a reactive object that calls tryCatch to print(plot_object()). In other words, we're going to print the same object that we've plotted, but instead of actually displaying the plot, we're going to capture any messages, including warnings and errors, from ggplot using the elusive tryCatch(). This message handler is based on a simplified message handler that user Pork Chop came up with in this useful Stack Overflow post.

Here, we extend that concept from being able to capture one message only, to being able to capture all messages produced by ggplot, along with their timestamps and sorted by most recent.

my_messages <- reactive({

    # try to print plot_object() -- this generates messages if any are produced
    tryCatch({
        print(plot_object())
        }, message = function(m) {

            # if there are no messages, then return
            if (m$message == "") {
                return("No messages")

            # otherwise, get the message time, append it to the global
            # msg_list variable, increment the msg_num counter,
            # concatenate all messages into one vector, and return 
            # all messages sorted by most recent. The same process
            # occurs for warnings and errors below.
            } else {
                msg_time <- paste0(Sys.time())
                msg_list[[msg_time]] <<- paste0("Message ", msg_num, ": ", m$message)
                msg_num <<- msg_num + 1
                all_msgs <- c(msg_list,wrn_list,err_list)
                return(all_msgs[rev(order(as.POSIXct(names(all_msgs))))])
            }

        }, warning = function(w) {
            if (w$message == "") {
                return("No warnings")
            } else {
                wrn_time <- paste0(Sys.time())
                wrn_list[[wrn_time]] <<- paste0("Warning ", wrn_num, ": ", w$message)
                wrn_num <<- wrn_num + 1
                all_msgs <- c(msg_list,wrn_list,err_list)
                return(all_msgs[rev(order(as.POSIXct(names(all_msgs))))])
            }

        }, error = function(e) {
            if (e$message == "") {
                return("No errors")
            } else {
                err_time <- paste0(Sys.time())
                err_list[[err_time]] <<- paste0("Error ", err_num, ": ", e$message)
                err_num <<- err_num + 1
                all_msgs <- c(msg_list,wrn_list,err_list)
                return(all_msgs[rev(order(as.POSIXct(names(all_msgs))))])
        }
    })
})

The my_messages reactive object tries to print the ggplot object. Using tryCatch(), it looks for messages, warnings and errors. If it finds them, appends them to a global list of messages, adds a timestamp, increments the global message counter, then returns the compiled vector of messages. We can instantiate the message lists and counter in the global environment:

# define global lists for messages,
# warnings and errors
err_list <- wrn_list <- msg_list <- list() 

# define global counters for messages,
# warnings and errors
err_num <- wrn_num <- msg_num <- 1

The final piece of the puzzle is to render the plot, along with the messages.

output$my_plot <- renderPlot({
        plot_object()
    })

    output$ggplot_messages <- renderPrint({
        my_messages()
    })

And there we have it. Clicking the buttons to generate either a warning or an error will append the message to the top of the verbatimTextOutput('ggplot_messages') textbox.

Final thoughts

If you're generating a bunch of messages, the textbox can start to get pretty unwieldy. A quick fix is to limit the maximum height of the output with some css. There is some great documentation on styling Shiny apps with css, and here's how I'd do it in a simple app like this one:

# limit maximum height of message box
tags$head(
    tags$style(
        HTML(
            '
            #ggplot_messages {
                max-height: 200px;
            }
            '
        )))

Check out the full code for this tutorial here.



0 comments

Leave a comment