diff --git a/NEWS.md b/NEWS.md index e3d557f9..b198ec7a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -139,12 +139,17 @@ related to plot layering. See "Bug fixes" below. #### Other new features - New top-level `tinyplot()`/`plt()` arguments: + - `xpad` and `ypad` enable control over how much padding (as a fraction of the + data range) is added to each end of the axes. Following base R conventions, + the default for most plots is `0.04`, i.e. 4% padding on each side. Also + settable globally via `tpar(xpad = , ypad = )`. + (#729 @grantmcdermott) - `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) + settable globally via `tpar(xaxr = , 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 diff --git a/R/facet.R b/R/facet.R index ae915cca..3c1397d4 100644 --- a/R/facet.R +++ b/R/facet.R @@ -24,8 +24,8 @@ draw_facet_window = function( facet_blank = FALSE, # axes args axes, flip, frame.plot, oxaxis, oyaxis, - xlabs, xlim, null_xlim, xaxt, xaxs, xaxb, xaxl, xaxr = NULL, - ylabs, ylim, null_ylim, yaxt, yaxs, yaxb, yaxl, yaxr = NULL, + xlabs, xlim, null_xlim, xaxt, xaxs, xaxb, xaxl, xaxr = NULL, xpad = NULL, + ylabs, ylim, null_ylim, yaxt, yaxs, yaxb, yaxl, yaxr = NULL, ypad = NULL, rev_x = FALSE, rev_y = FALSE, xlim_partial = NULL, ylim_partial = NULL, facet_labs = NULL, @@ -395,6 +395,10 @@ draw_facet_window = function( if (log == "x") log_flip = "y" if (log == "y") log_flip = "x" } + # Explicit user pad -> calculated limits are already final, so avoid + # automatic par(x/yaxs = "r") expansion. "i" takes limits as fixed. + if (!is.null(ypad)) pdots[["xaxs"]] = "i" + if (!is.null(xpad)) pdots[["yaxs"]] = "i" do.call( "plot.window", c(list(xlim = ylim, ylim = xlim, asp = asp, log = log_flip), pdots) @@ -403,6 +407,11 @@ draw_facet_window = function( yside = 1 } else { ## ... standard plot window for all other cases + + # Explicit user pad -> calculated limits are already final, so avoid + # automatic par(x/yaxs = "r") expansion. "i" takes limits as fixed. + if (!is.null(xpad)) pdots[["xaxs"]] = "i" + if (!is.null(ypad)) pdots[["yaxs"]] = "i" do.call( "plot.window", c(list(xlim = xlim, ylim = ylim, asp = asp, log = log), pdots) @@ -485,13 +494,18 @@ draw_facet_window = function( # each panel spans only the categories it uses. .xall_cat = length(.fxlabs) > 0 && is.null(facet_labs[["x"]]) .yall_cat = length(.fylabs) > 0 && is.null(facet_labs[["y"]]) - if (null_xlim || !is.null(xlim_partial)) { + # A panel that derives its own range still has to expand it; one that + # inherits the fixed limits does not, because lim_args() expanded those + # already. Re-expanding an inherited range applies the padding twice. + .derived_x = null_xlim || !is.null(xlim_partial) + .derived_y = null_ylim || !is.null(ylim_partial) + if (.derived_x) { xlim = facet_free_lim( if (.xall_cat) xcat else xfree, xall, xlim_partial, "xlim" ) + .pad if (length(.fxlabs)) xlim = range(c(xlim, .fxlabs)) } - if (null_ylim || !is.null(ylim_partial)) { + if (.derived_y) { ylim = facet_free_lim( if (.yall_cat) ycat else yfree, yall, ylim_partial, "ylim" ) @@ -504,9 +518,15 @@ draw_facet_window = function( # the descending order. (#644) rev_xext = isTRUE(rev_x) || (!null_xlim && length(xlim) == 2L && xlim[2L] < xlim[1L]) rev_yext = isTRUE(rev_y) || (!null_ylim && length(ylim) == 2L && ylim[2L] < ylim[1L]) - # extendrange() returns an ascending pair, so reverse afterwards - xext = extendrange(sort(xlim), f = 0.04) - yext = extendrange(sort(ylim), f = 0.04) + # expand_lim() returns an ascending pair, so reverse afterwards + xext = sort(xlim) + yext = sort(ylim) + # Free panels expand at a flat rate regardless of xaxs/yaxs; the + # barplot_facet_free snapshot will catch you if you change that. + .padded_x = !is.null(xpad) && !.derived_x # lim_args() got to it first + .padded_y = !is.null(ypad) && !.derived_y + if (!.padded_x) xext = expand_lim(xext, xpad %||% 0.04) + if (!.padded_y) yext = expand_lim(yext, ypad %||% 0.04) # A facet with a single distinct x (or y) value yields a zero-width # extent, which par(usr=) rejects. Mirror base plot.window() and pad # a degenerate range symmetrically so the facet still draws. (#668) diff --git a/R/flip.R b/R/flip.R index 636e8566..3b3b9ef7 100644 --- a/R/flip.R +++ b/R/flip.R @@ -32,6 +32,7 @@ flip_datapoints = function(settings) { swap_elements(settings, "xlab", "ylab") swap_elements(settings, "xlabs", "ylabs") swap_elements(settings, "xlim", "ylim") + swap_elements(settings, "xpad", "ypad") swap_elements(settings, "null_xlim", "null_ylim") swap_elements(settings, "rev_x", "rev_y") swap_elements(settings, "xmax", "ymax") diff --git a/R/lim.R b/R/lim.R index 34e62976..c3437e49 100644 --- a/R/lim.R +++ b/R/lim.R @@ -7,7 +7,7 @@ lim_args = function(settings) { c( "xaxb", "xlabs", "xlim", "null_xlim", "rev_x", "yaxb", "ylabs", "ylim", "null_ylim", "rev_y", - "datapoints", "type", "type_hints" + "datapoints", "type", "type_hints", "xpad", "ypad", "log" ) ) @@ -51,6 +51,15 @@ lim_args = function(settings) { if (null_xlim && !is.null(xaxb) && !prop_lim) xlim = range(c(xlim, xaxb)) if (null_ylim && !is.null(yaxb) && !prop_lim) ylim = range(c(ylim, yaxb)) + if (!is.null(xpad)) { + xlim = expand_lim(widen_degenerate(xlim), xpad, + log = grepl("x", log, fixed = TRUE)) + } + if (!is.null(ypad)) { + ylim = expand_lim(widen_degenerate(ylim), ypad, + log = grepl("y", log, fixed = TRUE)) + } + # reverse axis direction last, once the range is otherwise finalized if (isTRUE(rev_x)) xlim = rev(xlim) if (isTRUE(rev_y)) ylim = rev(ylim) @@ -59,7 +68,7 @@ lim_args = function(settings) { env2env( environment(), settings, - c("xlim", "ylim", "xlabs", "ylabs", "xaxb", "yaxb") + c("xlim", "ylim", "xpad", "ypad", "xlabs", "ylabs", "xaxb", "yaxb") ) } @@ -68,6 +77,32 @@ lim_args = function(settings) { # x/ylim helpers ---- # +# Widen a data range by `pad` at each end, as a fraction of the range. +# +# A logged axis is expanded in log space, which is where base applies it too -- +# expanding the raw values would put the padding in the wrong place entirely +# once the range spans decades. +expand_lim = function(lim, pad, log = FALSE) { + if (length(lim) != 2L || !all(is.finite(lim))) return(lim) + if (!is.finite(pad) || pad == 0) return(lim) + logged = isTRUE(log) && all(lim > 0) + if (logged) lim = log10(lim) + out = lim + c(-1, 1) * pad * diff(lim) + if (logged) out = 10^out + out +} + +# Widen a zero-width range the way base R does before any style expansion is +# applied: out to 0.4 of the value either side, or to +/-1 when the value is +# zero. Kept separate from expand_lim() because the three places tinyplot +# expands a range do not currently agree on this rule (base's here, 0.04 of the +# value in free facets, half a unit in the dynmar predictor). Reconciling them +# is a behaviour change and deliberately not part of this one. +widen_degenerate = function(lim) { + if (length(lim) != 2L || !all(is.finite(lim)) || diff(lim) != 0) return(lim) + lim + c(-1, 1) * (if (lim[1L] == 0) 1 else 0.4 * abs(lim[1L])) +} + # Resolve a user-supplied x/ylim that may be a scalar or contains a single NA. # `lim` : raw user value (already known to be non-NULL) # `drng` : data range, 2-element numeric, i.e. range(..., finite = TRUE) diff --git a/R/sanitize_axes.R b/R/sanitize_axes.R index 3096fa99..581d44e6 100644 --- a/R/sanitize_axes.R +++ b/R/sanitize_axes.R @@ -1,6 +1,6 @@ sanitize_axes = function(settings) { env2env(settings, environment(), - c("axes", "xaxt", "yaxt", "frame.plot", "xaxr", "yaxr")) + c("axes", "xaxt", "yaxt", "frame.plot", "xaxr", "yaxr", "xpad", "ypad")) ## handle defaults of axes, xaxt, yaxt, frame.plot ## - convert axes to character if necessary ## - set defaults of xaxt/yaxt (if these are NULL) based on axes @@ -35,6 +35,18 @@ sanitize_axes = function(settings) { if (!is.null(xaxr) && (!is.finite(xaxr) || xaxr %% 360 == 0)) xaxr = NULL if (!is.null(yaxr) && (!is.finite(yaxr) || yaxr %% 360 == 0)) yaxr = NULL - env2env(environment(), settings, - c("axes", "xaxt", "yaxt", "frame.plot", "xaxr", "yaxr")) + ## axis padding: an explicit x/ypad wins over the theme's tpar setting. This + ## has to resolve here, before flip_datapoints() swaps the pair -- resolving + ## it later would leave a tpar default attached to the axis rather than to + ## the variable, so the two paths would disagree under `flip`. + if (is.null(xpad)) xpad = get_tpar("xpad") + if (is.null(ypad)) ypad = get_tpar("ypad") + assert_numeric(xpad, len = 1, lower = 0, null.ok = TRUE, name = "xpad") + assert_numeric(ypad, len = 1, lower = 0, null.ok = TRUE, name = "ypad") + + env2env( + environment(), + settings, + c("axes", "xaxt", "yaxt", "frame.plot", "xaxr", "yaxr", "xpad", "ypad") + ) } diff --git a/R/tinyAxis.R b/R/tinyAxis.R index 155e9a80..fb48bf20 100644 --- a/R/tinyAxis.R +++ b/R/tinyAxis.R @@ -202,13 +202,17 @@ axis_tick_labels = function(labelset, lim, axb = NULL, axl = NULL, log = FALSE, ## A log axis can't represent a zero or negative limit, so those fall back to a ## linear measurement: plot.window() raises its own, clearer complaint moments ## later, and log10() here would only put "NaNs produced" in front of it. -axis_usr = function(lim, log = FALSE, axb = NULL) { +axis_usr = function(lim, log = FALSE, axb = NULL, pad = NULL) { log = isTRUE(log) && all(is.finite(lim)) && all(lim > 0) if (log) lim = log10(lim) # A single distinct value gives a zero-width range that extendrange() can't # pad and axisTicks() can't tick, so widen it the way plot.window() does. # An explicit `at` (xaxb/yaxb) supplies its own ticks, hence the guard. - usr = if (diff(lim) == 0 && is.null(axb)) { + # A non-NULL `pad` means lim_args() has already expanded these limits, so + # there is nothing left to add. + usr = if (!is.null(pad)) { + lim + } else if (diff(lim) == 0 && is.null(axb)) { lim + c(-0.5, 0.5) } else { extendrange(lim, f = 0.04) diff --git a/R/tinyplot.R b/R/tinyplot.R index 9cd80d33..021feb43 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -275,6 +275,11 @@ #' @param xaxs,yaxs character specifying the style of the interval calculation #' used for the x-axis and y-axis, respectively. See #' \code{\link[graphics]{par}} for the possible values. +#' @param xpad,ypad numeric specifying how much padding, as a fraction of the +#' data range, should be added to each end of the axes. Defaults to `NULL`, +#' in which case behaviour depends on the value of `x/yaxs`. In most cases, +#' this will translate to a value of `0.04`, i.e. 4% padding on each end (see +#' \code{\link[graphics]{par}}). #' @param xaxb,yaxb numeric vector (or character vector, if appropriate) giving #' the break points at which the axis tick-marks are to be drawn. Break points #' outside the range of the data will be ignored if the associated axis @@ -788,7 +793,9 @@ tinyplot.default = function( xaxt = NULL, yaxt = NULL, xaxs = NULL, + xpad = NULL, yaxs = NULL, + ypad = NULL, xaxb = NULL, yaxb = NULL, xaxl = NULL, @@ -982,11 +989,13 @@ tinyplot.default = function( xaxl = xaxl, xaxr = xaxr, xaxs = xaxs, + xpad = xpad, yaxt = yaxt, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, yaxs = yaxs, + ypad = ypad, frame.plot = frame.plot, xlim = xlim, ylim = ylim, @@ -1392,7 +1401,7 @@ tinyplot.default = function( max(0, fin / par("csi") - pad) } if (!is.null(xaxr)) { - .u = axis_usr(xlim, log = .xlog, axb = xaxb) + .u = axis_usr(xlim, log = .xlog, axb = xaxb, pad = xpad) .at = if (!is.null(xlabs)) as.numeric(xlabs) else axisTicks(usr = .u[["usr"]], log = .u[["log"]]) # axisTicks() reports tick locations in data units, but the inset is a @@ -1404,7 +1413,7 @@ tinyplot.default = function( .dyn = .add_lean(.dyn, .ovh, .flank(.xside)) } if (!is.null(yaxr)) { - .u = axis_usr(ylim, log = .ylog, axb = yaxb) + .u = axis_usr(ylim, log = .ylog, axb = yaxb, pad = ypad) .at = if (!is.null(ylabs)) as.numeric(ylabs) else axisTicks(usr = .u[["usr"]], log = .u[["log"]]) # axisTicks() reports tick locations in data units, but the inset is a @@ -1533,10 +1542,14 @@ tinyplot.default = function( } par(mar = dynmar_computed + .whtsbp) if (!is.null(xlim) && !is.null(ylim)) { - plot.window(xlim = xlim, ylim = ylim) + plot.window(xlim = xlim, ylim = ylim, + xaxs = if (is.null(xpad)) par("xaxs") else "i", + yaxs = if (is.null(ypad)) par("yaxs") else "i") } } else if (direct_labels_flag && !is.null(xlim) && !is.null(ylim)) { - plot.window(xlim = xlim, ylim = ylim) + plot.window(xlim = xlim, ylim = ylim, + xaxs = if (is.null(xpad)) par("xaxs") else "i", + yaxs = if (is.null(ypad)) par("yaxs") else "i") } # Expand right margin for direct labels based on actual label overshoot @@ -1559,7 +1572,9 @@ tinyplot.default = function( cur_mar[4] = cur_mar[4] + overshoot_lines par(mar = cur_mar) } - plot.window(xlim = xlim, ylim = ylim) + plot.window(xlim = xlim, ylim = ylim, + xaxs = if (is.null(xpad)) par("xaxs") else "i", + yaxs = if (is.null(ypad)) par("yaxs") else "i") } } @@ -1641,8 +1656,8 @@ tinyplot.default = function( # axes args axes = axes, flip = flip, frame.plot = frame.plot, oxaxis = oxaxis, oyaxis = oyaxis, - xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, - ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, + xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, xpad = xpad, + ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, ypad = ypad, rev_x = rev_x, rev_y = rev_y, xlim_partial = xlim_partial, ylim_partial = ylim_partial, facet_labs = facet_labs, @@ -1678,8 +1693,8 @@ tinyplot.default = function( nfacets = nfacets, nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows, axes = axes, flip = flip, frame.plot = frame.plot, oxaxis = oxaxis, oyaxis = oyaxis, - xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, - ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, + xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, xpad = xpad, + ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, ypad = ypad, rev_x = rev_x, rev_y = rev_y, xlim_partial = xlim_partial, ylim_partial = ylim_partial, facet_labs = facet_labs, diff --git a/R/tpar.R b/R/tpar.R index a56ec402..0c542bf6 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -89,6 +89,7 @@ #' * `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`. +#' * `xpad`, `ypad`: Numeric specifying how much padding, as a fraction of the data range, should be added to each end of the x- and y-axis, respectively. Both default to `NULL`, in which case behaviour depends on the value of `x/yaxs`. In most cases, this will translate to a value of `0.04`, i.e. 4% padding on each end (see \code{\link[graphics]{par}}). Sets the default for the `xpad` and `ypad` arguments of [`tinyplot()`], which take precedence. #' * `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)`. #' #' @importFrom graphics par @@ -320,8 +321,10 @@ known_tpar = c( "tinytheme", "xaxr", "xaxt", + "xpad", "yaxr", - "yaxt" + "yaxt", + "ypad" ) @@ -342,6 +345,8 @@ assert_tpar = function(.tpar) { assert_numeric(.tpar[["cex.yaxs"]], len = 1, lower = 0, null.ok = TRUE, name = "cex.yaxs") assert_numeric(.tpar[["xaxr"]], len = 1, null.ok = TRUE, name = "xaxr") assert_numeric(.tpar[["yaxr"]], len = 1, null.ok = TRUE, name = "yaxr") + assert_numeric(.tpar[["xpad"]], len = 1, lower = 0, null.ok = TRUE, name = "xpad") + assert_numeric(.tpar[["ypad"]], len = 1, lower = 0, null.ok = TRUE, name = "ypad") assert_flag(.tpar[["cairo"]], name = "cairo") assert_flag(.tpar[["dynmar"]], null.ok = FALSE, name = "dynmar") assert_choice(.tpar[["ljust"]], choice = c("left", "center", "l", "c"), null.ok = TRUE, name = "ljust") diff --git a/inst/tinytest/test-axis-pad.R b/inst/tinytest/test-axis-pad.R new file mode 100644 index 00000000..8d6d1f3b --- /dev/null +++ b/inst/tinytest/test-axis-pad.R @@ -0,0 +1,80 @@ +source("helpers.R") + +# No snapshots here: every assertion below is a coordinate, so it can be checked +# exactly rather than compared as an image. One sink device for the whole file -- +# par("usr") needs a device open, but nothing needs to look at what is drawn, and +# opening one per call costs ~40x more than reusing this one. +pdf(NULL) + + +# `xpad`/`ypad` take over the axis expansion that base R hardcodes at 4%. +usr_for = function(...) { + tinyplot(y ~ x, data = data.frame(x = c(1, 10), y = c(1, 10)), ...) + par("usr") +} + +# Left alone, nothing changes: base still applies its own 4%. +expect_equal(usr_for()[1:2], c(0.64, 10.36)) + +# 0 fits the axis tightly to the data; a larger value widens it proportionally. +expect_equal(usr_for(xpad = 0)[1:2], c(1, 10)) +expect_equal(usr_for(xpad = 0.2)[1:2], c(-0.8, 11.8)) + +# Each axis is independent: `ypad` tightens y and leaves x on base's default. +expect_equal(usr_for(ypad = 0), c(0.64, 10.36, 1, 10)) + +# The padding follows its variable across a flip, the way `xlim` and `log` do, +# so it lands on whichever axis the x variable ended up on. +expect_equal(usr_for(xpad = 0, flip = TRUE)[3:4], c(1, 10)) + +# A logged axis is expanded in log space, not on the raw values. The pad has to +# be non-zero to tell the two apart: raw-scale padding of log10(c(1, 10)) would +# give c(0.1, 10.9) on the untransformed data, i.e. usr c(-1, 1.037). +expect_equal(usr_for(log = "x", xpad = 0.1)[1:2], c(-0.1, 1.1)) + +# tpar() sets the default; an explicit argument still wins. +tpar(xpad = 0) +expect_equal(usr_for()[1:2], c(1, 10)) +expect_equal(usr_for(xpad = 0.2)[1:2], c(-0.8, 11.8)) +tpar(xpad = NULL) +expect_equal(usr_for()[1:2], c(0.64, 10.36)) + +# A flipped boxplot is the one type flip_datapoints() leaves alone: it hands +# ylim to the physical x axis and xlim to the physical y instead of swapping +# the pair. The pad still belongs to its own variable, so `xpad` has to reach +# the categorical axis -- which lands on y -- rather than the numeric one. +box_usr = function(...) { + d = data.frame(g = factor(rep(c("a", "b", "c"), 4)), y = rep(c(1, 5), 6)) + tinyplot(y ~ g, data = d, type = "boxplot", flip = TRUE, ...) + par("usr") +} +expect_equal(box_usr(xpad = 0)[3:4], c(0.5, 3.5)) # categorical, tight +expect_equal(box_usr()[3:4], c(0.38, 3.62)) # categorical, base's 4% + +# Free facets derive a range per panel, so the padding has to reach them too. +fusr_for = function(...) { + d = data.frame(x = c(1, 10, 1, 10), y = c(1, 10, 2, 20), f = c("a", "a", "b", "b")) + tinyplot(y ~ x, facet = ~f, data = d, facet.args = list(free = TRUE), ...) + tinyplot:::get_environment_variable(".fusr")[[1]] +} +expect_equal(fusr_for(xpad = 0)[1:2], c(1, 10)) +expect_equal(fusr_for()[1:2], c(0.64, 10.36)) + +# A negative pad would crop the data rather than pad it -- past -0.5 it +# collapses the range, and at -1 it silently reverses the axis -- so it is +# refused. There is no upper bound: a large pad is just a zoomed-out plot. +expect_error(usr_for(xpad = -0.1), pattern = "greater than or equal to 0") +expect_error(usr_for(ypad = -1), pattern = "greater than or equal to 0") +expect_error(usr_for(xpad = "a"), pattern = "must be numeric") +expect_error(usr_for(xpad = c(0.1, 0.2)), pattern = "length must be 1") +expect_equal(usr_for(xpad = 3)[1:2], c(-26, 37)) + +# The same checks apply when the value arrives via tpar() rather than the arg. +# NB: tpar() assigns before it validates, so a rejected value sticks and would +# poison every later call (see test-record.R). Reset each one explicitly. +expect_error(tpar(xpad = "a"), pattern = "xpad") +tpar(xpad = NULL) +expect_error(tpar(ypad = -1), pattern = "ypad") +tpar(ypad = NULL) + +dev.off() diff --git a/man/facet.Rd b/man/facet.Rd index 35bb4d7b..7f747fe9 100644 --- a/man/facet.Rd +++ b/man/facet.Rd @@ -37,6 +37,7 @@ draw_facet_window( xaxb, xaxl, xaxr = NULL, + xpad = NULL, ylabs, ylim, null_ylim, @@ -45,6 +46,7 @@ draw_facet_window( yaxb, yaxl, yaxr = NULL, + ypad = NULL, rev_x = FALSE, rev_y = FALSE, xlim_partial = NULL, diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index cf1e12c1..f7b5e3dd 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -37,7 +37,9 @@ tinyplot(x, ...) xaxt = NULL, yaxt = NULL, xaxs = NULL, + xpad = NULL, yaxs = NULL, + ypad = NULL, xaxb = NULL, yaxb = NULL, xaxl = NULL, @@ -437,6 +439,12 @@ respectively. See \code{axes} for the possible values.} used for the x-axis and y-axis, respectively. See \code{\link[graphics]{par}} for the possible values.} +\item{xpad, ypad}{numeric specifying how much padding, as a fraction of the +data range, should be added to each end of the axes. Defaults to \code{NULL}, +in which case behaviour depends on the value of \code{x/yaxs}. In most cases, +this will translate to a value of \code{0.04}, i.e. 4\% padding on each end (see +\code{\link[graphics]{par}}).} + \item{xaxb, yaxb}{numeric vector (or character vector, if appropriate) giving the break points at which the axis tick-marks are to be drawn. Break points outside the range of the data will be ignored if the associated axis diff --git a/man/tpar.Rd b/man/tpar.Rd index 680290ba..de32dc8f 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -100,6 +100,7 @@ you should rather use \code{par()} instead. \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{xpad}, \code{ypad}: Numeric specifying how much padding, as a fraction of the data range, should be added to each end of the x- and y-axis, respectively. Both default to \code{NULL}, in which case behaviour depends on the value of \code{x/yaxs}. In most cases, this will translate to a value of \code{0.04}, i.e. 4\% padding on each end (see \code{\link[graphics]{par}}). Sets the default for the \code{xpad} and \code{ypad} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. \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)}. } }