diff --git a/NEWS.md b/NEWS.md index 0bfdec0e..0a2322a7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -236,6 +236,13 @@ Theme fixes: column arrangements. (#562 @zeileis) - `plt(..., facet = z ~ 1)` <-> `plt(..., facet = ~z, facet.args = list(ncol = 1))` - `plt(..., facet = 1 ~ z)` <-> `plt(..., facet = ~z, facet.args = list(nrow = 1))`. + - `x/ylim` gain several "smart" override forms. (#644 @grantmcdermott) + - A single scalar (e.g. `ylim = 0`) ensures that value is covered by the + axis range, e.g. for forcing zero onto a coefficient plot. + - A length-2 vector with one `NA` (e.g. `ylim = c(0, NA)`) pins the non-`NA` + limit and lets the data determine the other. + - The string `"rev"` (or `"reverse"`) reverses the auto-computed axis range, + without needing to know the data extent in advance. - Type-specific updates: - `type_barplot()` gains an `offset` argument for shifting bar baselines away from zero. (#611, #615 @grantmcdermott @zeileis) diff --git a/R/facet.R b/R/facet.R index a9b73a37..ea1518bc 100644 --- a/R/facet.R +++ b/R/facet.R @@ -25,6 +25,7 @@ draw_facet_window = function( axes, flip, frame.plot, oxaxis, oyaxis, xlabs, xlim, null_xlim, xaxt, xaxs, xaxb, xaxl, ylabs, ylim, null_ylim, yaxt, yaxs, yaxb, yaxl, + rev_x = FALSE, rev_y = FALSE, asp, log, # other args (in approx. alphabetical + group ordering) dots, @@ -355,8 +356,23 @@ draw_facet_window = function( yfree = if (!is.null(facet)) split(c(y, ymin, ymax), facet)[[ii]] else c(y, ymin, ymax) if (null_xlim) xlim = range(xfree, na.rm = TRUE) if (null_ylim) ylim = range(yfree, na.rm = TRUE) - xext = extendrange(xlim, f = 0.04) - yext = extendrange(ylim, f = 0.04) + # An axis is reversed either via the `rev_x`/`rev_y` flag (e.g. the + # "reverse" keyword) or when the user supplies descending fixed limits + # (e.g. xlim = c(10, 0)). The latter must be detected before extendrange() + # below, which always returns an ascending pair and would otherwise drop + # 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) + # base axTicks() misbehaves on a reversed usr (it collapses to a single + # tick), so precompute ticks from the ascending extent and pass them as + # an explicit `at` below; placement against the reversed usr is fine. + xat = if (rev_xext) axisTicks(usr = xext, log = par("xlog")) else NULL + yat = if (rev_yext) axisTicks(usr = yext, log = par("ylog")) else NULL + if (rev_xext) xext = rev(xext) + if (rev_yext) yext = rev(yext) # We'll save this in a special .fusr env var (list) that we'll re-use # when it comes to plotting the actual elements later if (ii == 1) { @@ -371,12 +387,16 @@ draw_facet_window = function( # if plot frame is true then print axes per normal... if (type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(xlabs)) { tinyAxis(xfree, side = xside, at = xlabs, labels = names(xlabs), type = xaxt, labeller = xaxl) + } else if (!is.null(xat)) { + tinyAxis(xfree, side = xside, at = xat, type = xaxt, labeller = xaxl) } else { tinyAxis(xfree, side = xside, type = xaxt, labeller = xaxl) } if (.ymgp_shift > 0) par(mgp = par("mgp") - c(0, .ymgp_shift, 0)) if (isTRUE(flip) && type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(ylabs)) { tinyAxis(yfree, side = yside, at = ylabs, labels = names(ylabs), type = yaxt, labeller = yaxl) + } else if (!is.null(yat)) { + tinyAxis(yfree, side = yside, at = yat, type = yaxt, labeller = yaxl) } else { tinyAxis(yfree, side = yside, type = yaxt, labeller = yaxl) } diff --git a/R/flip.R b/R/flip.R index ff35a8dd..66980264 100644 --- a/R/flip.R +++ b/R/flip.R @@ -30,6 +30,7 @@ flip_datapoints = function(settings) { swap_elements(settings, "xlab", "ylab") swap_elements(settings, "xlabs", "ylabs") swap_elements(settings, "xlim", "ylim") + swap_elements(settings, "rev_x", "rev_y") swap_elements(settings, "xmax", "ymax") swap_elements(settings, "xmin", "ymin") diff --git a/R/lim.R b/R/lim.R index 7eb13aad..4fd09f83 100644 --- a/R/lim.R +++ b/R/lim.R @@ -5,8 +5,8 @@ lim_args = function(settings) { settings, environment(), c( - "xaxb", "xlabs", "xlim", "null_xlim", - "yaxb", "ylabs", "ylim", "null_ylim", + "xaxb", "xlabs", "xlim", "null_xlim", "rev_x", + "yaxb", "ylabs", "ylim", "null_ylim", "rev_y", "datapoints", "type" ) ) @@ -26,11 +26,21 @@ lim_args = function(settings) { xlim = range(as.numeric(c( datapoints[["x"]], datapoints[["xmin"]], datapoints[["xmax"]])), finite = TRUE) + } else if (length(xlim) != 2L || anyNA(xlim)) { + xdrng = range(as.numeric(c( + datapoints[["x"]], datapoints[["xmin"]], + datapoints[["xmax"]])), finite = TRUE) + xlim = resolve_lim(xlim, xdrng, "xlim") } if (is.null(ylim)) { ylim = range(as.numeric(c( datapoints[["y"]], datapoints[["ymin"]], datapoints[["ymax"]])), finite = TRUE) + } else if (length(ylim) != 2L || anyNA(ylim)) { + ydrng = range(as.numeric(c( + datapoints[["y"]], datapoints[["ymin"]], + datapoints[["ymax"]])), finite = TRUE) + ylim = resolve_lim(ylim, ydrng, "ylim") } if (identical(type, "boxplot")) { @@ -40,6 +50,10 @@ lim_args = function(settings) { if (null_xlim && !is.null(xaxb) && type != "spineplot") xlim = range(c(xlim, xaxb)) if (null_ylim && !is.null(yaxb) && type != "spineplot") ylim = range(c(ylim, yaxb)) + # reverse axis direction last, once the range is otherwise finalized + if (isTRUE(rev_x)) xlim = rev(xlim) + if (isTRUE(rev_y)) ylim = rev(ylim) + # update settings env2env( environment(), @@ -47,3 +61,53 @@ lim_args = function(settings) { c("xlim", "ylim", "xlabs", "ylabs", "xaxb", "yaxb") ) } + + +# +# x/ylim helpers ---- +# + +# 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) +# Returns a 2-element numeric vector (raw; window padding applied downstream). +resolve_lim = function(lim, drng, arg = "xlim") { + if (!is.numeric(lim)) { + stop(sprintf("`%s` must be numeric (a scalar or length-2 vector).", arg), call. = FALSE) + } + n = length(lim) + if (n == 1L) { + if (is.na(lim)) stop(sprintf("`%s` cannot be a single `NA`.", arg), call. = FALSE) + # scalar: ensure the value is covered alongside the data + return(range(c(drng, lim))) + } + if (n == 2L) { + nas = is.na(lim) + if (all(nas)) { + stop(sprintf("`%s` cannot be `c(NA, NA)`; supply at least one finite limit.", arg), call. = FALSE) + } + if (!any(nas)) return(lim) # full override: unchanged (reversed axis OK) + # exactly one NA: fill that side from the data range, pin the other verbatim + if (nas[1L]) lim[1L] = drng[1L] else lim[2L] = drng[2L] + return(lim) + } + stop(sprintf("`%s` must be length 1 or 2, not length %d.", arg, n), call. = FALSE) +} + +# Resolve an axis-reversal keyword passed to x/ylim, e.g. xlim = "reverse" (or +# the "rev" abbreviation). Returns a list with the (possibly NULL-ified) limit +# and a logical flag. When a keyword is detected we strip the limit back to NULL +# so that all the usual auto-range machinery (data range, breaks, free facets, +# type-specific limits) runs untouched; the flag is consumed later to rev() the +# finalized range. +sanitize_lim_rev = function(lim, arg = "xlim") { + if (is.character(lim)) { + rev_ok = length(lim) == 1L && !is.na(lim) && + nchar(lim) >= 3L && pmatch(tolower(lim), "reverse", nomatch = 0L) == 1L + if (!rev_ok) { + stop(sprintf('If `%s` is a character string it must be "reverse" (or "rev").', arg), call. = FALSE) + } + return(list(lim = NULL, rev = TRUE)) + } + list(lim = lim, rev = FALSE) +} diff --git a/R/tinyplot.R b/R/tinyplot.R index 3f940beb..a1d7fd9f 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -180,9 +180,19 @@ #' @param ann a logical value indicating whether the default annotation (title #' and x and y axis labels) should appear on the plot. #' @param xlim the x limits (x1, x2) of the plot. Note that x1 > x2 is allowed -#' and leads to a ‘reversed axis’. The default value, NULL, indicates that -#' the range of the `finite` values to be plotted should be used. -#' @param ylim the y limits of the plot. +#' and leads to a reversed axis (although see the `"rev(erse)"` keyword option +#' below). The default value, `NULL`, indicates that the range of the `finite` +#' values to be plotted should be used. Alongside the standard length-2 vector +#' (e.g., `xlim = c(0, 1)`), `tinyplot` supports three further convenience +#' forms: +#' - a single scalar (e.g. `xlim = 0`) ensures that the provided value is +#' covered by the axis range, irrespective of the data extent. +#' - a length-2 vector with one `NA` (e.g. `xlim = c(0, NA)`) pins the +#' non-`NA` limit and lets the data determine the other limit. +#' - the convenience string `"rev"` (or the longer `"reverse"`) reverses the +#' automatically-computed axis range. This is equivalent to passing a +#' descending vector, but without having to know the data extent in advance. +#' @param ylim the y limits of the plot. Accepts the same input forms as `xlim`. #' @param axes logical or character. Should axes be drawn (`TRUE` or `FALSE`)? #' Or alternatively what type of axes should be drawn: `"standard"` (with #' axis, ticks, and labels; equivalent to `TRUE`), `"none"` (no axes; @@ -788,6 +798,13 @@ tinyplot.default = function( dots = list(...) + # resolve any axis-reversal keyword (e.g. xlim = "reverse") into a flag, and + # reset the limit to NULL so the normal auto-range path runs (see lim.R) + .revx = sanitize_lim_rev(xlim, "xlim") + xlim = .revx[["lim"]]; rev_x = .revx[["rev"]] + .revy = sanitize_lim_rev(ylim, "ylim") + ylim = .revy[["lim"]]; rev_y = .revy[["rev"]] + settings_list = list( # save call to check user input later call = match.call(), @@ -849,6 +866,8 @@ tinyplot.default = function( frame.plot = frame.plot, xlim = xlim, ylim = ylim, + rev_x = rev_x, + rev_y = rev_y, # flags to check user input (useful later on) null_by = is.null(by), @@ -1357,6 +1376,7 @@ tinyplot.default = function( oxaxis = oxaxis, oyaxis = oyaxis, xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, + rev_x = rev_x, rev_y = rev_y, asp = asp, log = log, # other args (in approx. alphabetical + group ordering) dots = dots, @@ -1389,6 +1409,7 @@ tinyplot.default = function( oxaxis = oxaxis, oyaxis = oyaxis, xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, + rev_x = rev_x, rev_y = rev_y, asp = asp, log = log, dots = dots, draw = draw, diff --git a/R/zzz.R b/R/zzz.R index 6f15970f..6b893367 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -75,6 +75,8 @@ "oxaxis", "oyaxis", "pch", + "rev_x", + "rev_y", "ribbon.alpha", "split_data", "tpars", diff --git a/inst/tinytest/_tinysnapshot/lim_partial_na.svg b/inst/tinytest/_tinysnapshot/lim_partial_na.svg new file mode 100644 index 00000000..496ddf1f --- /dev/null +++ b/inst/tinytest/_tinysnapshot/lim_partial_na.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + +speed +dist + + + + + + +5 +10 +15 +20 +25 + + + + + + + + +0 +20 +40 +60 +80 +100 +120 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/lim_reverse.svg b/inst/tinytest/_tinysnapshot/lim_reverse.svg new file mode 100644 index 00000000..162d3ff4 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/lim_reverse.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + +speed +dist + + + + + + +25 +20 +15 +10 +5 + + + + + + + + +0 +20 +40 +60 +80 +100 +120 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/lim_reverse_free_facet.svg b/inst/tinytest/_tinysnapshot/lim_reverse_free_facet.svg new file mode 100644 index 00000000..1f44ae6f --- /dev/null +++ b/inst/tinytest/_tinysnapshot/lim_reverse_free_facet.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + +speed +dist + + + + + + + + + + + + + + + +24 +22 +20 +18 +16 + + + + + + +40 +60 +80 +100 +120 + +fast + + + + + + + + + + + + + + + + + +14 +12 +10 +8 +6 +4 + + + + + + +0 +20 +40 +60 +80 + +slow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/lim_scalar_zero.svg b/inst/tinytest/_tinysnapshot/lim_scalar_zero.svg new file mode 100644 index 00000000..c3416c18 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/lim_scalar_zero.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + +speed +dist + + + + + + + +0 +5 +10 +15 +20 +25 + + + + + + + + +0 +20 +40 +60 +80 +100 +120 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-lim.R b/inst/tinytest/test-lim.R new file mode 100644 index 00000000..52cc23a9 --- /dev/null +++ b/inst/tinytest/test-lim.R @@ -0,0 +1,26 @@ +source("helpers.R") +using("tinysnapshot") + +# Smart x/ylim overrides (#616): scalar coverage, partial-NA limits, and the +# axis-reversal keyword. + +# Partial NA: lower pinned at 0, upper driven by data. +fun = function() tinyplot(dist ~ speed, data = cars, ylim = c(0, NA)) +expect_snapshot_plot(fun, label = "lim_partial_na") + +# Scalar coverage: ensure value is shown alongside the data (in this case will +# be identical to previous plot since y value is not binding) +fun = function() tinyplot(dist ~ speed, data = cars, xlim = 0, ylim = 100) +expect_snapshot_plot(fun, label = "lim_scalar_zero") + +# Reversed x-axis via keyword. +fun = function() tinyplot(dist ~ speed, data = cars, xlim = "reverse") +expect_snapshot_plot(fun, label = "lim_reverse") + +# Reversed x-axis on free-scale facets (exercises the facet.R path). +fun = function() { + fast = ifelse(cars$speed > 15, "fast", "slow") + tinyplot(dist ~ speed, facet = fast, data = cars, + facet.args = list(free = TRUE, ncol = 1), xlim = "reverse") +} +expect_snapshot_plot(fun, label = "lim_reverse_free_facet") diff --git a/man/facet.Rd b/man/facet.Rd index 1be2b97f..406aa222 100644 --- a/man/facet.Rd +++ b/man/facet.Rd @@ -41,6 +41,8 @@ draw_facet_window( yaxs, yaxb, yaxl, + rev_x = FALSE, + rev_y = FALSE, asp, log, dots, diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 8925aa6a..d2e9ce3e 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -332,10 +332,22 @@ can be customized via \code{\link[tinyplot]{tpar}} parameters \code{adj.cap}, and x and y axis labels) should appear on the plot.} \item{xlim}{the x limits (x1, x2) of the plot. Note that x1 > x2 is allowed -and leads to a ‘reversed axis’. The default value, NULL, indicates that -the range of the \code{finite} values to be plotted should be used.} +and leads to a reversed axis (although see the \code{"rev(erse)"} keyword option +below). The default value, \code{NULL}, indicates that the range of the \code{finite} +values to be plotted should be used. Alongside the standard length-2 vector +(e.g., \code{xlim = c(0, 1)}), \code{tinyplot} supports three further convenience +forms: +\itemize{ +\item a single scalar (e.g. \code{xlim = 0}) ensures that the provided value is +covered by the axis range, irrespective of the data extent. +\item a length-2 vector with one \code{NA} (e.g. \code{xlim = c(0, NA)}) pins the +non-\code{NA} limit and lets the data determine the other limit. +\item the convenience string \code{"rev"} (or the longer \code{"reverse"}) reverses the +automatically-computed axis range. This is equivalent to passing a +descending vector, but without having to know the data extent in advance. +}} -\item{ylim}{the y limits of the plot.} +\item{ylim}{the y limits of the plot. Accepts the same input forms as \code{xlim}.} \item{axes}{logical or character. Should axes be drawn (\code{TRUE} or \code{FALSE})? Or alternatively what type of axes should be drawn: \code{"standard"} (with