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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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