diff --git a/NAMESPACE b/NAMESPACE index 3a02e5240..0bbc36762 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -90,6 +90,7 @@ export(fluidRow) export(formatStackTrace) export(freezeReactiveVal) export(freezeReactiveValue) +export(getCurrentOutputInfo) export(getDefaultReactiveDomain) export(getQueryString) export(getShinyOption) diff --git a/R/render-plot.R b/R/render-plot.R index d44c9321c..3ab43cb7d 100644 --- a/R/render-plot.R +++ b/R/render-plot.R @@ -1042,6 +1042,7 @@ find_panel_ranges <- function(g, pixelratio, res) { #' } #' #' +#' #' @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. @@ -1049,13 +1050,17 @@ find_panel_ranges <- function(g, pixelratio, res) { #' 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 width,height The dimensions of the plot. (Use double the user -#' width/height for retina/hi-dpi compatibility.) -#' @param res The resolution of the PNG. Use 72 for normal screens, 144 for -#' retina/hi-dpi. +#' @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. +#' \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 @@ -1065,7 +1070,7 @@ find_panel_ranges <- function(g, pixelratio, res) { #' #' @export plotCache <- function(invalidationExpr, plotFunc, - width = 400, height = 400, res = 72, + baseWidth = 400, aspectRatioRate = 1.25, growthRate = 1.25, res = 72, cacheDir = NULL, invalidation.env = parent.frame(), invalidation.quoted = FALSE, @@ -1109,6 +1114,8 @@ plotCache <- function(invalidationExpr, plotFunc, 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. @@ -1122,18 +1129,33 @@ plotCache <- function(invalidationExpr, plotFunc, } ) - function(..., .width = width, .height = height, .pixelratio = getDefaultReactiveDomain()$clientData$pixelratio) { + 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 = .width, height = .height, res = res, pixelratio = .pixelratio)), ".png") + 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 = .width * .pixelratio, - height = .height * .pixelratio, - res = res * .pixelratio, + width = dims$width * pixelratio, + height = dims$height * pixelratio, + res = res * pixelratio, function() { do.call("plotFunc", args) } @@ -1142,3 +1164,39 @@ plotCache <- function(invalidationExpr, plotFunc, 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 +} diff --git a/R/shiny.R b/R/shiny.R index 33d589032..dc88d9f86 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -445,6 +445,8 @@ ShinySession <- R6Class( testMode = FALSE, # Are we running in test mode? testExportExprs = list(), outputValues = list(), # Saved output values (for testing mode) + currentOutputName = NULL, # Name of the currently-running output + outputInfo = list(), # List of information for each output testSnapshotUrl = character(0), sendResponse = function(requestMsg, value) { @@ -491,6 +493,16 @@ ShinySession <- R6Class( return(defaultValue) return(result) }, + withCurrentOutput = function(name, expr) { + if (!is.null(private$currentOutputName)) { + stop("Nested calls to withCurrentOutput() are not allowed.") + } + + private$currentOutputName <- name + on.exit(private$currentOutputName <- NULL, add = TRUE) + + expr + }, shouldSuspend = function(name) { # Find corresponding hidden state clientData variable, with the format # "output_foo_hidden". (It comes from .clientdata_output_foo_hidden @@ -1070,7 +1082,11 @@ ShinySession <- R6Class( # to include the $then/$catch calls below? hybrid_chain( hybrid_chain( - shinyCallingHandlers(func()), + { + private$withCurrentOutput(name, { + shinyCallingHandlers(func()) + }) + }, catch = function(cond) { if (inherits(cond, "shiny.custom.error")) { if (isTRUE(getOption("show.error.messages"))) printError(cond) @@ -1313,6 +1329,52 @@ ShinySession <- R6Class( } }, + getCurrentOutputInfo = function() { + # TODO: How should we deal with namespacing? + + name <- private$currentOutputName + if (is.null(private$outputInfo[[name]])) { + private$outputInfo[[name]] <- list(name = name) + } + + tmp_info <- private$outputInfo[[name]] + + # cd_names() returns names of all items in clientData, without taking a + # reactive dependency. It is a function and it's memoized, so that we do + # the (relatively) expensive isolate(names(...)) call only when needed, + # and at most one time in this function. + .cd_names <- NULL + cd_names <- function() { + if (is.null(.cd_names)) { + .cd_names <<- isolate(names(self$clientData)) + } + .cd_names + } + + # If we don't already have width for this output info, see if it's + # present, and if so, add it. + if (! ("width" %in% names(tmp_info)) ) { + width_name <- paste0("output_", name, "_width") + if (width_name %in% cd_names()) { + tmp_info$width <- reactive({ + self$clientData[[width_name]] + }) + } + } + + if (! ("height" %in% names(tmp_info)) ) { + height_name <- paste0("output_", name, "_height") + if (height_name %in% cd_names()) { + tmp_info$height <- reactive({ + self$clientData[[height_name]] + }) + } + } + + private$outputInfo[[name]] <- tmp_info + private$outputInfo[[name]] + }, + createBookmarkObservers = function() { # This registers observers for bookmarking to work. @@ -2056,6 +2118,16 @@ outputOptions <- function(x, name, ...) { .subset2(x, 'impl')$outputOptions(name, ...) } + +#' Get information about the output that is currently being executed. +#' +#' @param session The current Shiny session. +#' +#' @export +getCurrentOutputInfo <- function(session = getDefaultReactiveDomain()) { + session$getCurrentOutputInfo() +} + #' Add callbacks for Shiny session events #' #' These functions are for registering callbacks on Shiny session events. diff --git a/man/getCurrentOutputInfo.Rd b/man/getCurrentOutputInfo.Rd new file mode 100644 index 000000000..870b230a5 --- /dev/null +++ b/man/getCurrentOutputInfo.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shiny.R +\name{getCurrentOutputInfo} +\alias{getCurrentOutputInfo} +\title{Get information about the output that is currently being executed.} +\usage{ +getCurrentOutputInfo(session = getDefaultReactiveDomain()) +} +\arguments{ +\item{session}{The current Shiny session.} +} +\description{ +Get information about the output that is currently being executed. +} diff --git a/man/plotCache.Rd b/man/plotCache.Rd index fd23bc6bd..ade14111f 100644 --- a/man/plotCache.Rd +++ b/man/plotCache.Rd @@ -4,9 +4,10 @@ \alias{plotCache} \title{Disk-based plot cache} \usage{ -plotCache(invalidationExpr, plotFunc, width, height, res = 72, - cacheDir = NULL, invalidation.env = parent.frame(), - invalidation.quoted = FALSE, session = getDefaultReactiveDomain()) +plotCache(invalidationExpr, plotFunc, baseWidth = 400, + aspectRatioRate = 1.25, growthRate = 1.25, res = 72, cacheDir = NULL, + invalidation.env = parent.frame(), invalidation.quoted = FALSE, + session = getDefaultReactiveDomain()) } \arguments{ \item{invalidationExpr}{Any expression or block of code that accesses any @@ -18,15 +19,21 @@ 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).} -\item{width, height}{The dimensions of the plot. (Use double the user -width/height for retina/hi-dpi compatibility.)} +\item{baseWidth}{A base value for the width of the cached plot.} -\item{res}{The resolution of the PNG. Use 72 for normal screens, 144 for -retina/hi-dpi.} +\item{aspectRatioRate}{A multiplier for different possible aspect ratios.} + +\item{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.} + +\item{res}{The resolution of the PNG, in pixels per inch.} \item{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.} +\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.} \item{invalidation.env}{The environment where the \code{invalidationExpr} is evaluated.}