Files
shiny/R/render-cached-plot.R
2018-06-18 16:25:35 -05:00

224 lines
9.1 KiB
R

#' Disk-based plot cache
#'
#' Creates a read-through cache for plots. The plotting logic is provided as
#' \code{plotFunc}, a function that can have any number/combination of
#' arguments; the return value of \code{plotCache()} is a function that should
#' be used in the place of plotFunc. Each unique combination of inputs will be
#' cached to disk in the location specified by \code{cacheDir}.
#'
#' \code{invalidationExpr} is an expression that uses reactive values like
#' \code{input$click} and/or reactive expressions like \code{data()}. Whenever
#' it changes value, the cache is invalidated (the contents are erased). You
#' typically want to invalidate the cache when a plot made with the same input
#' variables would have a different result. For example, if the plot is a
#' scatter plot and the data set originally had 100 rows, and then changes to
#' have 200 rows, you would want to invalidate the cache so that the plots would
#' be redrawn display the new, larger data set. The \code{invalidationExpr}
#' parameter works just like the \code{eventExpr} parameter of
#' \code{\link{observeEvent}}.
#'
#' Another way to use \code{invalidationExpr} is to have it invalidate the cache
#' at a fixed time interval. For example, you might want to have invalidate the
#' cache once per hour, or once per day. See below for an example.
#'
#' @section Cache scoping:
#'
#' There are a number of different ways you may want to scope the cache. For
#' example, you may want each user session to have their own plot cache, or
#' you may want each run of the application to have a cache (shared among
#' possibly multiple simultaneous user sessions), or you may want to have a
#' cache that persists even after the application is shut down and started
#' again.
#'
#' The cache can be scoped automatically, based on where you call
#' \code{plotCache()}. If automatic scoping is used, the cache will be
#' automatically deleted when the scope exits. For example if it is scoped to
#' a session, then the cache will be deleted when the session exits.
#'
#' \describe{
#' \item{1}{To scope the cache to one session, call \code{plotCache()} inside
#' of the server function.}
#' \item{2}{To scope the cache to one run of a Shiny application (shared
#' among possibly multiple user sessions), call \code{plotCache()} in your
#' application, but outside of the server function.}
#' \item{3}{To scope the cache to a single R process (possibly across multiple
#' runs of applications), call \code{plotCache()} somewhere outside of
#' code that is run by \code{runApp()}. (This is an uncommon use case, but
#' can happen during local application development when running code in the
#' console.)}
#' }
#'
#' If you want to set the scope of the cache manually, use the
#' \code{cacheDir} parameter. This can be useful if you want the cache to
#' persist across R processes or even system reboots.
#'
#' \describe{
#' \item{4}{To have the cache persist across different R processes, use
#' \code{cacheDir=file.path(dirname(tempdir()), "my_cache_id")}.
#' This will create a subdirectory in your system temp directory named
#' \code{my_cache_id} (where \code{my_cache_id} is replaced with a unique
#' name of your choosing).}
#' \item{5}{To have the cache persist even across system reboots, you can set
#' \code{cacheDir} to a location outside of the temp directory.}
#' }
#'
#'
#'
#' @param invalidationExpr Any expression or block of code that accesses any
#' reactives whose invalidation should cause cache invalidation. Use
#' \code{NULL} if you don't want to cause cache invalidation.
#' @param plotFunc Plotting logic, provided as a function that takes zero or
#' more arguments. Don't worry about setting up a graphics device or creating
#' a PNG; just write to the graphics device (you must call \code{print()} on
#' ggplot2 objects).
#' @param baseWidth A base value for the width of the cached plot.
#' @param aspectRatioRate A multiplier for different possible aspect ratios.
#' @param growthRate A multiplier for different cached image sizes. For
#' example, with a \code{width} of 400 and a \code{growth_rate} of 1.25, there
#' will be possible cached images of widths 256, 320, 400, 500, 625, and so
#' on, both smaller and larger.
#' @param res The resolution of the PNG, in pixels per inch.
#' @param cacheDir The location on disk where the cache will be stored. If
#' \code{NULL} (the default), it uses a temp directory which will be cleaned
#' up when the cache scope exits. See the Cache Scoping section for more
#' information.
#' @param invalidation.env The environment where the \code{invalidationExpr} is
#' evaluated.
#' @param invalidation.quoted Is \code{invalidationExpr} expression quoted? By
#' default, this is FALSE. This is useful when you want to use an expression
#' that is stored in a variable; to do so, it must be quoted with
#' \code{quote()}.
#'
#' @export
createCachedPlot <- function(invalidationExpr, plotFunc,
baseWidth = 400, aspectRatioRate = 1.25, growthRate = 1.25, res = 72,
cacheDir = NULL,
invalidation.env = parent.frame(),
invalidation.quoted = FALSE,
session = getDefaultReactiveDomain()
) {
# If user didn't supply cacheDir, automatically determine it.
if (is.null(cacheDir)) {
if (!is.null(session)) {
# Case 1: scope to session
cacheScopePath <- file.path(tempdir(), paste0("shinysession-", session$token))
} else if (!is.null(getShinyOption("appToken"))) {
# Case 2: scope to app
cacheScopePath <- file.path(tempdir(), paste0("shinyapp-", getShinyOption("appToken")))
} else {
# Case 3: scope to current R process
cacheScopePath <- file.path(tempdir(), "shiny")
}
cacheDir <- file.path(cacheScopePath, createUniqueId(8))
# Remove the cache directory when it's no longer needed.
reg.finalizer(environment(), function(e) {
unlink(cacheDir, recursive = TRUE)
# If cacheScopePath is empty, remove it.
siblingPaths <- setdiff(dir(cacheScopePath, all.files = TRUE), c(".", ".."))
if (length(siblingPaths) == 0) {
file.remove(cacheScopePath)
}
})
}
if (!dirExists(cacheDir)) {
dir.create(cacheDir, recursive = TRUE, mode = "0700")
}
if (!invalidation.quoted) {
invalidationExpr <- substitute(invalidationExpr)
}
possible_dims <- all_possible_dims(baseWidth, aspectRatioRate, growthRate)
# Delete the cacheDir at the appropriate time. Use ignoreInit=TRUE because we don't
# want it to happen right in the beginning, especially when cacheDir is provided
# by the user and it might need to persist across R processes.
observeEvent(invalidationExpr, event.env = invalidation.env, event.quoted = TRUE,
ignoreInit = TRUE,
{
if (dirExists(cacheDir)) {
unlink(cacheDir, recursive = TRUE)
}
dir.create(cacheDir, recursive = TRUE, mode = "0700")
}
)
function(...) {
output_info <- getCurrentOutputInfo()
if (is.null(output_info)) {
stop("This must be run in a Shiny output.")
}
session <- getDefaultReactiveDomain()
if (is.null(session)) {
stop("This must be run from a Shiny session.")
}
target_width <- output_info$width()
target_height <- output_info$height()
dims <- find_smallest_containing_rect(target_width, target_height, possible_dims)
pixelratio <- session$clientData$pixelratio
args <- list(...)
# TODO: What if the args include weird objects like environments or reactive expressions?
key <- paste0(digest::digest(c(args, width = dims$width, height = dims$height, res = res, pixelratio = pixelratio)), ".png")
filePath <- file.path(cacheDir, key)
if (!file.exists(filePath)) {
plotPNG(
filename = filePath,
width = dims$width * pixelratio,
height = dims$height * pixelratio,
res = res * pixelratio,
function() {
do.call("plotFunc", args)
}
)
}
filePath
}
}
# Given a target rectangle with `width` and `height`, and data frame `dims` of possible
# dimensions, with column `width` and `height, find the smallest possible width x
# height pair from `dims` that fully contains `width` and `height.`
find_smallest_containing_rect <- function(width, height, dims) {
fit_rows <- width <= dims$width & height <= dims$height
if (sum(fit_rows) == 0) {
# TODO: handle case where width x height is larger than all dims
}
# Drop all the rows where width x height won't fit
dims <- dims[fit_rows, ]
# Find the possible rectangle with the smallest area
dims$area <- dims$width * dims$height
min_row <- which.min(dims$area)
list(
width = dims$width[min_row],
height = dims$height[min_row]
)
}
# Returns a data frame with all possible width-height combinations. This could
# use some fine-tuning in the future.
all_possible_dims <- function(base_width = 400, aspect_ratio_rate = 1.25, growth_rate = 1.25) {
aspect_ratios <- aspect_ratio_rate ^ (-3:3)
dims <- expand.grid(width = base_width * (growth_rate ^ (-6:6)), ratio = aspect_ratios)
dims$height <- dims$width * dims$ratio
dims$width <- round(dims$width)
dims$height <- round(dims$height)
dims
}