diff --git a/NEWS.md b/NEWS.md index 3abb02c650..63afe2fe18 100644 --- a/NEWS.md +++ b/NEWS.md @@ -47,6 +47,11 @@ core developer team. * `x0` and `y0` are now recognized positional aesthetics so they will get scaled if used in extension geoms and stats (@thomasp85, #3168) + +* Continuous scale limits now accept functions which accept the default + limits and return adjusted limits. This makes it possible to write + a function that e.g. ensures the limits are always a multiple of 100, + regardless of the data (@econandrew, #2307). ## Minor improvements and bug fixes diff --git a/R/scale-.r b/R/scale-.r index 00ff579618..40c1ef33a5 100644 --- a/R/scale-.r +++ b/R/scale-.r @@ -14,7 +14,6 @@ Scale <- ggproto("Scale", NULL, call = NULL, - aesthetics = aes(), scale_name = NULL, palette = function() { @@ -107,16 +106,20 @@ Scale <- ggproto("Scale", NULL, stop("Not implemented", call. = FALSE) }, - # if scale contains a NULL, use the default scale range + # if scale is a function, apply it to the default (inverted) scale range + # if scale is NULL, use the default scale range # if scale contains a NA, use the default range for that axis, otherwise # use the user defined limit for that axis get_limits = function(self) { if (self$is_empty()) return(c(0, 1)) - if (!is.null(self$limits)) { - ifelse(!is.na(self$limits), self$limits, self$range$range) - } else { + if (is.null(self$limits)) { self$range$range + } else if (is.function(self$limits)) { + # if limits is a function, it expects to work in data space + self$trans$transform(self$limits(self$trans$inverse(self$range$range))) + } else { + ifelse(is.na(self$limits), self$range$range, self$limits) } }, @@ -526,8 +529,12 @@ ScaleDiscrete <- ggproto("ScaleDiscrete", Scale, #' - A character vector giving labels (must be same length as `breaks`) #' - A function that takes the breaks as input and returns labels #' as output -#' @param limits A numeric vector of length two providing limits of the scale. -#' Use `NA` to refer to the existing minimum or maximum. +#' @param limits One of: +#' - `NULL` to use the default scale range +#' - A numeric vector of length two providing limits of the scale. +#' Use `NA` to refer to the existing minimum or maximum +#' - A function that accepts the existing (automatic) limits and returns +#' new limits #' @param rescaler Used by diverging and n colour gradients #' (i.e. [scale_colour_gradient2()], [scale_colour_gradientn()]). #' A function used to scale the input values to the range \[0, 1]. @@ -538,7 +545,7 @@ ScaleDiscrete <- ggproto("ScaleDiscrete", Scale, #' @param trans Either the name of a transformation object, or the #' object itself. Built-in transformations include "asn", "atanh", #' "boxcox", "date", "exp", "hms", "identity", "log", "log10", "log1p", "log2", -#' "logit", "modulus", "probability", "probit", "pseudo_log", "reciprocal", +#' "logit", "modulus", "probability", "probit", "pseudo_log", "reciprocal", #' "reverse", "sqrt" and "time". #' #' A transformation object bundles together a transform, its inverse, @@ -569,7 +576,7 @@ continuous_scale <- function(aesthetics, scale_name, palette, name = waiver(), } trans <- as.trans(trans) - if (!is.null(limits)) { + if (!is.null(limits) && !is.function(limits)) { limits <- trans$transform(limits) } diff --git a/man/continuous_scale.Rd b/man/continuous_scale.Rd index bf5001474e..36d46fb5af 100644 --- a/man/continuous_scale.Rd +++ b/man/continuous_scale.Rd @@ -53,8 +53,14 @@ transformation object as output }} -\item{limits}{A numeric vector of length two providing limits of the scale. -Use \code{NA} to refer to the existing minimum or maximum.} +\item{limits}{One of: +\itemize{ +\item \code{NULL} to use the default scale range +\item A numeric vector of length two providing limits of the scale. +Use \code{NA} to refer to the existing minimum or maximum +\item A function that accepts the existing (automatic) limits and returns +new limits +}} \item{rescaler}{Used by diverging and n colour gradients (i.e. \code{\link[=scale_colour_gradient2]{scale_colour_gradient2()}}, \code{\link[=scale_colour_gradientn]{scale_colour_gradientn()}}). diff --git a/man/scale_continuous.Rd b/man/scale_continuous.Rd index 1476f6943f..1806550a1f 100644 --- a/man/scale_continuous.Rd +++ b/man/scale_continuous.Rd @@ -68,8 +68,14 @@ transformation object as output }} -\item{limits}{A numeric vector of length two providing limits of the scale. -Use \code{NA} to refer to the existing minimum or maximum.} +\item{limits}{One of: +\itemize{ +\item \code{NULL} to use the default scale range +\item A numeric vector of length two providing limits of the scale. +Use \code{NA} to refer to the existing minimum or maximum +\item A function that accepts the existing (automatic) limits and returns +new limits +}} \item{expand}{Vector of range expansion constants used to add some padding around the data, to ensure that they are placed some distance diff --git a/man/scale_date.Rd b/man/scale_date.Rd index aa1c57d0c4..f09a0f339c 100644 --- a/man/scale_date.Rd +++ b/man/scale_date.Rd @@ -88,8 +88,14 @@ output like "2 weeks", or "10 years". If both \code{minor_breaks} and \code{date_minor_breaks} are specified, \code{date_minor_breaks} wins.} -\item{limits}{A numeric vector of length two providing limits of the scale. -Use \code{NA} to refer to the existing minimum or maximum.} +\item{limits}{One of: +\itemize{ +\item \code{NULL} to use the default scale range +\item A numeric vector of length two providing limits of the scale. +Use \code{NA} to refer to the existing minimum or maximum +\item A function that accepts the existing (automatic) limits and returns +new limits +}} \item{expand}{Vector of range expansion constants used to add some padding around the data, to ensure that they are placed some distance diff --git a/man/scale_gradient.Rd b/man/scale_gradient.Rd index 26ed332d1e..44237ace91 100644 --- a/man/scale_gradient.Rd +++ b/man/scale_gradient.Rd @@ -78,8 +78,14 @@ transformation object \item A function that takes the breaks as input and returns labels as output }} - \item{limits}{A numeric vector of length two providing limits of the scale. -Use \code{NA} to refer to the existing minimum or maximum.} + \item{limits}{One of: +\itemize{ +\item \code{NULL} to use the default scale range +\item A numeric vector of length two providing limits of the scale. +Use \code{NA} to refer to the existing minimum or maximum +\item A function that accepts the existing (automatic) limits and returns +new limits +}} \item{rescaler}{Used by diverging and n colour gradients (i.e. \code{\link[=scale_colour_gradient2]{scale_colour_gradient2()}}, \code{\link[=scale_colour_gradientn]{scale_colour_gradientn()}}). A function used to scale the input values to the range [0, 1].} diff --git a/man/scale_size.Rd b/man/scale_size.Rd index 6f09f75c03..75820050ce 100644 --- a/man/scale_size.Rd +++ b/man/scale_size.Rd @@ -47,8 +47,14 @@ transformation object as output }} -\item{limits}{A numeric vector of length two providing limits of the scale. -Use \code{NA} to refer to the existing minimum or maximum.} +\item{limits}{One of: +\itemize{ +\item \code{NULL} to use the default scale range +\item A numeric vector of length two providing limits of the scale. +Use \code{NA} to refer to the existing minimum or maximum +\item A function that accepts the existing (automatic) limits and returns +new limits +}} \item{range}{a numeric vector of length 2 that specifies the minimum and maximum size of the plotting symbol after transformation.} @@ -100,8 +106,14 @@ transformation object \item A function that takes the breaks as input and returns labels as output }} - \item{limits}{A numeric vector of length two providing limits of the scale. -Use \code{NA} to refer to the existing minimum or maximum.} + \item{limits}{One of: +\itemize{ +\item \code{NULL} to use the default scale range +\item A numeric vector of length two providing limits of the scale. +Use \code{NA} to refer to the existing minimum or maximum +\item A function that accepts the existing (automatic) limits and returns +new limits +}} \item{oob}{Function that handles limits outside of the scale limits (out of bounds). The default replaces out of bounds values with \code{NA}.} \item{na.value}{Missing values will be replaced with this value.} diff --git a/tests/figs/scales-breaks-and-labels/functional-limits.svg b/tests/figs/scales-breaks-and-labels/functional-limits.svg new file mode 100644 index 0000000000..961969a880 --- /dev/null +++ b/tests/figs/scales-breaks-and-labels/functional-limits.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +25 +50 +75 +100 + + + + + + + + + + + + +2seater +compact +midsize +minivan +pickup +subcompact +suv +class +count + +drv + + + + + + +4 +f +r +functional limits + diff --git a/tests/testthat/test-scales-breaks-labels.r b/tests/testthat/test-scales-breaks-labels.r index 7f7e42f253..12bd968e7e 100644 --- a/tests/testthat/test-scales-breaks-labels.r +++ b/tests/testthat/test-scales-breaks-labels.r @@ -202,7 +202,6 @@ test_that("scale_breaks with explicit NA options (deprecated)", { expect_error(scc$get_breaks()) }) - test_that("breaks can be specified by names of labels", { labels <- setNames(LETTERS[1:4], letters[1:4]) @@ -241,6 +240,12 @@ test_that("minor breaks are transformed by scales", { expect_equal(sc$get_breaks_minor(), c(0, 1, 2)) }) +test_that("continuous limits accepts functions", { + p <- ggplot(mpg, aes(class, hwy)) + + scale_y_continuous(limits = function(lims) (c(lims[1] - 10, lims[2] + 100))) + + expect_equal(layer_scales(p)$y$get_limits(), c(range(mpg$hwy)[1] - 10, range(mpg$hwy)[2] + 100)) +}) # Visual tests ------------------------------------------------------------ @@ -324,3 +329,18 @@ test_that("scale breaks can be removed", { ggplot(dat, aes(x = 1, y = y, colour = x)) + geom_point() + scale_colour_continuous(breaks = NULL) ) }) + +test_that("functional limits work for continuous scales", { + limiter <- function(by) { + function(limits) { + low <- floor(limits[1] / by) * by + high <- ceiling(limits[2] / by) * by + c(low, high) + } + } + + expect_doppelganger( + "functional limits", + ggplot(mpg, aes(class)) + geom_bar(aes(fill = drv)) + scale_y_continuous(limits = limiter(50)) + ) +})