From d6b2f1fdf95f6ea702f5ae817fb2c2a51d108087 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 23 Jun 2026 15:22:06 -0700 Subject: [PATCH 1/8] issue 616 --- R/lim.R | 37 +++++++++++++++++++++++++++++++++++++ R/tinyplot.R | 10 ++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/R/lim.R b/R/lim.R index 7eb13aad0..880d0c96e 100644 --- a/R/lim.R +++ b/R/lim.R @@ -1,5 +1,32 @@ # calculate limits of each plot +# 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) +} + lim_args = function(settings) { env2env( settings, @@ -26,11 +53,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")) { diff --git a/R/tinyplot.R b/R/tinyplot.R index 3f940bebd..bd479205a 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -181,8 +181,14 @@ #' 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. +#' 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 +#' two further convenience forms. First, passing a single scalar (e.g. +#' `xlim = 0`) ensures that the provided value is covered by the axis range, +#' irespective of the data extent. Second, 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. +#' @param ylim the y limits of the plot. Accepts 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; From d54473200f5205c07a5f0ce9d0d8217d6c62a782 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 23 Jun 2026 16:45:58 -0700 Subject: [PATCH 2/8] support `x/ylim = "rev(erse)"` --- R/facet.R | 4 +++ R/flip.R | 1 + R/lim.R | 85 ++++++++++++++++++++++++++++++++----------------- R/tinyplot.R | 33 +++++++++++++------ R/zzz.R | 2 ++ man/facet.Rd | 2 ++ man/tinyplot.Rd | 18 +++++++++-- 7 files changed, 104 insertions(+), 41 deletions(-) diff --git a/R/facet.R b/R/facet.R index a9b73a373..43a0c7174 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,11 @@ 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) + # extendrange() returns an ascending pair, so reverse afterwards xext = extendrange(xlim, f = 0.04) yext = extendrange(ylim, f = 0.04) + if (isTRUE(rev_x)) xext = rev(xext) + if (isTRUE(rev_y)) 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) { diff --git a/R/flip.R b/R/flip.R index ff35a8dd6..669802642 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 880d0c96e..4fd09f83b 100644 --- a/R/lim.R +++ b/R/lim.R @@ -1,39 +1,12 @@ # calculate limits of each plot -# 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) -} - lim_args = function(settings) { env2env( 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" ) ) @@ -77,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(), @@ -84,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 bd479205a..a1d7fd9f4 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -180,15 +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. Alongside -#' the standard length-2 vector (e.g., `xlim = c(0, 1)`, `tinyplot` supports -#' two further convenience forms. First, passing a single scalar (e.g. -#' `xlim = 0`) ensures that the provided value is covered by the axis range, -#' irespective of the data extent. Second, 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. -#' @param ylim the y limits of the plot. Accepts input forms as `xlim`. +#' 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; @@ -794,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(), @@ -855,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), @@ -1363,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, @@ -1395,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 6f15970f0..6b8933672 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/man/facet.Rd b/man/facet.Rd index 1be2b97ff..406aa222f 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 8925aa6a6..d2e9ce3e0 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 From 2ea00229c91cf75bbfc9a1c81e9bd4c7869e9775 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 23 Jun 2026 17:05:14 -0700 Subject: [PATCH 3/8] free facet axTicks gotcha --- R/facet.R | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/R/facet.R b/R/facet.R index 43a0c7174..504d22665 100644 --- a/R/facet.R +++ b/R/facet.R @@ -359,6 +359,11 @@ draw_facet_window = function( # extendrange() returns an ascending pair, so reverse afterwards xext = extendrange(xlim, f = 0.04) yext = extendrange(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 (isTRUE(rev_x)) axisTicks(usr = xext, log = par("xlog")) else NULL + yat = if (isTRUE(rev_y)) axisTicks(usr = yext, log = par("ylog")) else NULL if (isTRUE(rev_x)) xext = rev(xext) if (isTRUE(rev_y)) yext = rev(yext) # We'll save this in a special .fusr env var (list) that we'll re-use @@ -375,12 +380,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) } From 961f7b9967b4922163c3c459084629d28d5de38e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 23 Jun 2026 17:13:25 -0700 Subject: [PATCH 4/8] tests --- .../tinytest/_tinysnapshot/lim_partial_na.svg | 117 +++++++++++++ inst/tinytest/_tinysnapshot/lim_reverse.svg | 117 +++++++++++++ .../_tinysnapshot/lim_reverse_free_facet.svg | 164 ++++++++++++++++++ .../_tinysnapshot/lim_scalar_zero.svg | 119 +++++++++++++ inst/tinytest/test-lim.R | 26 +++ 5 files changed, 543 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/lim_partial_na.svg create mode 100644 inst/tinytest/_tinysnapshot/lim_reverse.svg create mode 100644 inst/tinytest/_tinysnapshot/lim_reverse_free_facet.svg create mode 100644 inst/tinytest/_tinysnapshot/lim_scalar_zero.svg create mode 100644 inst/tinytest/test-lim.R diff --git a/inst/tinytest/_tinysnapshot/lim_partial_na.svg b/inst/tinytest/_tinysnapshot/lim_partial_na.svg new file mode 100644 index 000000000..496ddf1f6 --- /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 000000000..162d3ff4c --- /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 000000000..1f44ae6fb --- /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 000000000..c3416c184 --- /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 000000000..52cc23a9d --- /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") From 3ac01a185dec5ebd125d4c7769dc2d3cd697afe5 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 23 Jun 2026 17:16:12 -0700 Subject: [PATCH 5/8] news --- NEWS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NEWS.md b/NEWS.md index 0bfdec0ee..c26c8d008 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. (#616 @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) From 24b1c04f4a2956c5491a337dbc6e19de3fb28b39 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 23 Jun 2026 17:28:34 -0700 Subject: [PATCH 6/8] correct pr link --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index c26c8d008..c26dbd6da 100644 --- a/NEWS.md +++ b/NEWS.md @@ -236,7 +236,7 @@ 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. (#616 @grantmcdermott) + - `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` From ff8901087571600050103052d1d7db9c8fc86a67 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 28 Jun 2026 20:27:22 -0700 Subject: [PATCH 7/8] typo --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index c26dbd6da..0a2322a7a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -241,7 +241,7 @@ Theme fixes: 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, + - 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 c86cbf5daa36574da28b71a0e26ae23c2217a264 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 28 Jun 2026 20:30:33 -0700 Subject: [PATCH 8/8] manual reverse axis gotcha --- R/facet.R | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/R/facet.R b/R/facet.R index 504d22665..ea1518bcf 100644 --- a/R/facet.R +++ b/R/facet.R @@ -356,16 +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) + # 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(xlim, f = 0.04) - yext = extendrange(ylim, f = 0.04) + 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 (isTRUE(rev_x)) axisTicks(usr = xext, log = par("xlog")) else NULL - yat = if (isTRUE(rev_y)) axisTicks(usr = yext, log = par("ylog")) else NULL - if (isTRUE(rev_x)) xext = rev(xext) - if (isTRUE(rev_y)) yext = rev(yext) + 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) {