From 3d2e6f7390daecaef31e415dfb3c07c71b6b0987 Mon Sep 17 00:00:00 2001 From: Andrew Whitby Date: Fri, 10 Nov 2017 22:07:58 -0500 Subject: [PATCH 1/4] Allow functional limits in continous scales --- NEWS.md | 6 ++++++ R/scale-.r | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index dae20bcde8..1a04ab6826 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,12 @@ ## New features +* Continuous scale limits now accept a function, which must accept the default + / automatic 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. It somewhat parallels the behaviour of breaks and + labels in accepting functions (@econandrew, #2307). + * ggplot2 now works on R 3.1 onwards, and uses the [vdiffr](https://github.com/lionel-/vdiffr) package for visual testing. diff --git a/R/scale-.r b/R/scale-.r index 67ca8e4b4a..4235597425 100644 --- a/R/scale-.r +++ b/R/scale-.r @@ -107,13 +107,17 @@ Scale <- ggproto("Scale", NULL, stop("Not implemented", call. = FALSE) }, + # if scale contains a function, apply it to the default (inverted) scale range # if scale contains a 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)) { + 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 if (!is.null(self$limits)) { ifelse(!is.na(self$limits), self$limits, self$range$range) } else { self$range$range @@ -526,8 +530,11 @@ 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: +#' - 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 \eqn{[0, 1]}. @@ -565,7 +572,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) } @@ -598,7 +605,7 @@ continuous_scale <- function(aesthetics, scale_name, palette, name = waiver(), #' #' @export #' @inheritParams continuous_scale -#' @param breaks One of: +#' @param breaks One of: #' - `NULL` for no breaks #' - `waiver()` for the default breaks computed by the #' transformation object From c86d643a91d94c11c228bea79e1d28b56ee86786 Mon Sep 17 00:00:00 2001 From: Dana Seidel Date: Sat, 13 Apr 2019 14:15:46 -0700 Subject: [PATCH 2/4] clarify get_limits conditionals, add tests. --- R/scale-.r | 46 ++++++----- man/continuous_scale.Rd | 10 ++- man/scale_continuous.Rd | 10 ++- man/scale_date.Rd | 10 ++- man/scale_gradient.Rd | 10 ++- man/scale_size.Rd | 20 ++++- .../functional-limits.svg | 78 +++++++++++++++++++ tests/testthat/test-scales-breaks-labels.r | 22 +++++- 8 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 tests/figs/scales-breaks-and-labels/functional-limits.svg diff --git a/R/scale-.r b/R/scale-.r index 4235597425..6639043003 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,20 +106,20 @@ Scale <- ggproto("Scale", NULL, stop("Not implemented", call. = FALSE) }, - # if scale contains a function, apply it to the default (inverted) scale range + # if scale is a function, apply it to the default (inverted) scale range # if scale contains a 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.function(self$limits)) { + 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 if (!is.null(self$limits)) { - ifelse(!is.na(self$limits), self$limits, self$range$range) } else { - self$range$range + ifelse(is.na(self$limits), self$range$range, self$limits) } }, @@ -219,7 +218,7 @@ ScaleContinuous <- ggproto("ScaleContinuous", Scale, }, map = function(self, x, limits = self$get_limits()) { - x <- self$oob(self$rescaler(x, from = limits)) + x <- self$rescaler(self$oob(x, range = limits), from = limits) uniq <- unique(x) pal <- self$palette(uniq) @@ -503,10 +502,10 @@ ScaleDiscrete <- ggproto("ScaleDiscrete", Scale, #' @export #' @param aesthetics The names of the aesthetics that this scale works with #' @param scale_name The name of the scale -#' @param palette A palette function that when called with a single integer -#' argument (the number of levels in the scale) returns the values that -#' they should take -#' @param name The name of the scale. Used as axis or legend title. If +#' @param palette A palette function that when called with a numeric vector with +#' values between 0 and 1 returns the corresponding values in the range the +#' scale maps to. +#' @param name The name of the scale. Used as the axis or legend title. If #' `waiver()`, the default, the name of the scale is taken from the first #' mapping used for that aesthetic. If `NULL`, the legend title will be #' omitted. @@ -531,23 +530,25 @@ ScaleDiscrete <- ggproto("ScaleDiscrete", Scale, #' - A function that takes the breaks as input and returns labels #' as output #' @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 \eqn{[0, 1]}. +#' A function used to scale the input values to the range \[0, 1]. #' @param oob Function that handles limits outside of the scale limits -#' (out of bounds). The default replaces out of bounds values with NA. +#' (out of bounds). The default replaces out of bounds values with `NA`. #' @inheritParams scale_x_discrete #' @param na.value Missing values will be replaced with this value. #' @param trans Either the name of a transformation object, or the #' object itself. Built-in transformations include "asn", "atanh", -#' "boxcox", "exp", "identity", "log", "log10", "log1p", "log2", -#' "logit", "probability", "probit", "reciprocal", "reverse" and "sqrt". +#' "boxcox", "date", "exp", "hms", "identity", "log", "log10", "log1p", "log2", +#' "logit", "modulus", "probability", "probit", "pseudo_log", "reciprocal", +#' "reverse", "sqrt" and "time". #' -#' A transformation object bundles together a transform, it's inverse, +#' A transformation object bundles together a transform, its inverse, #' and methods for generating breaks and labels. Transformation objects #' are defined in the scales package, and are called `name_trans`, e.g. #' [scales::boxcox_trans()]. You can create your own @@ -563,11 +564,14 @@ continuous_scale <- function(aesthetics, scale_name, palette, name = waiver(), rescaler = rescale, oob = censor, expand = waiver(), na.value = NA_real_, trans = "identity", guide = "legend", position = "left", super = ScaleContinuous) { + aesthetics <- standardise_aes_names(aesthetics) + check_breaks_labels(breaks, labels) position <- match.arg(position, c("left", "right", "top", "bottom")) - if (is.null(breaks) && !is_position_aes(aesthetics) && guide != "none") { + # If the scale is non-positional, break = NULL means removing the guide + if (is.null(breaks) && all(!is_position_aes(aesthetics))) { guide <- "none" } @@ -605,6 +609,9 @@ continuous_scale <- function(aesthetics, scale_name, palette, name = waiver(), #' #' @export #' @inheritParams continuous_scale +#' @param palette A palette function that when called with a single integer +#' argument (the number of levels in the scale) returns the values that +#' they should take. #' @param breaks One of: #' - `NULL` for no breaks #' - `waiver()` for the default breaks computed by the @@ -629,11 +636,14 @@ discrete_scale <- function(aesthetics, scale_name, palette, name = waiver(), na.translate = TRUE, na.value = NA, drop = TRUE, guide = "legend", position = "left", super = ScaleDiscrete) { + aesthetics <- standardise_aes_names(aesthetics) + check_breaks_labels(breaks, labels) position <- match.arg(position, c("left", "right", "top", "bottom")) - if (is.null(breaks) && !is_position_aes(aesthetics) && guide != "none") { + # If the scale is non-positional, break = NULL means removing the guide + if (is.null(breaks) && all(!is_position_aes(aesthetics))) { guide <- "none" } 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)) + ) +}) From 4cd6c4fcb5385357d2ecbee91c6e7523d82a42a5 Mon Sep 17 00:00:00 2001 From: Dana Seidel Date: Sat, 13 Apr 2019 14:26:10 -0700 Subject: [PATCH 3/4] minor comment change --- R/scale-.r | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/scale-.r b/R/scale-.r index 6639043003..40c1ef33a5 100644 --- a/R/scale-.r +++ b/R/scale-.r @@ -107,7 +107,7 @@ Scale <- ggproto("Scale", NULL, }, # if scale is a function, apply it to the default (inverted) scale range - # if scale contains a NULL, use the default 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) { From 184bd2cb9ffddea8c00e68b0e2a8087efe635c81 Mon Sep 17 00:00:00 2001 From: Dana Seidel Date: Mon, 15 Apr 2019 09:21:49 -0700 Subject: [PATCH 4/4] revert NEWS nits. --- NEWS.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index f0d4952de5..63afe2fe18 100644 --- a/NEWS.md +++ b/NEWS.md @@ -24,7 +24,7 @@ core developer team. equivalents. * `geom_polygon()` can now draw polygons with holes using the new `subgroup` - aesthetic. This functionality requires R 3.6.0 (@thomasp85, #3128). + aesthetic. This functionality requires R 3.6.0 (@thomasp85, #3128) * Aesthetic mappings now accept functions that return `NULL` (@yutannihilation, #2997). @@ -46,7 +46,7 @@ core developer team. interest to extension developers (@clauswilke, #2872). * `x0` and `y0` are now recognized positional aesthetics so they will get scaled - if used in extension geoms and stats (@thomasp85, #3168). + 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 @@ -56,7 +56,7 @@ core developer team. ## Minor improvements and bug fixes * `cut_width()` now accepts `...` to pass further arguments to `base::cut.default()` - like `cut_number()` and `cut_interval()` already did (@cderv, #3055). + like `cut_number()` and `cut_interval()` already did (@cderv, #3055) * `coord_map()` now can have axes on the top and right (@karawoo, #3042). @@ -88,7 +88,7 @@ core developer team. * `geom_area()` and `geom_ribbon()` now sort the data along the x-axis in the `setup_data()` method rather than as part of `draw_group()` (@thomasp85, - #3023). + #3023) * `geom_hline()`, `geom_vline()`, and `geom_abline()` now throw a warning if the user supplies both an `xintercept`, `yintercept`, or `slope` value and a @@ -111,7 +111,7 @@ core developer team. #3079). * `scale_shape_identity()` now works correctly with `guide = "legend"` - (@malcolmbarrett, #3029). + (@malcolmbarrett, #3029) * `stat_bin()` will now error when the number of bins exceeds 1e6 to avoid accidentally freezing the user session (@thomasp85). @@ -121,7 +121,8 @@ core developer team. * `facet_wrap()` and `facet_grid()` now automatically remove NULL from facet specs, and accept empty specs (@yutannihilation, #3070, #2986). -* `stat_bin()` now handles data with only one unique value (@yutannihilation, #3047). +* `stat_bin()` now handles data with only one unique value (@yutannihilation + #3047). * `sec_axis()` now accepts functions as well as formulas (@yutannihilation, #3031).