diff --git a/NAMESPACE b/NAMESPACE index 60690e2a9..441effcd4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,7 @@ # Generated by roxygen2: do not edit by hand +S3method(print,recordedtinyplot) +S3method(str,recordedtinyplot) S3method(tinyplot,data.frame) S3method(tinyplot,default) S3method(tinyplot,density) @@ -64,6 +66,7 @@ importFrom(grDevices, col2rgb, colorRampPalette, convertColor, + dev.control, dev.cur, dev.list, dev.new, @@ -82,6 +85,8 @@ importFrom(grDevices, pdf, png, recordGraphics, + recordPlot, + replayPlot, svg, xy.coords ) diff --git a/NEWS.md b/NEWS.md index 7f61fbc37..c57d083a9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -138,26 +138,27 @@ related to plot layering. See "Bug fixes" below. #### Other new features -- New top-level `xaxr` and `yaxr` arguments allow rotating of the x- and y-axis - tick labels by arbitrary angles, closing a long-standing feature request - (#346). Note that setting one overrides `las` for that axis. Best combined - with a dynamic theme, since the plot margins are resized to fit the rotated - labels. Also settable via `tpar("x/yaxr")` and thus as part of a `tinytheme` - too. (#717 @grantmcdermott) -- Custom plot types have more control over the surrounding plot machinery, via a - new `type_hints` mechanism. A type can declare properties about itself---that - it draws its own axes, needs a secondary right-hand axis, uses proportional - limits, fills its legend key from `col`, and so on---and **tinyplot** adjusts - margins, axis limits and legend keys accordingly. Previously this behaviour - was hard-coded against the names of built-in types, so it was unavailable to - custom types. See - [Advanced customization](https://grantmcdermott.com/tinyplot/vignettes/types.html#type-hints) - in the `Types` vignette for the list of supported hints. (#543 @grantmcdermott) -- New `cex.xaxs` and `cex.yaxs` graphical parameters allow the x- and y-axis - tick labels to be sized independently, e.g. `tpar(cex.yaxs = 0.6)` to shrink a - long list of category names on the y-axis without also shrinking the x-axis. - Both default to `NULL`, in which case the shared `cex.axis` value is used, so - existing plots are unaffected. (#677 @grantmcdermott) +- New top-level `tinyplot()`/`plt()` arguments: + - `xaxr` and `yaxr` enable rotating of the x- and y-axis tick labels by + arbitrary angles, closing a long-standing feature request (#346). Note that + setting one overrides `las` for that axis. Best combined with a dynamic + theme, since the plot margins are resized to fit the rotated labels. Also + settable globally via `tpar("x/yaxr")` and thus as part of a `tinytheme` + too. (#717 @grantmcdermott) + - (Experimental) `record` enables recording plots as replayable objects, + closing another long-standing feature request (#121). Specifically, setting + `record = TRUE` returns a `"recordedtinyplot"` object (see + `?recordedtinyplot`), allowing for assignment and later recall, e.g. + `myplot = tinyplot(...); myplot`. Also settable globally via + `tpar(record = TRUE)`, so that all `tinyplot()` plots are automatically + recorded---with potential memory implications for detailed plots with _many_ + elements. Note that a recorded plot will still render its display on the + initialising call if you are using an interactive graphics device. While we + have done our best to vet this new `record` functionality carefully, users + should still regard it as experimental. We may change its behaviour + (availability) in a future release if we observe undesirable side-effects. + Please help us by testing on your own machines and reporting any issues. + (#686 @grantmcdermott) - Type-specific updates: - `type_lines()` and its shortcut equivalents like `"l"` and `"s"` now support a _continuous_ `by` variable, drawing a colour gradient along the line @@ -188,6 +189,20 @@ related to plot layering. See "Bug fixes" below. so that tiles meet the panel edge, and also rotates the tick labels against their respective axes. Colour fills default to the "tealgrn" sequential palette. (#677 @grantmcdermott) +- Custom plot types have more control over the surrounding plot machinery, via a + new `type_hints` mechanism. A type can declare properties about itself---that + it draws its own axes, needs a secondary right-hand axis, uses proportional + limits, fills its legend key from `col`, and so on---and **tinyplot** adjusts + margins, axis limits and legend keys accordingly. Previously this behaviour + was hard-coded against the names of built-in types, so it was unavailable to + custom types. See + [Advanced customization](https://grantmcdermott.com/tinyplot/vignettes/types.html#type-hints) + in the `Types` vignette for the list of supported hints. (#543 @grantmcdermott) +- New `cex.xaxs` and `cex.yaxs` graphical parameters allow the x- and y-axis + tick labels to be sized independently, e.g. `tpar(cex.yaxs = 0.6)` to shrink a + long list of category names on the y-axis without also shrinking the x-axis. + Both default to `NULL`, in which case the shared `cex.axis` value is used, so + existing plots are unaffected. (#677 @grantmcdermott) ### Bug fixes diff --git a/R/record.R b/R/record.R new file mode 100644 index 000000000..ff9ee372d --- /dev/null +++ b/R/record.R @@ -0,0 +1,186 @@ +## Recorded tinyplot objects ----- +## +## A plain "recordedplot" carries the display list and nothing else, so +## replaying one restores the pixels but not the context that tinyplot_add() +## needs. The result is that +## +## p = tinyplot(..., record = TRUE); tinyplot(1); p; tinyplot_add(type = "lm") +## +## layers onto tinyplot(1) rather than onto p, because .last_call still points +## at the intervening plot. Wrapping the recording lets us carry that state +## along and put it back when the plot is replayed. + + +# Plot-scoped entries of .tinyplot_env that tinyplot_add() and the layering +# machinery need in order to treat a replayed plot as "the current plot". +# +# Two kinds of entry are deliberately absent. First, package-level config that +# is not tied to any single plot: .base_par_names (a device cache), +# .registered_themes and .tpar_hooks (session settings). Second -- and less +# obviously -- the saved par/usr/dev state (.saved_par_before, .saved_par_after, +# usr_orig, dev_orig). That state describes the device the plot was *recorded* +# on, which need not be the device it is replayed onto: recording a plot with +# `file = ` captures a file device that is then closed, so restoring its par on +# replay leaves the live device inconsistent and a following tinyplot_add() +# fails with "plot.new has not been called yet". Replaying redraws the plot on +# the current device anyway, so that device's own par is the correct one to +# keep. +recorded_state_keys = c( + ".last_call", + ".group_offsets", + ".offsets_axis", + ".facet_labs", + ".top_legend_soma", + "xlabs_orig" +) + + +# Snapshot the plot-scoped state, to be stashed on a recorded plot. +capture_record_state = function() { + out = lapply(recorded_state_keys, function(k) .tinyplot_env[[k]]) + names(out) = recorded_state_keys + # Drop the per-call output directives from the stored call. tinyplot_add() + # rebuilds the last call, so leaving these in would make every layer added + # after a replay repeat them: `record` would re-record each layer (and warn + # if that device was not recording), while `file`/`width`/`height` would open + # a fresh device via setup_device() and then fail, because add-mode draws + # onto a plot that the new device does not have. These arguments describe how + # one call produced its output, not properties of the plot to be inherited; + # pass them to tinyplot_add() explicitly if a layer needs them. + cal = out[[".last_call"]] + if (is.call(cal)) { + drop = intersect(c("record", "file", "width", "height"), names(as.list(cal))) + for (nm in drop) cal[[nm]] = NULL + out[[".last_call"]] = cal + } + return(out) +} + + +# Put a snapshot back, so that a subsequent tinyplot_add() sees the replayed +# plot as the current one. +restore_record_state = function(state) { + if (!is.list(state)) return(invisible(NULL)) + for (k in intersect(names(state), recorded_state_keys)) { + .tinyplot_env[[k]] = state[[k]] + } + return(invisible(NULL)) +} + + +# Wrap a recordedplot, stashing the state alongside it. The result still +# inherits from "recordedplot", so replayPlot() and everything else that +# expects a bare recording keep working. +as_recordedtinyplot = function(rec, flip = FALSE) { + state = capture_record_state() + # Needed to recompute usr_orig on replay, below. + state[["flip"]] = isTRUE(flip) + attr(rec, "tinyplot_state") = state + class(rec) = c("recordedtinyplot", "recordedplot") + return(rec) +} + + +#' @title Recorded tinyplot objects +#' +#' @description Objects of class `recordedtinyplot` are returned by +#' `tinyplot(..., record = TRUE)`. They are a thin wrapper around +#' \code{\link[grDevices]{recordPlot}}---thus conferring the same replay +#' functionality---but with the initializing plot call and state added, so that +#' recorded (tiny)plots play nicely with \code{\link{tinyplot_add}()} and +#' friends. +#' +#' @param x a `recordedtinyplot` object, returned by a +#' `tinyplot(..., record = TRUE)` call. +#' @param ... further arguments passed to \code{\link[grDevices]{replayPlot}}. +#' +#' @returns `print()` replays the plot and returns `x` invisibly. +#' +#' @details Recording requires a device whose display list is enabled (see +#' \code{\link[grDevices]{dev.control}}); without one there is nothing to +#' record and the returned plot will replay blank. Interactive devices---the +#' plot pane of an IDE, say---typically enable it by default, whereas +#' file-based devices (`png`, `pdf`, `svg`, ...) do not. `tinyplot()` enables +#' it for any device that it opens itself via the `file` argument, and warns +#' if the current device is not recording. +#' +#' Printing a recorded plot---either explicitly with `print()`, or simply by +#' evaluating it at the console---is what restores the initializing call, so +#' that a subsequent \code{\link{tinyplot_add}()} layers onto it. Calling +#' \code{\link[grDevices]{replayPlot}()} on the object instead redraws it +#' just as it would any other recording, but leaves the current plot context +#' untouched; layering after that route targets whichever plot was drawn last. +#' +#' This class is experimental, as is the `record` argument that produces it. +#' +#' @seealso \code{\link[grDevices]{recordPlot}} and +#' \code{\link[grDevices]{replayPlot}}, which this class wraps and defers to +#' for the actual recording and replaying. +#' \code{\link[grDevices]{dev.control}} for enabling a device's display list, +#' without which there is nothing to record. +#' +#' @examples +#' \dontrun{ +#' p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, +#' record = TRUE) +#' tinyplot(1:10) # some other plot +#' p # replay, restoring plot context +#' tinyplot_add(type = "lm") # layers onto p, not onto the 1:10 plot +#' } +#' +#' @name recordedtinyplot +#' @importFrom grDevices replayPlot +#' @export +print.recordedtinyplot = function(x, ...) { + # Strip our class before handing off, so we replay as a plain recording + # rather than recursing back into this method. + rec = x + attr(rec, "tinyplot_state") = NULL + class(rec) = "recordedplot" + # replayPlot() re-executes the recorded par (a grDevices property, not + # ours), which would leave a themed plot's cosmetic par on the live device + # and leak into later plots. Restored below, once the replay is done. Only + # the keys a theme can set are read: the coordinate system must be left as + # the replay draws it, so that tinyplot_add() can still layer onto it. + pre_par = par(intersect(names(theme_default), base_par_names())) + replayPlot(rec, ...) + # Only now adopt the replayed plot as the current one: replaying redraws it, + # so tinyplot_add() should target it rather than the previous plot. + state = attr(x, "tinyplot_state") + restore_record_state(state) + # align_layer() validates that a layer is being added to the plot it thinks + # is current, by comparing usr_orig/dev_orig against the live device. Those + # describe whichever plot was drawn last, which after a replay is the + # *intervening* plot, so the comparison fails and categorical layers silently + # skip alignment. Recompute them from the plot we just drew. Note we derive + # these from the live device rather than restoring the recorded values: the + # recording may come from a device that no longer exists (`file = `), and + # restoring its par leaves the current device inconsistent. + .tinyplot_env[["dev_orig"]] = dev.cur() + .tinyplot_env[["usr_orig"]] = if (isTRUE(state[["flip"]])) { + par("usr")[c(3, 4, 1, 2)] + } else { + par("usr") + } + # Undo the leak noted above, mirroring what an ephemeral theme does on exit. + par(pre_par) + return(invisible(x)) +} + + +#' @rdname recordedtinyplot +#' @param object a `recordedtinyplot` object. +#' @export +str.recordedtinyplot = function(object, ...) { + # The underlying display list str()s to hundreds of lines of dotted pair + # lists, which is noise in an object viewer. Report what is useful instead. + state = attr(object, "tinyplot_state") + cal = state[[".last_call"]] + cat("\n") + if (!is.null(cal)) { + cat(" call: ", paste(deparse(cal), collapse = " "), "\n", sep = "") + } + cat(" display list entries: ", length(object[[1]]), "\n", sep = "") + cat(" size: ", format(utils::object.size(object), units = "auto"), "\n", sep = "") + return(invisible(NULL)) +} diff --git a/R/setup_device.R b/R/setup_device.R index 76441fca5..ce3a0f250 100644 --- a/R/setup_device.R +++ b/R/setup_device.R @@ -1,5 +1,5 @@ setup_device = function(settings) { - env2env(settings, environment(), c("file", "width", "height")) + env2env(settings, environment(), c("file", "width", "height", "record")) # write to file if (!is.null(file)) { @@ -29,6 +29,10 @@ setup_device = function(settings) { svg = svg(filepath, width = filewidth, height = fileheight), stop("\nUnsupported file extension. Only '.png', '.jpg', '.pdf', or '.svg' are allowed.\n") ) + # File devices default to displaylist = "inhibit", which would make + # recordPlot() return an empty plot. This device is opened and closed by + # tinyplot, so enabling it here has no effect on the user's own devices. + if (isTRUE(record)) dev.control(displaylist = "enable") dop$new = FALSE # catch for some interfaces par(dop) diff --git a/R/tinyplot.R b/R/tinyplot.R index 607bd14fd..80d56b7cf 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -430,10 +430,10 @@ #' `y` are plotted). The `draw` argument is primarily useful for adding common #' elements to each facet of a faceted plot, e.g. #' \code{\link[graphics]{abline}} or \code{\link[graphics]{text}}. Note that -#' this argument is somewhat experimental and that _no_ internal checking is -#' done for correctness; the provided argument is simply captured and -#' evaluated as-is within `tinyplot()` and thus has access to the local -#' definition of all variables such as `x`, `y`, etc. See Examples. +#' _no_ internal checking is done for correctness; the provided argument is +#' simply captured and evaluated as-is within `tinyplot()` and thus has +#' access to the local definition of all variables such as `x`, `y`, etc. +#' See Examples. #' @param restore.par a logical value indicating whether the #' \code{\link[graphics]{par}} settings prior to calling `tinyplot` should be #' restored on exit. Defaults to FALSE, which makes it possible to add @@ -478,6 +478,18 @@ #' @param height numeric giving the plot height in inches. Same considerations as #' `width` (above) apply, e.g. will default to `tpar("file.height")` if not #' specified. +#' @param record (experimental) a logical value indicating whether the plot +#' should be recorded and returned as a replayable +#' \code{\link{recordedtinyplot}} object. Defaults to `FALSE`. Setting to +#' `TRUE` allows for assignment and later recall, e.g. +#' `myplot = tinyplot(...); myplot`. This behaviour can also be set globally +#' via `tpar(record = TRUE)`; an explicit argument here takes precedence. +#' +#' Note that recording requires a device with an enabled display list (see +#' \code{\link[grDevices]{dev.control}}). Most interactive devices enable this +#' behaviour by default, whereas file-based devices do not. However `tinyplot` +#' automatically enables it for any device that it opens itself via `file`, +#' and further emits a warning if the current device is not recording. #' @param asp the y/xy/x aspect ratio, see `plot.window`. #' @param theme keyword string (e.g. `"clean"`) or list defining a theme. Passed #' on to [`tinytheme`], but reset upon exit so that the theme effect is only @@ -488,7 +500,10 @@ #' All remaining arguments from `...` can be further graphical parameters, see #' \code{\link[graphics]{par}}). #' -#' @returns No return value, called for side effect of producing a plot. +#' @returns By default, no return value; called for the side effect of producing +#' a plot. If `record = TRUE` (or globally via `tpar(record = TRUE)`), the +#' plot is instead returned invisibly as a `"recordedtinyplot"` object, which +#' can be replayed later; see \code{\link{recordedtinyplot}}. #' #' @details #' Disregarding the enhancements that it supports, `tinyplot` tries as far as @@ -497,7 +512,7 @@ #' out existing `plot` calls for `tinyplot` (or its shorthand alias `plt`), #' without causing unexpected changes to the output. #' -#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png svg xy.coords +#' @importFrom grDevices axisTicks adjustcolor cairo_pdf chull colorRampPalette dev.control dev.cur dev.list dev.off dev.new extendrange hcl.colors hcl.pals jpeg palette palette.colors palette.pals pdf png recordPlot svg xy.coords #' @importFrom graphics abline arrows axis Axis axTicks box boxplot grconvertX grconvertY hist lines mtext par plot.default plot.new plot.window points polygon polypath segments rect text title #' @importFrom utils modifyList head tail #' @importFrom stats na.omit setNames var @@ -800,6 +815,7 @@ tinyplot.default = function( file = NULL, width = NULL, height = NULL, + record = NULL, asp = NA, theme = NULL, ...) { @@ -816,6 +832,10 @@ tinyplot.default = function( par_first = get_saved_par("first") if (is.null(par_first)) set_saved_par("first", par()) + # Resolve `record`: an explicit argument wins over the tpar default. + if (is.null(record)) record = get_tpar("record", default = FALSE) + assert_flag(record, name = "record") + # Validate grid only for simple values; skip for unevaluated calls like grid() # which are passed as language objects from tinyplot.formula via substitute(). (#193) if (!is.null(grid) && !is.call(grid)) { @@ -914,6 +934,7 @@ tinyplot.default = function( file = file, width = width, height = height, + record = record, # deparsed input for use in labels by_dep = deparse1(substitute(by)), @@ -1910,6 +1931,24 @@ tinyplot.default = function( env = getNamespace('tinyplot') ) } + + if (isTRUE(record)) { + rec = as_recordedtinyplot(recordPlot(), flip = isTRUE(settings$flip)) + # A device that is not recording still yields a well-formed recording, + # just an empty one that replays blank. Say so rather than handing back + # something that silently does nothing. + if (length(rec[[1]]) == 0L) { + warning( + "`record = TRUE` but the current device is not recording, so the ", + "returned plot is empty and will replay blank. Call ", + "dev.control(displaylist = \"enable\") on the device first.", + call. = FALSE + ) + } + return(invisible(rec)) + } + + return(invisible(NULL)) } diff --git a/R/tinyplot.data.frame.R b/R/tinyplot.data.frame.R index 1633a4714..1a26d0ad2 100644 --- a/R/tinyplot.data.frame.R +++ b/R/tinyplot.data.frame.R @@ -37,7 +37,12 @@ #' to disambiguate from `frame.plot`. #' @param ... further arguments passed to `tinyplot`. #' -#' @returns No return value, called for the side effect of producing a plot. +#' @returns By default, no return value; called for the side effect of producing +#' a plot. If `record = TRUE` (or globally via `tpar(record = TRUE)`), the +#' plot is instead returned invisibly as a `"recordedtinyplot"` object; see +#' \code{\link{recordedtinyplot}}. The exception is the pairs-style case +#' (more than two columns), which draws a grid of sub-plots and always +#' returns `NULL`. #' #' @examples #' ## using tinyplot() with data frames diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R index f031ce2c2..61ebd6419 100644 --- a/R/tinyplot.matrix.R +++ b/R/tinyplot.matrix.R @@ -40,7 +40,7 @@ #' titles default to `NA`, since the dimnames label both axes. #' @param ... further arguments passed to `tinyplot`. #' -#' @returns No return value, called for the side effect of producing a plot. +#' @inherit tinyplot return #' #' @seealso \code{\link[graphics]{matplot}} #' diff --git a/R/tinyplot.ts.R b/R/tinyplot.ts.R index b578dc8e0..05e77a1ba 100644 --- a/R/tinyplot.ts.R +++ b/R/tinyplot.ts.R @@ -25,7 +25,7 @@ #' multivariate series. #' @param type,facet.args,xlab,ylab,... further arguments passed to `tinyplot`. #' -#' @returns No return value, called for the side effect of producing a plot. +#' @inherit tinyplot return #' #' @examples #' ## univariate series diff --git a/R/tinyplot_add.R b/R/tinyplot_add.R index 4b4d4f5cb..e39a8d3c4 100644 --- a/R/tinyplot_add.R +++ b/R/tinyplot_add.R @@ -44,7 +44,7 @@ #' tinyplot(mpg ~ wt, data = mtcars) #' tinyplot_add(subset = cyl == 4, col = "red", pch = 16) #' -#' @returns No return value, called for side effect of producing a plot. +#' @inherit tinyplot return #' #' @export tinyplot_add = function(...) { diff --git a/R/tpar.R b/R/tpar.R index e6e1e074d..a56ec402d 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -87,6 +87,7 @@ #' * `lwd.xaxs`, `lwd.yaxs`: Line widths for the x- and y-axis lines, respectively. Both default to `NULL`, whereby the shared `lwd.axis` value is used instead. #' * `palette.qualitative`: Palette for qualitative colors. See the `palette` argument in `?tinyplot`. #' * `palette.sequential`: Palette for sequential colors. See the `palette` argument in `?tinyplot`. +#' * `record`: (experimental) Logical indicating whether `tinyplot()` should record plots and return them as replayable \code{\link{recordedtinyplot}} objects. Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. #' * `ribbon.alpha`: Numeric factor in the range `[0,1]` for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is `0.2`. #' * `xaxr`, `yaxr`: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; `NULL` (the default) leaves them unrotated. Unlike `las`, which is limited to the four right angles, any angle is permitted. Setting one overrides `las` for that axis alone, leaving the other axis under `las` as usual, and `0` (or any multiple of 360) counts as no rotation at all. Sets the default for the `xaxr` and `yaxr` arguments of [`tinyplot()`], which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base `axis()`. First, margins are only resized to fit them under a theme with `dynmar = TRUE` (see `tinytheme`); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen `mar` yourself. Second, rotated labels do not inherit the thinning that `axis()` applies via `gap.axis`, so they start to overlap once the spacing between ticks falls below `line height / sin(srt)`. #' @@ -313,6 +314,7 @@ known_tpar = c( "pch", "palette.qualitative", "palette.sequential", + "record", "ribbon.alpha", "side.sub", "tinytheme", @@ -344,6 +346,7 @@ assert_tpar = function(.tpar) { assert_flag(.tpar[["dynmar"]], null.ok = FALSE, name = "dynmar") assert_choice(.tpar[["ljust"]], choice = c("left", "center", "l", "c"), null.ok = TRUE, name = "ljust") assert_numeric(.tpar[["lmar"]], len = 2, null.ok = TRUE, name = "lmar") + assert_flag(.tpar[["record"]], null.ok = TRUE, name = "record") assert_numeric(.tpar[["ribbon.alpha"]], len = 1, lower = 0, upper = 1, null.ok = TRUE, name = "ribbon.alpha") assert_numeric(.tpar[["grid.lwd"]], len = 1, lower = 0, null.ok = TRUE, name = "grid.lwd") assert_grid(.tpar[["grid"]], null.ok = TRUE, name = "grid") @@ -378,7 +381,7 @@ assert_tpar = function(.tpar) { assert_true(length(facet.col) == 1, name = "length(facet.col)==1") } - facet.bg = .tpar$facet.bg + facet.bg = .tpar[["facet.bg"]] if (!is.null(facet.bg)) { if (!is.numeric(facet.bg) && !is.character(facet.bg)) { stop("facet.bg needs to be NULL, or a numeric or character", call. = FALSE) @@ -386,7 +389,7 @@ assert_tpar = function(.tpar) { assert_true(length(facet.bg) == 1, name = "length(facet.bg)==1") } - facet.border = .tpar$facet.border + facet.border = .tpar[["facet.border"]] if (!is.null(facet.border)) { if (!is.numeric(facet.border) && !is.character(facet.border) && !is.na(facet.border)) { stop("facet.border needs to be NULL, or a numeric, character, or NA", call. = FALSE) @@ -396,6 +399,10 @@ assert_tpar = function(.tpar) { } init_tpar = function(rm_hook = FALSE) { + # `record` changes what tinyplot() returns, not how the plot looks, so it + # survives the wipe below (tinytheme() calls init_tpar() on every switch). + record_old = .tpar[["record"]] + rm(list = names(.tpar), envir = .tpar) if (isTRUE(rm_hook)) { @@ -406,47 +413,56 @@ init_tpar = function(rm_hook = FALSE) { } } - .tpar$cairo = if (is.null(getOption("tinyplot_cairo"))) capabilities("cairo") else as.logical(getOption("tinyplot_cairo")) + .tpar[["cairo"]] = if (is.null(getOption("tinyplot_cairo"))) capabilities("cairo") else as.logical(getOption("tinyplot_cairo")) - .tpar$dynmar = if (is.null(getOption("tinyplot_dynmar"))) FALSE else as.logical(getOption("tinyplot_dynmar")) + .tpar[["dynmar"]] = if (is.null(getOption("tinyplot_dynmar"))) FALSE else as.logical(getOption("tinyplot_dynmar")) # Figure output options if written to file - .tpar$file.width = if (is.null(getOption("tinyplot_file.width"))) 7 else as.numeric(getOption("tinyplot_file.width")) - .tpar$file.height = if (is.null(getOption("tinyplot_file.height"))) 7 else as.numeric(getOption("tinyplot_file.height")) - .tpar$file.res = if (is.null(getOption("tinyplot_file.res"))) 300 else as.numeric(getOption("tinyplot_file.res")) + .tpar[["file.width"]] = if (is.null(getOption("tinyplot_file.width"))) 7 else as.numeric(getOption("tinyplot_file.width")) + .tpar[["file.height"]] = if (is.null(getOption("tinyplot_file.height"))) 7 else as.numeric(getOption("tinyplot_file.height")) + .tpar[["file.res"]] = if (is.null(getOption("tinyplot_file.res"))) 300 else as.numeric(getOption("tinyplot_file.res")) + + # Record plots as replayable objects (see `?recordedtinyplot`) + .tpar[["record"]] = if (!is.null(record_old)) { + record_old + } else if (is.null(getOption("tinyplot_record"))) { + NULL + } else { + as.logical(getOption("tinyplot_record")) + } # Facet margin, i.e. gap between the individual facet windows - .tpar$fmar = if (is.null(getOption("tinyplot_fmar"))) c(1, 1, 1, 1) else as.numeric(getOption("tinyplot_fmar")) + .tpar[["fmar"]] = if (is.null(getOption("tinyplot_fmar"))) c(1, 1, 1, 1) else as.numeric(getOption("tinyplot_fmar")) # Other facet options - .tpar$facet.cex = if (is.null(getOption("tinyplot_facet.cex"))) 1 else as.numeric(getOption("tinyplot_facet.cex")) - .tpar$facet.font = if (is.null(getOption("tinyplot_facet.font"))) NULL else as.numeric(getOption("tinyplot_facet.font")) - .tpar$facet.col = if (is.null(getOption("tinyplot_facet.col"))) NULL else getOption("tinyplot_facet.col") - .tpar$facet.bg = if (is.null(getOption("tinyplot_facet.bg"))) NULL else getOption("tinyplot_facet.bg") - .tpar$facet.border = if (is.null(getOption("tinyplot_facet.border"))) NA else getOption("tinyplot_facet.border") - .tpar$facet.labeller = if (is.null(getOption("tinyplot_facet.labeller"))) NULL else getOption("tinyplot_facet.labeller") - .tpar$facet.prefix = if (is.null(getOption("tinyplot_facet.prefix"))) NULL else getOption("tinyplot_facet.prefix") - .tpar$facet.sep = if (is.null(getOption("tinyplot_facet.sep"))) NULL else getOption("tinyplot_facet.sep") + .tpar[["facet.cex"]] = if (is.null(getOption("tinyplot_facet.cex"))) 1 else as.numeric(getOption("tinyplot_facet.cex")) + .tpar[["facet.font"]] = if (is.null(getOption("tinyplot_facet.font"))) NULL else as.numeric(getOption("tinyplot_facet.font")) + .tpar[["facet.col"]] = if (is.null(getOption("tinyplot_facet.col"))) NULL else getOption("tinyplot_facet.col") + .tpar[["facet.bg"]] = if (is.null(getOption("tinyplot_facet.bg"))) NULL else getOption("tinyplot_facet.bg") + .tpar[["facet.border"]] = if (is.null(getOption("tinyplot_facet.border"))) NA else getOption("tinyplot_facet.border") + .tpar[["facet.labeller"]] = if (is.null(getOption("tinyplot_facet.labeller"))) NULL else getOption("tinyplot_facet.labeller") + .tpar[["facet.prefix"]] = if (is.null(getOption("tinyplot_facet.prefix"))) NULL else getOption("tinyplot_facet.prefix") + .tpar[["facet.sep"]] = if (is.null(getOption("tinyplot_facet.sep"))) NULL else getOption("tinyplot_facet.sep") # Plot grid - .tpar$grid = if (is.null(getOption("tinyplot_grid"))) FALSE else as.logical(getOption("tinyplot_grid")) - .tpar$grid.col = if (is.null(getOption("tinyplot_grid.col"))) "lightgray" else getOption("tinyplot_grid.col") - .tpar$grid.lty = if (is.null(getOption("tinyplot_grid.lty"))) "dotted" else getOption("tinyplot_grid.lty") - .tpar$grid.lwd = if (is.null(getOption("tinyplot_grid.lwd"))) 1 else as.numeric(getOption("tinyplot_grid.lwd")) + .tpar[["grid"]] = if (is.null(getOption("tinyplot_grid"))) FALSE else as.logical(getOption("tinyplot_grid")) + .tpar[["grid.col"]] = if (is.null(getOption("tinyplot_grid.col"))) "lightgray" else getOption("tinyplot_grid.col") + .tpar[["grid.lty"]] = if (is.null(getOption("tinyplot_grid.lty"))) "dotted" else getOption("tinyplot_grid.lty") + .tpar[["grid.lwd"]] = if (is.null(getOption("tinyplot_grid.lwd"))) 1 else as.numeric(getOption("tinyplot_grid.lwd")) # Default colour for single-group displays (NULL defers to the first # qualitative palette colour, or base palette()[1] if no theme is active) - .tpar$col.default = if (is.null(getOption("tinyplot_col.default"))) NULL else getOption("tinyplot_col.default") + .tpar[["col.default"]] = if (is.null(getOption("tinyplot_col.default"))) NULL else getOption("tinyplot_col.default") # Legend justification - .tpar$ljust = if (is.null(getOption("tinyplot_ljust"))) "left" else getOption("tinyplot_ljust") + .tpar[["ljust"]] = if (is.null(getOption("tinyplot_ljust"))) "left" else getOption("tinyplot_ljust") # Legend margin, i.e. gap between the legend and the plot elements - .tpar$lmar = if (is.null(getOption("tinyplot_lmar"))) c(1.0, 0.1) else as.numeric(getOption("tinyplot_lmar")) + .tpar[["lmar"]] = if (is.null(getOption("tinyplot_lmar"))) c(1.0, 0.1) else as.numeric(getOption("tinyplot_lmar")) # Alpha fill (transparency) default for ribbon and area plots - .tpar$ribbon.alpha = if (is.null(getOption("tinyplot_ribbon.alpha"))) 0.2 else as.numeric(getOption("tinyplot_ribbon.alpha")) + .tpar[["ribbon.alpha"]] = if (is.null(getOption("tinyplot_ribbon.alpha"))) 0.2 else as.numeric(getOption("tinyplot_ribbon.alpha")) } ## initialize internal environment for tpar variables diff --git a/R/zzz.R b/R/zzz.R index 9a69f29c1..92244d07f 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -78,6 +78,7 @@ "oxaxis", "oyaxis", "pch", + "record", "rev_x", "rev_y", "ribbon.alpha", diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml index adefe681b..de35b9663 100644 --- a/altdoc/pkgdown.yml +++ b/altdoc/pkgdown.yml @@ -2,7 +2,7 @@ altdoc: 0.7.3 pandoc: 3.10.2 pkgdown: 2.1.3 pkgdown_sha: ~ -last_built: 2026-09-05T20:50:52+0000 +last_built: 2026-09-11T03:06:01+0000 urls: reference: https://grantmcdermott.com/tinyplot/man article: https://grantmcdermott.com/tinyplot/vignettes diff --git a/altdoc/quarto_website.yml b/altdoc/quarto_website.yml index ffaae09b9..ccae17557 100644 --- a/altdoc/quarto_website.yml +++ b/altdoc/quarto_website.yml @@ -154,6 +154,8 @@ website: file: man/draw_legend.qmd - text: get_saved_par file: man/get_saved_par.qmd + - text: recordedtinyplot + file: man/recordedtinyplot.qmd # - text: tinyAxis # file: man/tinyAxis.qmd - text: tinylabel diff --git a/inst/tinytest/test-record.R b/inst/tinytest/test-record.R new file mode 100644 index 000000000..20c6fb6ea --- /dev/null +++ b/inst/tinytest/test-record.R @@ -0,0 +1,160 @@ +source("helpers.R") +using("tinysnapshot") + +# Unlike the other test files, this one manages devices explicitly: whether a +# device records is exactly what `record` depends on, so it is the fixture +# rather than incidental setup. pdf(NULL) is a sink that draws nowhere and +# writes no file, and like all file devices it starts with its display list +# inhibited -- which is what makes it useful for both cases below. +recording_device = function() { + pdf(NULL) + dev.control(displaylist = "enable") # as interactive devices do by default +} +nonrecording_device = function() { + pdf(NULL) # as file-based devices are by default +} + + +# `record` is opt-in: the default return value is NULL, as before. +recording_device() +expect_null(tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris)) + +# record = TRUE returns a replayable object with a non-empty display list. +# It is wrapped in our own class, but still inherits from "recordedplot" so +# that replayPlot() and friends keep working on it. +p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = TRUE) +expect_inherits(p, "recordedtinyplot") +expect_inherits(p, "recordedplot") +expect_true(length(p[[1]]) > 0L) +expect_silent(replayPlot(p)) + +# the wrapper carries the plot context, and str() reports it compactly rather +# than dumping the display list +expect_true(is.list(attr(p, "tinyplot_state"))) +expect_inherits(attr(p, "tinyplot_state")[[".last_call"]], "call") +expect_true(length(capture.output(str(p))) < 10L) + +# tpar() sets the default, and an explicit argument takes precedence over it +tpar(record = TRUE) +expect_inherits(tinyplot(Sepal.Length ~ Petal.Length, data = iris), "recordedplot") +expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = FALSE)) +tpar(record = NULL) +expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris)) + +# A theme switch must not clear tpar(record), including the ephemeral themes +# that tinyplot(theme=) applies and then undoes around a single plot. +tpar(record = TRUE) +tinytheme("clean") +expect_true(tpar("record")) +tinytheme() +expect_true(tpar("record")) +expect_inherits( + tinyplot(Sepal.Length ~ Petal.Length, data = iris, theme = "clean"), + "recordedtinyplot" +) +expect_true(tpar("record")) +tinyplot(Sepal.Length ~ Petal.Length, data = iris, theme = "default") +expect_true(tpar("record")) +tpar(record = NULL) +dev.off() + +# A non-recording device yields an empty plot; warn rather than hand back +# something that silently replays blank. +nonrecording_device() +expect_warning( + tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = TRUE), + pattern = "not recording" +) +dev.off() + +# tinyplot enables the display list on devices it opens itself, so record = TRUE +# and file = must compose (no enclosing device here: `file` supplies its own). +tmp = tempfile(fileext = ".png") +q = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, + record = TRUE, file = tmp) +expect_inherits(q, "recordedplot") +expect_true(length(q[[1]]) > 0L) +expect_true(file.exists(tmp)) +unlink(tmp) + +# ... and without record, file = still returns NULL +tmp2 = tempfile(fileext = ".png") +expect_null(tinyplot(Sepal.Length ~ Petal.Length, data = iris, file = tmp2)) +unlink(tmp2) + +# invalid input +nonrecording_device() +expect_error( + tinyplot(Sepal.Length ~ Petal.Length, data = iris, record = "yes"), + pattern = "record" +) +dev.off() +expect_error(tpar(record = "yes"), pattern = "record") +# NB: tpar() assigns before it validates, so a rejected value sticks and would +# poison every later call in this file. Reset it explicitly. +tpar(record = NULL) + +# A recorded plot replays to exactly the same output as the original draw. +# Deliberately not a snapshot test: replaying onto a *different* device than the +# one that recorded is not faithful (text metrics differ by backend), and the +# snapshot device is configured separately from whatever records here. Comparing +# two files produced in this session sidesteps that and tests the real property. +if (requireNamespace("svglite", quietly = TRUE)) { + draw = function(f, record = FALSE) { + svglite::svglite(f, width = 7, height = 7) + on.exit(dev.off()) + if (record) dev.control(displaylist = "enable") + tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, record = record) + } + f_native = tempfile(fileext = ".svg") + f_replay = tempfile(fileext = ".svg") + draw(f_native) + rec = draw(tempfile(fileext = ".svg"), record = TRUE) + svglite::svglite(f_replay, width = 7, height = 7) + replayPlot(rec) + dev.off() + native = readLines(f_native, warn = FALSE) + replay = readLines(f_replay, warn = FALSE) + # guard against the comparison passing vacuously on two empty files + expect_true(length(native) > 10L) + expect_identical(replay, native) + unlink(c(f_native, f_replay)) +} + +# The point of the wrapper: replaying restores plot context, so a following +# tinyplot_add() layers onto the replayed plot rather than whichever plot was +# drawn most recently. Reordered categories make this a strict test -- it also +# covers align_layer(), which validates against usr_orig/dev_orig and so needs +# those refreshed on replay rather than left describing the intervening plot. +if (requireNamespace("svglite", quietly = TRUE)) { + d1 = data.frame(g = factor(c("a", "b", "c")), y = c(1, 2, 3)) + d2 = data.frame( + g = factor(c("a", "b", "c"), levels = c("c", "b", "a")), + y = c(1.5, 2.5, 3.5) + ) + f_ref = tempfile(fileext = ".svg") + f_rep = tempfile(fileext = ".svg") + + svglite::svglite(f_ref, width = 7, height = 7) + tinyplot(y ~ g, data = d1) + tinyplot_add(y ~ g, data = d2, col = "red") + dev.off() + + svglite::svglite(tempfile(fileext = ".svg"), width = 7, height = 7) + dev.control(displaylist = "enable") + rec = tinyplot(y ~ g, data = d1, record = TRUE) + tinyplot(1:10) # intervening plot: leaves usr_orig describing *this* plot + dev.off() + + svglite::svglite(f_rep, width = 7, height = 7) + print(rec) + tinyplot_add(y ~ g, data = d2, col = "red") + dev.off() + + ref = readLines(f_ref, warn = FALSE) + expect_true(length(ref) > 10L) + expect_identical(readLines(f_rep, warn = FALSE), ref) + unlink(c(f_ref, f_rep)) +} + +tpar(record = NULL) diff --git a/man/recordedtinyplot.Rd b/man/recordedtinyplot.Rd new file mode 100644 index 000000000..287d3d21a --- /dev/null +++ b/man/recordedtinyplot.Rd @@ -0,0 +1,66 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/record.R +\name{recordedtinyplot} +\alias{recordedtinyplot} +\alias{print.recordedtinyplot} +\alias{str.recordedtinyplot} +\title{Recorded tinyplot objects} +\usage{ +\method{print}{recordedtinyplot}(x, ...) + +\method{str}{recordedtinyplot}(object, ...) +} +\arguments{ +\item{x}{a \code{recordedtinyplot} object, returned by a +\code{tinyplot(..., record = TRUE)} call.} + +\item{...}{further arguments passed to \code{\link[grDevices]{replayPlot}}.} + +\item{object}{a \code{recordedtinyplot} object.} +} +\value{ +\code{print()} replays the plot and returns \code{x} invisibly. +} +\description{ +Objects of class \code{recordedtinyplot} are returned by +\code{tinyplot(..., record = TRUE)}. They are a thin wrapper around +\code{\link[grDevices]{recordPlot}}---thus conferring the same replay +functionality---but with the initializing plot call and state added, so that +recorded (tiny)plots play nicely with \code{\link{tinyplot_add}()} and +friends. +} +\details{ +Recording requires a device whose display list is enabled (see +\code{\link[grDevices]{dev.control}}); without one there is nothing to +record and the returned plot will replay blank. Interactive devices---the +plot pane of an IDE, say---typically enable it by default, whereas +file-based devices (\code{png}, \code{pdf}, \code{svg}, ...) do not. \code{tinyplot()} enables +it for any device that it opens itself via the \code{file} argument, and warns +if the current device is not recording. + +Printing a recorded plot---either explicitly with \code{print()}, or simply by +evaluating it at the console---is what restores the initializing call, so +that a subsequent \code{\link{tinyplot_add}()} layers onto it. Calling +\code{\link[grDevices]{replayPlot}()} on the object instead redraws it +just as it would any other recording, but leaves the current plot context +untouched; layering after that route targets whichever plot was drawn last. + +This class is experimental, as is the \code{record} argument that produces it. +} +\examples{ +\dontrun{ +p = tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris, + record = TRUE) +tinyplot(1:10) # some other plot +p # replay, restoring plot context +tinyplot_add(type = "lm") # layers onto p, not onto the 1:10 plot +} + +} +\seealso{ +\code{\link[grDevices]{recordPlot}} and +\code{\link[grDevices]{replayPlot}}, which this class wraps and defers to +for the actual recording and replaying. +\code{\link[grDevices]{dev.control}} for enabling a device's display list, +without which there is nothing to record. +} diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 59521b8ac..cf1e12c18 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -64,6 +64,7 @@ tinyplot(x, ...) file = NULL, width = NULL, height = NULL, + record = NULL, asp = NA, theme = NULL, ... @@ -597,10 +598,10 @@ existing plot without having to repeat arguments.} \code{y} are plotted). The \code{draw} argument is primarily useful for adding common elements to each facet of a faceted plot, e.g. \code{\link[graphics]{abline}} or \code{\link[graphics]{text}}. Note that -this argument is somewhat experimental and that \emph{no} internal checking is -done for correctness; the provided argument is simply captured and -evaluated as-is within \code{tinyplot()} and thus has access to the local -definition of all variables such as \code{x}, \code{y}, etc. See Examples.} +\emph{no} internal checking is done for correctness; the provided argument is +simply captured and evaluated as-is within \code{tinyplot()} and thus has +access to the local definition of all variables such as \code{x}, \code{y}, etc. +See Examples.} \item{empty}{logical indicating whether the interior plot region should be left empty. The default is \code{FALSE}. Setting to \code{TRUE} has a similar effect @@ -651,6 +652,19 @@ graphics windows.} \code{width} (above) apply, e.g. will default to \code{tpar("file.height")} if not specified.} +\item{record}{(experimental) a logical value indicating whether the plot +should be recorded and returned as a replayable +\code{\link{recordedtinyplot}} object. Defaults to \code{FALSE}. Setting to +\code{TRUE} allows for assignment and later recall, e.g. +\verb{myplot = tinyplot(...); myplot}. This behaviour can also be set globally +via \code{tpar(record = TRUE)}; an explicit argument here takes precedence. + +Note that recording requires a device with an enabled display list (see +\code{\link[grDevices]{dev.control}}). Most interactive devices enable this +behaviour by default, whereas file-based devices do not. However \code{tinyplot} +automatically enables it for any device that it opens itself via \code{file}, +and further emits a warning if the current device is not recording.} + \item{asp}{the y/xy/x aspect ratio, see \code{plot.window}.} \item{theme}{keyword string (e.g. \code{"clean"}) or list defining a theme. Passed @@ -672,7 +686,10 @@ should not be specified in the same call.} when extracting the data from \code{formula} and \code{data}.} } \value{ -No return value, called for side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Enhances the base \code{\link[graphics]{plot}} function. Supported features diff --git a/man/tinyplot.data.frame.Rd b/man/tinyplot.data.frame.Rd index 933f471ec..f2d3eaddb 100644 --- a/man/tinyplot.data.frame.Rd +++ b/man/tinyplot.data.frame.Rd @@ -37,7 +37,12 @@ to disambiguate from \code{frame.plot}.} \item{...}{further arguments passed to \code{tinyplot}.} } \value{ -No return value, called for the side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedtinyplot"} object; see +\code{\link{recordedtinyplot}}. The exception is the pairs-style case +(more than two columns), which draws a grid of sub-plots and always +returns \code{NULL}. } \description{ Convenience interface for visualizing diff --git a/man/tinyplot.matrix.Rd b/man/tinyplot.matrix.Rd index ad9084486..c20c91063 100644 --- a/man/tinyplot.matrix.Rd +++ b/man/tinyplot.matrix.Rd @@ -36,7 +36,10 @@ titles default to \code{NA}, since the dimnames label both axes.} \item{...}{further arguments passed to \code{tinyplot}.} } \value{ -No return value, called for the side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Convenience interface for visualizing diff --git a/man/tinyplot.ts.Rd b/man/tinyplot.ts.Rd index 2efaed63f..63d308a9d 100644 --- a/man/tinyplot.ts.Rd +++ b/man/tinyplot.ts.Rd @@ -25,7 +25,10 @@ multivariate series.} \item{type, facet.args, xlab, ylab, ...}{further arguments passed to \code{tinyplot}.} } \value{ -No return value, called for the side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ Convenience interface for visualizing \code{\link[stats]{ts}} diff --git a/man/tinyplot_add.Rd b/man/tinyplot_add.Rd index ba4634ab9..cd5244ea0 100644 --- a/man/tinyplot_add.Rd +++ b/man/tinyplot_add.Rd @@ -18,7 +18,10 @@ so those that rely on non-standard evaluation against \code{data}---e.g. \code{\link{tinyplot}} call.} } \value{ -No return value, called for side effect of producing a plot. +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. } \description{ This convenience function grabs the preceding \code{tinyplot} call and updates it diff --git a/man/tpar.Rd b/man/tpar.Rd index 7523478c6..680290ba6 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -98,6 +98,7 @@ you should rather use \code{par()} instead. \item \code{lwd.xaxs}, \code{lwd.yaxs}: Line widths for the x- and y-axis lines, respectively. Both default to \code{NULL}, whereby the shared \code{lwd.axis} value is used instead. \item \code{palette.qualitative}: Palette for qualitative colors. See the \code{palette} argument in \code{?tinyplot}. \item \code{palette.sequential}: Palette for sequential colors. See the \code{palette} argument in \code{?tinyplot}. +\item \code{record}: (experimental) Logical indicating whether \code{tinyplot()} should record plots and return them as replayable \code{\link{recordedtinyplot}} objects. Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. \item \code{ribbon.alpha}: Numeric factor in the range \verb{[0,1]} for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is \code{0.2}. \item \code{xaxr}, \code{yaxr}: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; \code{NULL} (the default) leaves them unrotated. Unlike \code{las}, which is limited to the four right angles, any angle is permitted. Setting one overrides \code{las} for that axis alone, leaving the other axis under \code{las} as usual, and \code{0} (or any multiple of 360) counts as no rotation at all. Sets the default for the \code{xaxr} and \code{yaxr} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base \code{axis()}. First, margins are only resized to fit them under a theme with \code{dynmar = TRUE} (see \code{tinytheme}); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen \code{mar} yourself. Second, rotated labels do not inherit the thinning that \code{axis()} applies via \code{gap.axis}, so they start to overlap once the spacing between ticks falls below \verb{line height / sin(srt)}. } diff --git a/vignettes/introduction.qmd b/vignettes/introduction.qmd index 059f1625b..5cc42123e 100644 --- a/vignettes/introduction.qmd +++ b/vignettes/introduction.qmd @@ -555,25 +555,31 @@ tinyplot( ) ``` -## Saving plots + +```{r theme_again} +#| include: false +tinytheme("clean2") +``` + +## Save and replay plots A final point to note is that **tinyplot** offers convenience features for -exporting plots to disk. Simply invoke the `file` argument to specify the -relevant file path (including the extension type). You can customize the output -dimensions (in inches) via the accompanying `width` and `height` +saving (and replaying) plots. For example, you can export a plot to disk simply +by invoking the `file` argument and specifying the relevant file path (including +the extension type). You can also customize the output +dimensions (in inches) via the companion `width` and `height` arguments.^[The default dimensions are 7x7, with a resolution of 300 DPI. However, these too can be customized via the `file.width`, `file.height`, and `file.res` parameters in [`tpar()`](https://grantmcdermott.com/tinyplot/man/tpar.html).] ```{r save_plot} -#| eval: false tinyplot( Temp ~ Day | Month, data = aq, file = "aq.png", width = 8, height = 5 ) -# optional: delete the saved plot +# optional: delete the saved png unlink("aq.png") ``` @@ -583,6 +589,34 @@ the traditional approach of manually opening an external graphics device, e.g. over to the exported file. Feel free to try yourself by setting some global graphics parameters via `tpar()` and then using `file` to save a plot. +A related---albeit experimental---feature is the ability to _record_ +plots, keeping them in your session rather than writing them to disk. Pass +`record = TRUE` and **tinyplot** hands back the plot as an object that you can +assign, hold onto, and recall whenever you like. As per the example below, you +can even record a plot that you write to disk and then retrieve it to your +interactive session later on. + +```{r record_plot} +p = tinyplot( + Temp ~ Day | Month, data = aq, + file = "aq2.png", + record = TRUE # <-- record plot for later recall +) +unlink("aq2.png") # optional: delete the saved png + +# + +p # replay initial plot (this time in our interactive window) +plt_add(type = "lm") # add another layer onto it +``` + +As noted above, recording and replaying plots is still something of an +experimental feature, introduced in **tinyplot** v0.8.0. We've made it opt-in +via an explicit argument for now, although you can also enable it globally for +your session via `tpar(record = TRUE)`. One caveat is that recording and +assigning detailed plots with many elements can eventually take up quite a bit +of memory. + ## Reset theme Don't forget to reset the plot theme.