diff --git a/NEWS.md b/NEWS.md index 549c3635e1..76246c4453 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,10 @@ # ggplot2 3.0.0.9000 +* `scale_*_date()`, `scale_*_time()` and `scale_*_datetime()` can now display + a secondary axis that is a __one-to-one__ transformation of the primary axis, + implemented using the `sec.axis` argument to the scale constructor + (@dpseidel, #2244). + * The error message in `compute_aesthetics()` now provides the names of only aesthetics with mismatched lengths, rather than all aesthetics (@karawoo, #2853). @@ -19,7 +24,7 @@ `grouped_df()` objects when dplyr is not installed (@jimhester, #2822). * All `geom_*()` now display an informative error message when required - aesthetics are missing (@dpseidel, #2637 and #2706). + aesthetics are missing (@dpseidel, #2637 and #2706).s * `sec_axis()` and `dup_axis()` now return appropriate breaks for the secondary axis when applied to log transformed scales (@dpseidel, #2729). diff --git a/R/axis-secondary.R b/R/axis-secondary.R index d62ecc275f..ebd7e58da1 100644 --- a/R/axis-secondary.R +++ b/R/axis-secondary.R @@ -45,6 +45,30 @@ #' # You can pass in a formula as a shorthand #' p + scale_y_continuous(sec.axis = ~.^2) #' +#' # Secondary axes work for date and datetime scales too: +#' df <- data.frame( +#' dx = seq(as.POSIXct("2012-02-29 12:00:00", +#' tz = "UTC", +#' format = "%Y-%m-%d %H:%M:%S" +#' ), +#' length.out = 10, by = "4 hour" +#' ), +#' price = seq(20, 200000, length.out = 10) +#' ) +#' +#' # useful for labelling different time scales in the same plot +#' ggplot(df, aes(x = dx, y = price)) + geom_line() + +#' scale_x_datetime("Date", date_labels = "%b %d", +#' date_breaks = "6 hour", +#' sec.axis = dup_axis(name = "Time of Day", +#' labels = scales::time_format("%I %p"))) +#' +#' # or to transform axes for different timezones +#' ggplot(df, aes(x = dx, y = price)) + geom_line() + +#' scale_x_datetime("GMT", date_labels = "%b %d %I %p", +#' sec.axis = sec_axis(~. + 8*3600, name = "GMT+8", +#' labels = scales::time_format("%b %d %I %p"))) +#' #' @export sec_axis <- function(trans = NULL, name = waiver(), breaks = waiver(), labels = waiver()) { if (!is.formula(trans)) stop("transformation for secondary axes must be a formula", call. = FALSE) @@ -61,9 +85,20 @@ sec_axis <- function(trans = NULL, name = waiver(), breaks = waiver(), labels = dup_axis <- function(trans = ~., name = derive(), breaks = derive(), labels = derive()) { sec_axis(trans, name, breaks, labels) } + is.sec_axis <- function(x) { inherits(x, "AxisSecondary") } + +set_sec_axis <- function(sec.axis, scale) { + if (!is.waive(sec.axis)) { + if (is.formula(sec.axis)) sec.axis <- sec_axis(sec.axis) + if (!is.sec_axis(sec.axis)) stop("Secondary axes must be specified using 'sec_axis()'") + scale$secondary.axis <- sec.axis + } + return(scale) +} + #' @rdname sec_axis #' #' @export diff --git a/R/scale-continuous.r b/R/scale-continuous.r index 044227a1c3..7134dd8c84 100644 --- a/R/scale-continuous.r +++ b/R/scale-continuous.r @@ -86,12 +86,9 @@ scale_x_continuous <- function(name = waiver(), breaks = waiver(), expand = expand, oob = oob, na.value = na.value, trans = trans, guide = "none", position = position, super = ScaleContinuousPosition ) - if (!is.waive(sec.axis)) { - if (is.formula(sec.axis)) sec.axis <- sec_axis(sec.axis) - if (!is.sec_axis(sec.axis)) stop("Secondary axes must be specified using 'sec_axis()'") - sc$secondary.axis <- sec.axis - } - sc + + set_sec_axis(sec.axis, sc) + } #' @rdname scale_continuous @@ -108,12 +105,8 @@ scale_y_continuous <- function(name = waiver(), breaks = waiver(), expand = expand, oob = oob, na.value = na.value, trans = trans, guide = "none", position = position, super = ScaleContinuousPosition ) - if (!is.waive(sec.axis)) { - if (is.formula(sec.axis)) sec.axis <- sec_axis(sec.axis) - if (!is.sec_axis(sec.axis)) stop("Secondary axes must be specified using 'sec_axis()'") - sc$secondary.axis <- sec.axis - } - sc + + set_sec_axis(sec.axis, sc) } diff --git a/R/scale-date.r b/R/scale-date.r index 6dceba7e89..030d2f412d 100644 --- a/R/scale-date.r +++ b/R/scale-date.r @@ -31,6 +31,7 @@ #' @param timezone The timezone to use for display on the axes. The default #' (`NULL`) uses the timezone encoded in the data. #' @family position scales +#' @seealso [sec_axis()] for how to specify secondary axes #' @examples #' last_month <- Sys.Date() - 0:29 #' df <- data.frame( @@ -49,6 +50,7 @@ #' #' # Set limits #' base + scale_x_date(limits = c(Sys.Date() - 7, NA)) +#' #' @name scale_date #' @aliases NULL NULL @@ -64,9 +66,10 @@ scale_x_date <- function(name = waiver(), date_minor_breaks = waiver(), limits = NULL, expand = waiver(), - position = "bottom") { + position = "bottom", + sec.axis = waiver()) { - datetime_scale( + sc <- datetime_scale( c("x", "xmin", "xmax", "xend"), "date", name = name, @@ -82,6 +85,8 @@ scale_x_date <- function(name = waiver(), expand = expand, position = position ) + + set_sec_axis(sec.axis, sc) } #' @rdname scale_date @@ -95,9 +100,10 @@ scale_y_date <- function(name = waiver(), date_minor_breaks = waiver(), limits = NULL, expand = waiver(), - position = "left") { + position = "left", + sec.axis = waiver()) { - datetime_scale( + sc <- datetime_scale( c("y", "ymin", "ymax", "yend"), "date", name = name, @@ -113,6 +119,8 @@ scale_y_date <- function(name = waiver(), expand = expand, position = position ) + + set_sec_axis(sec.axis, sc) } #' @export @@ -127,9 +135,10 @@ scale_x_datetime <- function(name = waiver(), timezone = NULL, limits = NULL, expand = waiver(), - position = "bottom") { + position = "bottom", + sec.axis = waiver()) { - datetime_scale( + sc <- datetime_scale( c("x", "xmin", "xmax", "xend"), "time", name = name, @@ -146,6 +155,8 @@ scale_x_datetime <- function(name = waiver(), expand = expand, position = position ) + + set_sec_axis(sec.axis, sc) } @@ -161,9 +172,10 @@ scale_y_datetime <- function(name = waiver(), timezone = NULL, limits = NULL, expand = waiver(), - position = "left") { + position = "left", + sec.axis = waiver()) { - datetime_scale( + sc <- datetime_scale( c("y", "ymin", "ymax", "yend"), "time", name = name, @@ -180,6 +192,8 @@ scale_y_datetime <- function(name = waiver(), expand = expand, position = position ) + + set_sec_axis(sec.axis, sc) } @@ -194,7 +208,8 @@ scale_x_time <- function(name = waiver(), expand = waiver(), oob = censor, na.value = NA_real_, - position = "bottom") { + position = "bottom", + sec.axis = waiver()) { scale_x_continuous( name = name, @@ -206,7 +221,8 @@ scale_x_time <- function(name = waiver(), oob = oob, na.value = na.value, position = position, - trans = scales::hms_trans() + trans = scales::hms_trans(), + sec.axis = sec.axis ) } @@ -221,7 +237,8 @@ scale_y_time <- function(name = waiver(), expand = waiver(), oob = censor, na.value = NA_real_, - position = "left") { + position = "left", + sec.axis = waiver()) { scale_y_continuous( name = name, @@ -233,7 +250,8 @@ scale_y_time <- function(name = waiver(), oob = oob, na.value = na.value, position = position, - trans = scales::hms_trans() + trans = scales::hms_trans(), + sec.axis = sec.axis ) } @@ -301,6 +319,7 @@ datetime_scale <- function(aesthetics, trans, palette, #' @usage NULL #' @export ScaleContinuousDatetime <- ggproto("ScaleContinuousDatetime", ScaleContinuous, + secondary.axis = waiver(), timezone = NULL, transform = function(self, x) { tz <- attr(x, "tzone") @@ -312,7 +331,30 @@ ScaleContinuousDatetime <- ggproto("ScaleContinuousDatetime", ScaleContinuous, }, map = function(self, x, limits = self$get_limits()) { self$oob(x, limits) + }, + break_info = function(self, range = NULL) { + breaks <- ggproto_parent(ScaleContinuous, self)$break_info(range) + if (!(is.waive(self$secondary.axis) || self$secondary.axis$empty())) { + self$secondary.axis$init(self) + breaks <- c(breaks, self$secondary.axis$break_info(breaks$range, self)) + } + breaks + }, + sec_name = function(self) { + if (is.waive(self$secondary.axis)) { + waiver() + } else { + self$secondary.axis$name + } + }, + make_sec_title = function(self, title) { + if (!is.waive(self$secondary.axis)) { + self$secondary.axis$make_title(title) + } else { + ggproto_parent(ScaleContinuous, self)$make_sec_title(title) + } } + ) #' @rdname ggplot2-ggproto @@ -320,7 +362,30 @@ ScaleContinuousDatetime <- ggproto("ScaleContinuousDatetime", ScaleContinuous, #' @usage NULL #' @export ScaleContinuousDate <- ggproto("ScaleContinuousDate", ScaleContinuous, + secondary.axis = waiver(), map = function(self, x, limits = self$get_limits()) { self$oob(x, limits) + }, + break_info = function(self, range = NULL) { + breaks <- ggproto_parent(ScaleContinuous, self)$break_info(range) + if (!(is.waive(self$secondary.axis) || self$secondary.axis$empty())) { + self$secondary.axis$init(self) + breaks <- c(breaks, self$secondary.axis$break_info(breaks$range, self)) + } + breaks + }, + sec_name = function(self) { + if (is.waive(self$secondary.axis)) { + waiver() + } else { + self$secondary.axis$name + } + }, + make_sec_title = function(self, title) { + if (!is.waive(self$secondary.axis)) { + self$secondary.axis$make_title(title) + } else { + ggproto_parent(ScaleContinuous, self)$make_sec_title(title) + } } ) diff --git a/man/scale_date.Rd b/man/scale_date.Rd index 91744aa438..aa1c57d0c4 100644 --- a/man/scale_date.Rd +++ b/man/scale_date.Rd @@ -12,34 +12,36 @@ scale_x_date(name = waiver(), breaks = waiver(), date_breaks = waiver(), labels = waiver(), date_labels = waiver(), minor_breaks = waiver(), date_minor_breaks = waiver(), - limits = NULL, expand = waiver(), position = "bottom") + limits = NULL, expand = waiver(), position = "bottom", + sec.axis = waiver()) scale_y_date(name = waiver(), breaks = waiver(), date_breaks = waiver(), labels = waiver(), date_labels = waiver(), minor_breaks = waiver(), date_minor_breaks = waiver(), - limits = NULL, expand = waiver(), position = "left") + limits = NULL, expand = waiver(), position = "left", + sec.axis = waiver()) scale_x_datetime(name = waiver(), breaks = waiver(), date_breaks = waiver(), labels = waiver(), date_labels = waiver(), minor_breaks = waiver(), date_minor_breaks = waiver(), timezone = NULL, limits = NULL, expand = waiver(), - position = "bottom") + position = "bottom", sec.axis = waiver()) scale_y_datetime(name = waiver(), breaks = waiver(), date_breaks = waiver(), labels = waiver(), date_labels = waiver(), minor_breaks = waiver(), date_minor_breaks = waiver(), timezone = NULL, limits = NULL, expand = waiver(), - position = "left") + position = "left", sec.axis = waiver()) scale_x_time(name = waiver(), breaks = waiver(), minor_breaks = waiver(), labels = waiver(), limits = NULL, expand = waiver(), oob = censor, na.value = NA_real_, - position = "bottom") + position = "bottom", sec.axis = waiver()) scale_y_time(name = waiver(), breaks = waiver(), minor_breaks = waiver(), labels = waiver(), limits = NULL, expand = waiver(), oob = censor, na.value = NA_real_, - position = "left") + position = "left", sec.axis = waiver()) } \arguments{ \item{name}{The name of the scale. Used as the axis or legend title. If @@ -99,6 +101,8 @@ expand the scale by 5\% on each side for continuous variables, and by \item{position}{The position of the axis. "left" or "right" for vertical scales, "top" or "bottom" for horizontal scales} +\item{sec.axis}{specify a secondary axis} + \item{timezone}{The timezone to use for display on the axes. The default (\code{NULL}) uses the timezone encoded in the data.} @@ -132,8 +136,11 @@ base + scale_x_date(date_minor_breaks = "1 day") # Set limits base + scale_x_date(limits = c(Sys.Date() - 7, NA)) + } \seealso{ +\code{\link[=sec_axis]{sec_axis()}} for how to specify secondary axes + Other position scales: \code{\link{scale_x_continuous}}, \code{\link{scale_x_discrete}} } diff --git a/man/sec_axis.Rd b/man/sec_axis.Rd index b3f722d74d..b6383830d4 100644 --- a/man/sec_axis.Rd +++ b/man/sec_axis.Rd @@ -65,4 +65,28 @@ p + scale_y_continuous(sec.axis = dup_axis()) # You can pass in a formula as a shorthand p + scale_y_continuous(sec.axis = ~.^2) +# Secondary axes work for date and datetime scales too: +df <- data.frame( + dx = seq(as.POSIXct("2012-02-29 12:00:00", + tz = "UTC", + format = "\%Y-\%m-\%d \%H:\%M:\%S" + ), + length.out = 10, by = "4 hour" + ), + price = seq(20, 200000, length.out = 10) + ) + +# useful for labelling different time scales in the same plot +ggplot(df, aes(x = dx, y = price)) + geom_line() + + scale_x_datetime("Date", date_labels = "\%b \%d", + date_breaks = "6 hour", + sec.axis = dup_axis(name = "Time of Day", + labels = scales::time_format("\%I \%p"))) + +# or to transform axes for different timezones +ggplot(df, aes(x = dx, y = price)) + geom_line() + + scale_x_datetime("GMT", date_labels = "\%b \%d \%I \%p", + sec.axis = sec_axis(~. + 8*3600, name = "GMT+8", + labels = scales::time_format("\%b \%d \%I \%p"))) + } diff --git a/tests/testthat/test-sec-axis.R b/tests/testthat/test-sec-axis.R index 6d79c2875e..c143bbc2da 100644 --- a/tests/testthat/test-sec-axis.R +++ b/tests/testthat/test-sec-axis.R @@ -120,6 +120,48 @@ test_that("sec axis works with tidy eval", { expect_equal(breaks$major_source / 10, breaks$sec.major_source) }) +test_that("sec_axis works with date/time/datetime scales", { + df <- data.frame( + dx = seq(as.POSIXct("2012-02-29 12:00:00", + tz = "UTC", + format = "%Y-%m-%d %H:%M:%S" + ), + length.out = 10, by = "4 hour" + ), + price = seq(20, 200000, length.out = 10) + ) + df$date <- as.Date(df$dx) + dt <- ggplot(df, aes(dx, price)) + + geom_line() + + scale_x_datetime(sec.axis = dup_axis()) + scale <- layer_scales(dt)$x + breaks <- scale$break_info() + expect_equal(breaks$major_source, breaks$sec.major_source) + + dt <- ggplot(df, aes(date, price)) + + geom_line() + + scale_x_date(sec.axis = dup_axis()) + scale <- layer_scales(dt)$x + breaks <- scale$break_info() + expect_equal(breaks$major_source, breaks$sec.major_source) + + dt <- ggplot(df, aes(dx, price)) + + geom_line() + + scale_x_datetime( + name = "UTC", + sec.axis = dup_axis(~. + 12 * 60 * 60, + name = "UTC+12" + ) + ) + scale <- layer_scales(dt)$x + breaks <- scale$break_info() + + expect_equal( + as.numeric(breaks$major_source) + 12 * 60 * 60, + as.numeric(breaks$sec.major_source) + ) +}) + test_that("sec_axis() works for power transformations (monotonicity test doesn't fail)", { p <- ggplot(foo, aes(x, y)) + geom_point() +