diff --git a/NAMESPACE b/NAMESPACE index bf4d62e93a..7cc1cb0896 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -627,6 +627,7 @@ export(scale_linewidth_discrete) export(scale_linewidth_identity) export(scale_linewidth_manual) export(scale_linewidth_ordinal) +export(scale_params) export(scale_radius) export(scale_shape) export(scale_shape_binned) diff --git a/R/all-classes.R b/R/all-classes.R index 42409e655a..4298ab0017 100644 --- a/R/all-classes.R +++ b/R/all-classes.R @@ -413,6 +413,39 @@ class_ggplot_built <- S7::new_class( } ) +## Scale params ----------------------------------------------------------- + +#' Setting scale parameters +#' +#' @param aesthetics The name of the aesthetics for which to update the scale. +#' @param ... Named arguments to one of the scale constructors, +#' [`continuous_scale()`], [`discrete_scale()`] or [`binned_scale()`]. +#' +#' @return A `ggplot2::scale_params` object that can be added to a plot. +#' @export +#' +#' @examples +#' ggplot(mpg, aes(displ, hwy)) + +#' geom_point() + +#' scale_params("x", limits = c(0, 10)) + +#' scale_params("y", transform = "sqrt") +scale_params <- S7::new_class( + "scale_params", parent = class_gg, + properties = list( + aesthetics = S7::class_character, + params = S7::class_list + ), + constructor = function(aesthetics, ...) { + params <- list2(...) + # TODO: use name check if #6766 is merged + S7::new_object( + S7::S7_object(), + aesthetics = standardise_aes_names(aesthetics), + params = params + ) + } +) + # Methods ----------------------------------------------------------------- #' @importFrom S7 convert diff --git a/R/plot-construction.R b/R/plot-construction.R index 1a8607488f..70065d9156 100644 --- a/R/plot-construction.R +++ b/R/plot-construction.R @@ -237,6 +237,12 @@ S7::method(update_ggplot, list(S7::new_S3_class("by"), class_ggplot)) <- ggplot_add(unclass(object), plot, object_name) } +S7::method(update_ggplot, list(scale_params, class_ggplot)) <- + function(object, plot, ...) { + plot$scales$add_params(object@aesthetics, object@params) + plot + } + # TODO: the S3 generic should be phased out once S7 is adopted more widely # For backward compatibility, ggplot_add still exists but by default it wraps # `update_ggplot()` diff --git a/R/scale-.R b/R/scale-.R index 47b63961ea..2df9e001ac 100644 --- a/R/scale-.R +++ b/R/scale-.R @@ -112,7 +112,6 @@ continuous_scale <- function(aesthetics, scale_name = deprecated(), palette, nam fallback.palette = NULL, call = caller_call(), super = ScaleContinuous) { - call <- call %||% current_call() if (lifecycle::is_present(scale_name)) { deprecate("3.5.0", "continuous_scale(scale_name)") } @@ -120,64 +119,8 @@ continuous_scale <- function(aesthetics, scale_name = deprecated(), palette, nam deprecate("3.5.0", "continuous_scale(trans)", "continuous_scale(transform)") transform <- trans } - - aesthetics <- standardise_aes_names(aesthetics) - - check_breaks_labels(breaks, labels, call = call) - fallback.palette <- validate_fallback_palette( - palette, fallback.palette, aesthetics, discrete = FALSE, - call = call - ) - - position <- arg_match0(position, c("left", "right", "top", "bottom")) - - # If the scale is non-positional, break = NULL means removing the guide - if (is.null(breaks) && !any(is_position_aes(aesthetics))) { - guide <- "none" - } - - transform <- as.transform(transform) - - # Convert formula to function if appropriate - limits <- allow_lambda(limits) - breaks <- allow_lambda(breaks) - labels <- allow_lambda(labels) - rescaler <- allow_lambda(rescaler) - oob <- allow_lambda(oob) - minor_breaks <- allow_lambda(minor_breaks) - - if (!is.null(limits) && !is.function(limits)) { - limits <- transform$transform(limits) - if (!anyNA(limits)) { - limits <- sort(limits) - } - } - check_continuous_limits(limits, call = call) - - ggproto(NULL, super, - call = call, - - aesthetics = aesthetics, - palette = palette, - fallback_palette = fallback.palette, - - range = ContinuousRange$new(), - limits = limits, - trans = transform, - na.value = na.value, - expand = expand, - rescaler = rescaler, - oob = oob, - - name = name, - breaks = breaks, - minor_breaks = minor_breaks, - n.breaks = n.breaks, - - labels = labels, - guide = guide, - position = position - ) + args <- find_args(call = NULL, scale_name = NULL, trans = NULL) + inject(super$new(!!!args, call = call %||% current_call())) } #' Discrete scale constructor @@ -222,64 +165,11 @@ discrete_scale <- function(aesthetics, scale_name = deprecated(), palette, name fallback.palette = NULL, call = caller_call(), super = ScaleDiscrete) { - call <- call %||% current_call() if (lifecycle::is_present(scale_name)) { deprecate("3.5.0", "discrete_scale(scale_name)") } - - aesthetics <- standardise_aes_names(aesthetics) - - check_breaks_labels(breaks, labels, call = call) - fallback.palette <- validate_fallback_palette( - palette, fallback.palette, aesthetics, discrete = TRUE, - call = call - ) - - # Convert formula input to function if appropriate - limits <- allow_lambda(limits) - breaks <- allow_lambda(breaks) - labels <- allow_lambda(labels) - minor_breaks <- allow_lambda(minor_breaks) - - if (!is.function(limits) && (length(limits) > 0) && !is_discrete(limits)) { - cli::cli_warn(c( - "Continuous limits supplied to discrete scale.", - "i" = "Did you mean {.code limits = factor(...)} or {.fn scale_*_continuous}?" - ), call = call) - } - - position <- arg_match0(position, c("left", "right", "top", "bottom")) - - # If the scale is non-positional, break = NULL means removing the guide - is_position <- any(is_position_aes(aesthetics)) - if (is.null(breaks) && !is_position) { - guide <- "none" - } - if (is_position && identical(palette, identity)) { - palette <- seq_len - } - - ggproto(NULL, super, - call = call, - - aesthetics = aesthetics, - palette = palette, - fallback_palette = fallback.palette, - - range = DiscreteRange$new(), - limits = limits, - na.value = na.value, - na.translate = na.translate, - expand = expand, - - name = name, - breaks = breaks, - minor_breaks = minor_breaks, - labels = labels, - drop = drop, - guide = guide, - position = position - ) + args <- find_args(call = NULL, scale_name = NULL) + inject(super$new(!!!args, call = call %||% current_call())) } #' Binning scale constructor @@ -327,65 +217,8 @@ binned_scale <- function(aesthetics, scale_name = deprecated(), palette, name = deprecate("3.5.0", "binned_scale(trans)", "binned_scale(transform)") transform <- trans } - - call <- call %||% current_call() - - aesthetics <- standardise_aes_names(aesthetics) - - check_breaks_labels(breaks, labels, call = call) - fallback.palette <- validate_fallback_palette( - palette, fallback.palette, aesthetics, discrete = FALSE, - call = call - ) - - position <- arg_match0(position, c("left", "right", "top", "bottom")) - - if (is.null(breaks) && !is_position_aes(aesthetics) && guide != "none") { - guide <- "none" - } - - transform <- as.transform(transform) - - # Convert formula input to function if appropriate - limits <- allow_lambda(limits) - breaks <- allow_lambda(breaks) - labels <- allow_lambda(labels) - rescaler <- allow_lambda(rescaler) - oob <- allow_lambda(oob) - - if (!is.null(limits) && !is.function(limits)) { - limits <- transform$transform(limits) - if (!anyNA(limits)) { - limits <- sort(limits) - } - } - - ggproto(NULL, super, - call = call, - - aesthetics = aesthetics, - palette = palette, - fallback_palette = fallback.palette, - - range = ContinuousRange$new(), - limits = limits, - trans = transform, - na.value = na.value, - expand = expand, - rescaler = rescaler, - oob = oob, - n.breaks = n.breaks, - nice.breaks = nice.breaks, - right = right, - show.limits = show.limits, - - name = name, - breaks = breaks, - - labels = labels, - guide = guide, - position = position - ) + args <- find_args(call = NULL, scale_name = NULL, trans = NULL) + inject(super$new(!!!args, call = call %||% current_call())) } #' @export @@ -892,6 +725,58 @@ Scale <- ggproto("Scale", NULL, title }, + new = function(self, aesthetics = NULL, palette = waiver(), breaks = waiver(), + minor_breaks = waiver(), labels = waiver(), limits = NULL, + guide = NULL, position = NULL, fallback.palette = NULL, + call = caller_call(), ..., super = NULL) { + + super <- super %||% self + call <- call %||% super$call %||% current_call() + aesthetics <- standardise_aes_names(aesthetics %||% super$aesthetics) + limits <- allow_lambda(limits %||% super$limits) + breaks <- allow_lambda(breaks %|W|% super$breaks) + labels <- allow_lambda(labels %|W|% super$labels) + minor_breaks <- allow_lambda(minor_breaks %|W|% super$minor_breaks) + check_breaks_labels(breaks, labels, call = call) + position <- arg_match0(position %||% super$position, .trbl) + if (is.null(breaks) & !any(is_position_aes(aesthetics))) { + guide <- "none" + } + palette <- palette %|W|% fetch_ggproto(super, "palette") + fallback.palette <- validate_fallback_palette( + pal = palette, + fallback = fallback.palette %||% fetch_ggproto(super, "fallback_palette"), + discrete = super$is_discrete(), + call = call + ) + + ggproto( + NULL, super, + call = call, + aesthetics = aesthetics, + limits = limits, + breaks = breaks, + minor_breaks = minor_breaks, + labels = labels, + guide = guide %||% super$guide, + position = position, + palette = palette, + fallback_palette = fallback.palette, + ... + ) + }, + + updatable_params = c( + "aesthetics", "scale_name", "palette", "name", "breaks", "labels", + "limits", "expand", "na.value", "guide", "position", "fallback.palette", + "call", "super" + ), + + update = function(self, params) { + check_update_params(self, params) + inject(self$new(!!!params)) + }, + make_sec_title = function(self, ...) { self$make_title(...) }, @@ -999,6 +884,20 @@ Scale <- ggproto("Scale", NULL, # ScaleContinuous --------------------------------------------------------- +check_update_params <- function(scale, params) { + args <- scale$updatable_params + extra <- setdiff(names(params), args) + if (length(extra) == 0) { + return(invisible(NULL)) + } + extra <- paste0("{.val ", extra, "}") + names(extra) <- rep("*", length(extra)) + cli::cli_abort( + c("Cannot update scale with the unknown {cli::qty(extra)} argument{?s}:", extra), + call = scale$call + ) +} + # This needs to be defined prior to the Scale subclasses. default_transform <- function(self, x) { transformation <- self$get_transformation() @@ -1255,6 +1154,55 @@ ScaleContinuous <- ggproto("ScaleContinuous", Scale, } else { cat(" Limits: ", show_range(self$dimension()), "\n", sep = "") } + }, + + new = function(self, rescaler = NULL, oob = NULL, + range = ContinuousRange$new(), + transform = NULL, limits = NULL, ..., + call = NULL, super = NULL) { + super <- super %||% self + transform <- as.transform(transform %||% super$trans) + limits <- allow_lambda(limits) + if (!is.null(limits) && !is.function(limits)) { + limits <- transform$transform(limits) + if (!anyNA(limits)) { + limits <- sort(limits) + } + } + limits <- limits %||% super$limits + check_continuous_limits(limits, call = call) + + rescaler <- allow_lambda(rescaler %||% super$rescaler) + oob <- allow_lambda(oob %||% super$oob) + + ggproto_parent(Scale, self)$new( + rescaler = rescaler, + range = range, + oob = oob, + trans = transform, + limits = limits, + call = call, + ..., + super = super + ) + }, + + updatable_params = c( + Scale$updatable_params, + "minor_breaks", "n.breaks", "rescaler", "oob", "transform" + ), + + update = function(self, params) { + check_update_params(self, params) + # We may need to update limits when previously transformed and + # a new transformation is coming in + if ("transform" %in% names(params) && + self$trans$name != "identity" && + (!"limits" %in% names(params)) && + !is.null(self$limits) && !is.function(self$limits)) { + params$limits <- self$trans$inverse(self$limits) + } + inject(self$new(!!!params)) } ) @@ -1480,6 +1428,41 @@ ScaleDiscrete <- ggproto("ScaleDiscrete", Scale, major_source = major, minor_source = NULL ) + }, + + updatable_params = c( + Scale$updatable_params, + "minor_breaks", "na.translate", "drop" + ), + + new = function(self, aesthetics = NULL, palette = waiver(), limits = NULL, call = caller_call(), + range = DiscreteRange$new(), + ..., super = NULL) { + call <- call %||% current_call() + super <- super %||% self + limits <- allow_lambda(limits) + if (!is.function(limits) && (length(limits) > 0 && !is_discrete(limits))) { + cli::cli_warn(c( + "Continuous limits supplied to discrete scale.", + i = "Did you mean {.code limits = factor(...)} or {.fn scale_*_continuous}?" + ), call = call) + } + + aesthetics <- aesthetics %||% super$aesthetics + palette <- palette %|W|% .subset2(super, "palette") + if (identical(palette, identity) && any(is_position_aes(aesthetics))) { + palette <- seq_len + } + + ggproto_parent(Scale, self)$new( + limits = limits, + range = range, + call = call, + aesthetics = aesthetics, + palette = palette, + ..., + super = super %||% self + ) } ) @@ -1726,6 +1709,16 @@ ScaleBinned <- ggproto("ScaleBinned", Scale, list(range = range, labels = labels, major = pal, minor = NULL, major_source = major, minor_source = NULL) + }, + + updatable_params = c( + Scale$updatable_params, + "rescaler", "oob", "n.breaks", "nice.breaks", + "right", "transform", "show.limits" + ), + + new = function(self, ..., super = NULL) { + ggproto_parent(ScaleContinuous, self)$new(..., super = super %||% self) } ) @@ -1810,7 +1803,8 @@ validate_fallback_palette <- function(pal, fallback, aesthetic = "x", return(pal) } cli::cli_abort( - "When {.code palette = NULL}, the {.arg fallback.palette} must be defined." + "When {.code palette = NULL}, the {.arg fallback.palette} must be defined.", + call = call ) } diff --git a/R/scales-.R b/R/scales-.R index bc0f09b414..298d47ecc4 100644 --- a/R/scales-.R +++ b/R/scales-.R @@ -8,6 +8,7 @@ scales_list <- function() { ScalesList <- ggproto("ScalesList", NULL, scales = NULL, + params = list(), find = function(self, aesthetic) { vapply(self$scales, function(x) any(aesthetic %in% x$aesthetics), logical(1)) @@ -21,7 +22,10 @@ ScalesList <- ggproto("ScalesList", NULL, if (is.null(scale)) { return() } - + aes <- intersect(scale$aesthetics, names(self$params)) + for (i in aes) { + scale <- scale$update(self$params[[aes]]) + } prev_aes <- self$find(scale$aesthetics) if (any(prev_aes)) { # Get only the first aesthetic name in the returned vector -- it can @@ -171,6 +175,22 @@ ScalesList <- ggproto("ScalesList", NULL, } }, + add_params = function(self, aesthetic, params = NULL) { + if (is.null(params) || is.null(aesthetic)) { + return() + } + index <- which(self$find(aesthetic)) + if (length(index) > 0) { + for (i in index) { + self$scales[[i]] <- self$scales[[i]]$update(params) + } + } else { + for (i in aesthetic) { + self$params[[i]] <- defaults(params, self$params[[i]]) + } + } + }, + set_palettes = function(self, theme) { for (scale in self$scales) { if (!is.null(scale$palette)) { diff --git a/man/scale_params.Rd b/man/scale_params.Rd new file mode 100644 index 0000000000..d1e91fbd22 --- /dev/null +++ b/man/scale_params.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/all-classes.R +\name{scale_params} +\alias{scale_params} +\title{Setting scale parameters} +\usage{ +scale_params(aesthetics, ...) +} +\arguments{ +\item{aesthetics}{The name of the aesthetics for which to update the scale.} + +\item{...}{Named arguments to one of the scale constructors, +\code{\link[=continuous_scale]{continuous_scale()}}, \code{\link[=discrete_scale]{discrete_scale()}} or \code{\link[=binned_scale]{binned_scale()}}.} +} +\value{ +A \code{ggplot2::scale_params} object that can be added to a plot. +} +\description{ +Setting scale parameters +} +\examples{ +ggplot(mpg, aes(displ, hwy)) + + geom_point() + + scale_params("x", limits = c(0, 10)) + + scale_params("y", transform = "sqrt") +} diff --git a/tests/testthat/test-scales.R b/tests/testthat/test-scales.R index faed08e180..11cec55e20 100644 --- a/tests/testthat/test-scales.R +++ b/tests/testthat/test-scales.R @@ -210,3 +210,67 @@ test_that("populating palettes works", { expect_equal(scl$scales[[1]]$palette(2), c("red", "blue")) }) +test_that("continuous scales update limits when changing transforms", { + + x <- scale_x_continuous(limits = c(10, 100), transform = "sqrt") + expect_equal(x$limits, sqrt(c(10, 100))) + + x <- x$update(list(transform = "log10")) + expect_equal(x$limits, c(1, 2)) + +}) + +test_that("scale updating mechanism works", { + p <- ggplot(mtcars, aes(disp, mpg, colour = factor(cyl), shape = factor(gear))) + + geom_point(na.rm = TRUE) + + scales <- get_panel_scales( + p + + scale_params("y", name = "Miles per gallon") + + scale_params("y", limits = c(10, 40)) + + scale_y_continuous(transform = "sqrt") + + scale_params("y", expand = expansion()) + ) + y <- scales$y + expect_equal(y$get_limits(), sqrt(c(10, 40))) + expect_equal(y$expand, c(0, 0, 0, 0)) + expect_equal(y$name, "Miles per gallon") + + b <- ggplot_build( + p + + scale_params("colour", labels = identity, breaks = c(8, 4, 6)) + + scale_params(c("colour", "shape"), labels = function(x) as.character(as.roman(x))) + + scale_params("shape", limits = as.character(c(3, 5)), labels = identity) + ) + + # Roman label should override identity labels + # Order should be unnatural + l <- get_guide_data(b, "colour") + expect_equal(l$.label, c("VIII", "IV", "VI")) + + # Identity labels should override roman labels + # gear = 4 should be missing from legend + l <- get_guide_data(b, "shape") + expect_equal(l$.label, as.character(c(3, 5)), ignore_attr = "pos") +}) + +test_that("scale updateable params is consistent with constructors", { + + # Note: 'trans' is deprecated in favour of 'transform' + constr_params <- function(fun) setdiff(fn_fmls_names(fun), "trans") + + expect_setequal( + ScaleContinuous$updatable_params, + constr_params(continuous_scale) + ) + + expect_setequal( + ScaleDiscrete$updatable_params, + constr_params(discrete_scale) + ) + + expect_setequal( + ScaleBinned$updatable_params, + constr_params(binned_scale) + ) +})