diff --git a/DESCRIPTION b/DESCRIPTION index cc6394eb99..b49bc6c9b0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -177,6 +177,7 @@ Collate: 'guide-bins.R' 'guide-colorbar.r' 'guide-colorsteps.R' + 'guide-data.r' 'guide-legend.r' 'guides-.r' 'guides-axis.r' diff --git a/NAMESPACE b/NAMESPACE index af6f39040c..159152e7f4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -431,6 +431,7 @@ export(guide_colorbar) export(guide_colorsteps) export(guide_colourbar) export(guide_coloursteps) +export(guide_data) export(guide_gengrob) export(guide_geom) export(guide_legend) diff --git a/NEWS.md b/NEWS.md index 76bf5100a3..b692366709 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # ggplot2 (development version) +* New `guide_data()` function to programmatically access information about the + guides, such as axes and legends, for a final plot (@teunbrand, #5004). * Fixed a regression in `geom_hex()` where aesthetics were replicated across bins (@thomasp85, #5037 and #5044) * Fixed spurious warning when `weight` aesthetic was used in `stat_smooth()` diff --git a/R/coord-sf.R b/R/coord-sf.R index a2c2a49121..8f2aa82ed1 100644 --- a/R/coord-sf.R +++ b/R/coord-sf.R @@ -320,34 +320,12 @@ CoordSf <- ggproto("CoordSf", CoordCartesian, graticule <- panel_params$graticule # top axis - id1 <- id2 <- integer(0) - # labels based on panel side - id1 <- c(id1, which(graticule$type == panel_params$label_axes$top & graticule$y_start > 0.999)) - id2 <- c(id2, which(graticule$type == panel_params$label_axes$top & graticule$y_end > 0.999)) - - # labels based on graticule direction - if ("S" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "E" & graticule$y_start > 0.999)) - } - if ("N" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "E" & graticule$y_end > 0.999)) - } - if ("W" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "N" & graticule$y_start > 0.999)) - } - if ("E" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "N" & graticule$y_end > 0.999)) - } - - ticks1 <- graticule[unique0(id1), ] - ticks2 <- graticule[unique0(id2), ] - tick_positions <- c(ticks1$x_start, ticks2$x_end) - tick_labels <- c(ticks1$degree_label, ticks2$degree_label) + key <- graticule_to_ticks(graticule, panel_params, "top") - if (length(tick_positions) > 0) { + if (!is.null(key)) { top <- draw_axis( - tick_positions, - tick_labels, + key$x, + key$.label, axis_position = "top", theme = theme ) @@ -356,34 +334,12 @@ CoordSf <- ggproto("CoordSf", CoordCartesian, } # bottom axis - id1 <- id2 <- integer(0) - # labels based on panel side - id1 <- c(id1, which(graticule$type == panel_params$label_axes$bottom & graticule$y_start < 0.001)) - id2 <- c(id2, which(graticule$type == panel_params$label_axes$bottom & graticule$y_end < 0.001)) - - # labels based on graticule direction - if ("S" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "E" & graticule$y_start < 0.001)) - } - if ("N" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "E" & graticule$y_end < 0.001)) - } - if ("W" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "N" & graticule$y_start < 0.001)) - } - if ("E" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "N" & graticule$y_end < 0.001)) - } - - ticks1 <- graticule[unique0(id1), ] - ticks2 <- graticule[unique0(id2), ] - tick_positions <- c(ticks1$x_start, ticks2$x_end) - tick_labels <- c(ticks1$degree_label, ticks2$degree_label) + key <- graticule_to_ticks(graticule, panel_params, "bottom") - if (length(tick_positions) > 0) { + if (!is.null(key)) { bottom <- draw_axis( - tick_positions, - tick_labels, + key$x, + key$.label, axis_position = "bottom", theme = theme ) @@ -397,35 +353,12 @@ CoordSf <- ggproto("CoordSf", CoordCartesian, render_axis_v = function(self, panel_params, theme) { graticule <- panel_params$graticule - # right axis - id1 <- id2 <- integer(0) - # labels based on panel side - id1 <- c(id1, which(graticule$type == panel_params$label_axes$right & graticule$x_end > 0.999)) - id2 <- c(id2, which(graticule$type == panel_params$label_axes$right & graticule$x_start > 0.999)) + key <- graticule_to_ticks(graticule, panel_params, "right") - # labels based on graticule direction - if ("N" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "E" & graticule$x_end > 0.999)) - } - if ("S" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "E" & graticule$x_start > 0.999)) - } - if ("E" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "N" & graticule$x_end > 0.999)) - } - if ("W" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "N" & graticule$x_start > 0.999)) - } - - ticks1 <- graticule[unique0(id1), ] - ticks2 <- graticule[unique0(id2), ] - tick_positions <- c(ticks1$y_end, ticks2$y_start) - tick_labels <- c(ticks1$degree_label, ticks2$degree_label) - - if (length(tick_positions) > 0) { + if (!is.null(key)) { right <- draw_axis( - tick_positions, - tick_labels, + key$y, + key$.label, axis_position = "right", theme = theme ) @@ -433,35 +366,12 @@ CoordSf <- ggproto("CoordSf", CoordCartesian, right <- zeroGrob() } - # left axis - id1 <- id2 <- integer(0) - # labels based on panel side - id1 <- c(id1, which(graticule$type == panel_params$label_axes$left & graticule$x_end < 0.001)) - id2 <- c(id2, which(graticule$type == panel_params$label_axes$left & graticule$x_start < 0.001)) + key <- graticule_to_ticks(graticule, panel_params, "left") - # labels based on graticule direction - if ("N" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "E" & graticule$x_end < 0.001)) - } - if ("S" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "E" & graticule$x_start < 0.001)) - } - if ("E" %in% panel_params$label_graticule) { - id1 <- c(id1, which(graticule$type == "N" & graticule$x_end < 0.001)) - } - if ("W" %in% panel_params$label_graticule) { - id2 <- c(id2, which(graticule$type == "N" & graticule$x_start < 0.001)) - } - - ticks1 <- graticule[unique0(id1), ] - ticks2 <- graticule[unique0(id2), ] - tick_positions <- c(ticks1$y_end, ticks2$y_start) - tick_labels <- c(ticks1$degree_label, ticks2$degree_label) - - if (length(tick_positions) > 0) { + if (!is.null(key)) { left <- draw_axis( - tick_positions, - tick_labels, + key$y, + key$.label, axis_position = "left", theme = theme ) @@ -716,3 +626,70 @@ parse_axes_labeling <- function(x) { labs = unlist(strsplit(x, "")) list(top = labs[1], right = labs[2], bottom = labs[3], left = labs[4]) } + +# In essence what guide_train + guide_transform does for sf graticules +graticule_to_ticks <- function(graticule, panel, position) { + + position <- arg_match0(position, c("top", "bottom", "left", "right")) + aesthetic <- if (position %in% c("top", "bottom")) "x" else "y" + + pos_check <- graticule$type == panel$label_axes[[position]] + thres <- if (position %in% c("bottom", "left")) 0.001 else 0.999 + switch( + position, + "top" = { + start_check <- graticule$y_start > thres + end_check <- graticule$y_end > thres + }, + "bottom" = { + start_check <- graticule$y_start < thres + end_check <- graticule$y_end < thres + }, + # vertical ticks have flipped start/end logic here + "right" = { + start_check <- graticule$x_end > thres + end_check <- graticule$x_start > thres + }, + "left" = { + start_check <- graticule$x_end < thres + end_check <- graticule$x_start < thres + } + ) + + id1 <- id2 <- integer(0) + id1 <- c(id1, which(pos_check & start_check)) + id2 <- c(id2, which(pos_check & end_check)) + + if ("N" %in% panel$label_graticule) { + id1 <- c(id1, which(graticule$type == "E" & start_check)) + } + if ("S" %in% panel$label_graticule) { + id2 <- c(id2, which(graticule$type == "E" & end_check)) + } + if ("E" %in% panel$label_graticule) { + id1 <- c(id1, which(graticule$type == "N" & start_check)) + } + if ("W" %in% panel$label_graticule) { + id2 <- c(id2, which(graticule$type == "N" & end_check)) + } + + ticks1 <- graticule[unique0(id1), ] + ticks2 <- graticule[unique0(id2), ] + + if (position %in% c("top", "bottom")) { + tick_positions <- c(ticks1$x_start, ticks2$x_end) + } else { + tick_positions <- c(ticks1$y_end, ticks2$y_start) + } + if (length(tick_positions) == 0) { + return(NULL) + } + + key <- data_frame(tick_positions, .name_repair = ~ aesthetic) + key$.value <- c(ticks1$degree, ticks2$degree) + key$.label <- c(ticks1$degree_label, ticks2$degree_label) + key[[setdiff(c("x", "y"), aesthetic)]] <- switch( + position, "top" = 1, "bottom" = 0, "left" = 0, "right" = 1 + ) + key +} diff --git a/R/guide-data.r b/R/guide-data.r new file mode 100644 index 0000000000..bc670baaf2 --- /dev/null +++ b/R/guide-data.r @@ -0,0 +1,192 @@ +#' Extract tick information from guides +#' +#' `guide_data()` builds a plot and extracts information from guide keys. This +#' information typically contains positions, values and/or labels, depending +#' on which aesthetic is queried or guide is used. +#' +#' @param plot A `ggplot` object, or `ggplot_build` object. +#' @param aesthetic A scalar character that describes an aesthetic for which +#' to extract guide information. For example `"colour"`, `"size"`, `"x"` or +#' `"y.sec"`. +#' @param i,j An integer giving a row (i) or column (j) number of a facet for +#' which to return position guide information. +#' +#' @details When used with plots containing `coord_sf()`, an aesthetic of `"x"` +#' is used for the bottom axis, `"x.sec"` for the top axis, `"y"` for the +#' left axis and `"y.sec"` for the right axis. This is also the default for +#' other coords, but this can be changed for other coords with the `guides()` +#' function. +#' +#' @return A `data.frame` containing information extracted from the guide key, +#' or `NULL` when no such information could be found. +#' @export +#' +#' @examples +#' # A typical plot +#' p <- ggplot(mtcars) + +#' aes(mpg, disp, colour = drat, size = drat) + +#' geom_point() + +#' facet_wrap(vars(cyl), scales = "free_x") +#' +#' # Getting guide data for position aesthetic in particular panel +#' guide_data(p, "x", i = 1, j = 2) +#' +#' # Getting guide data for a legend +#' guide_data(p, "size") +#' +#' # If guides are merged, `guide_data()` reports merged guide data +#' guide_data(p + guides(colour = "legend"), "size") +guide_data <- function( + plot = last_plot(), aesthetic, i = 1L, j = 1L +) { + # For now, only handles single aesthetics + if (!is_scalar_character(aesthetic)) { + cli::cli_abort(paste0( + "{.arg aesthetic} must be a {.cls character} of length 1." + )) + } + + # Pre-build plot + if (!inherits(plot, "ggplot_built")) { + plot <- ggplot_build(plot) + } + + # Decide whether to get position or regular guide information + if (aesthetic %in% c("x", "y", "x.sec", "y.sec", "theta", "r")) { + ans <- guide_data_position(plot, aesthetic, i = i, j = j) + } else { + # Regular guides don't carry any panel-wise information, so i/j are dropped + ans <- guide_data_legend(plot, aesthetic) + } + ans +} + +guide_data_position <- function(plot, aesthetic, i = 1L, j = 1L) { + + # We might expect guides to be unnamed, but matchable by position + k <- match(aesthetic, c("x", "y", "x.sec", "y.sec", + "theta", "theta.sec", "r", "r.sec")) + + # Select relevant panel + layout <- plot$layout$layout + selected <- layout[layout$ROW == i & layout$COL == j, , drop = FALSE] + if (nrow(selected) < 1) { + cli::cli_warn(c(paste0( + "Selection with {.arg i = {i[1]}} and {.arg j = {j[1]}} yielded no ", + "suitable panels." + ), "!" = "{.code NULL} was returned.")) + return(NULL) + } + + # Execute `plot$layout$setup_panel_guides()` for relevant panels only + panels <- lapply( + plot$layout$panel_params[selected$PANEL], + plot$layout$coord$setup_panel_guides, + plot$plot$guides, + plot$layout$coord_params + ) + panels <- lapply( + panels, + plot$layout$coord$train_panel_guides, + plot$plot$layers, + plot$plot$mapping, + plot$layout$coord_params + ) + + # For coord_polar(), translate x/y to theta/r + if (!is.null(plot$layout$coord$theta)) { + if (plot$layout$coord$theta == "y") { + aesthetic <- switch( + aesthetic, + "x" = "r", "x.sec" = "r.sec", + "y" = "theta", "y.sec" = "theta.sec", + aesthetic + ) + } else { + aesthetic <- switch( + aesthetic, + "x" = "theta", "x.sec" = "theta.sec", + "y" = "r", "y.sec" = "r.sec", + aesthetic + ) + } + } + + # Loop through panel, extract guide information + lapply(panels, function(panel) { + if (!is_empty(panel$guides)) { + # We have coords with proper guides + if (is.null(names(panel$guides))) { + panel$guides[[k]]$key + } else { + panel$guides[[aesthetic]]$key + } + } else if (!is_empty(panel$graticule)) { + # We have CoordSf which has graticules + position <- switch( + aesthetic, + "x" = "bottom", "x.sec" = "top", + "y" = "left", "y.sec" = "right", + cli::cli_abort(paste0( + "Cannot extract guide data from graticule for ", + "{.arg aesthetic = '{aesthetic}'}." + )) + ) + graticule_to_ticks(panel$graticule, panel, position) + } else { + # We have coords without proper guides + cols <- paste0(aesthetic, c(".major", ".labels")) + out <- data_frame0(major = panel[[cols[1]]], labels = panel[[cols[2]]]) + colnames(out) <- cols + out + } + }) +} + +guide_data_legend <- function(plot, aesthetic, ...) { + + # Prepare theme information + theme <- plot_theme(plot$plot) + position <- theme$legend.position %||% "right" + if (position == "none") { + return(NULL) + } + + # Pick all scales, since guides can be merged + scales <- plot$plot$scales$non_position_scales() + if (!scales$has_scale(aesthetic)) { + cli::cli_warn(c(paste0( + "No scale for {.arg aesthetic = '{aesthetic}'} was found." + ), "!" = "{.code NULL} was returned.")) + return(NULL) + } + + # Mimic `build_guides` + theme$legend.direction <- switch( + legend_position(position), + "inside" = "vertical", + "vertical" = "vertical", + "horizontal" = "horizontal" + ) + + # Train guides to build keys from scales + guides <- guides_train( + scales = scales, + theme = theme, + guides = plot$plot$guides, + labels = plot$plot$mapping + ) + if (length(guides) == 0) { + return(NULL) + } + + # Merge guides + guides <- guides_merge(guides) + + # Extract keys + guides <- lapply(guides, `[[`, "key") + my_guide <- vapply(guides, function(x) aesthetic %in% colnames(x), logical(1)) + unname(guides)[my_guide] +} + + diff --git a/man/guide_data.Rd b/man/guide_data.Rd new file mode 100644 index 0000000000..c6a35fa338 --- /dev/null +++ b/man/guide_data.Rd @@ -0,0 +1,50 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/guide-data.r +\name{guide_data} +\alias{guide_data} +\title{Extract tick information from guides} +\usage{ +guide_data(plot = last_plot(), aesthetic, i = 1L, j = 1L) +} +\arguments{ +\item{plot}{A \code{ggplot} object, or \code{ggplot_build} object.} + +\item{aesthetic}{A scalar character that describes an aesthetic for which +to extract guide information. For example \code{"colour"}, \code{"size"}, \code{"x"} or +\code{"y.sec"}.} + +\item{i, j}{An integer giving a row (i) or column (j) number of a facet for +which to return position guide information.} +} +\value{ +A \code{data.frame} containing information extracted from the guide key, +or \code{NULL} when no such information could be found. +} +\description{ +\code{guide_data()} builds a plot and extracts information from guide keys. This +information typically contains positions, values and/or labels, depending +on which aesthetic is queried or guide is used. +} +\details{ +When used with plots containing \code{coord_sf()}, an aesthetic of \code{"x"} +is used for the bottom axis, \code{"x.sec"} for the top axis, \code{"y"} for the +left axis and \code{"y.sec"} for the right axis. This is also the default for +other coords, but this can be changed for other coords with the \code{guides()} +function. +} +\examples{ +# A typical plot +p <- ggplot(mtcars) + + aes(mpg, disp, colour = drat, size = drat) + + geom_point() + + facet_wrap(vars(cyl), scales = "free_x") + +# Getting guide data for position aesthetic in particular panel +guide_data(p, "x", i = 1, j = 2) + +# Getting guide data for a legend +guide_data(p, "size") + +# If guides are merged, `guide_data()` reports merged guide data +guide_data(p + guides(colour = "legend"), "size") +} diff --git a/tests/testthat/test-guides.R b/tests/testthat/test-guides.R index 8203c0c3b2..67d8fc839a 100644 --- a/tests/testthat/test-guides.R +++ b/tests/testthat/test-guides.R @@ -240,6 +240,99 @@ test_that("colorsteps and bins checks the breaks format", { expect_snapshot_error(suppressWarnings(ggplotGrob(p))) }) +test_that("guide_data retrieves appropriate data", { + + p <- ggplot(mtcars) + + aes(mpg, disp, colour = drat, size = drat, fill = wt) + + geom_point(shape = 21) + + facet_wrap(vars(cyl), scales = "free_x") + + guides(colour = guide_legend()) + b <- ggplot_build(p) + + # Can we retrieve facetted panel labels? + test <- guide_data(b, "x", i = 1, j = 2) + expect_equal(test[[1]]$.label, c("18", "19", "20", "21")) + + # Can we retrieve legend guide keys? + test <- guide_data(b, "fill") + expect_equal(test[[1]]$.label, c("2", "3", "4", "5")) + + # Do we get merged keys when appropriate? + test <- guide_data(b, "colour") + expect_true(all(c("colour", "size") %in% colnames(test[[1]]))) + + # Does it warns for aesthetics that aren't mapped? + expect_warning( + test <- guide_data(b, "shape"), + "No scale" + ) + expect_null(test) + + # Does it warn for unsuitable panels? + expect_warning( + test <- guide_data(b, "x", i = 2, j = 2), + "no suitable panels" + ) + expect_null(test) + + # When `position = "none"` should return NULL + b$plot$theme$legend.position <- "none" + test <- guide_data(b, "fill") + expect_null(test) + + # Should abort when making a typo + expect_error( + guide_data(b, 1), + "must be" + ) +}) + +test_that("guide_data can retrieve axis information for different coords", { + + p <- ggplot(mtcars, aes(mpg, disp)) + geom_point() + + b <- ggplot_build(p + coord_polar()) + + # Should get theta labels for x + test <- guide_data(b, "x") + expect_equal(colnames(test[[1]]), c("theta.major", "theta.labels")) + + # Should get r labels for x when theta = "y" + b$layout$coord$theta <- "y" + test <- guide_data(b, "x") + expect_equal(colnames(test[[1]]), c("r.major", "r.labels")) + + # Should be able to get r/theta labels directly + test <- guide_data(b, "theta") + expect_equal(colnames(test[[1]]), c("theta.major", "theta.labels")) + test <- guide_data(b, "r") + expect_equal(colnames(test[[1]]), c("r.major", "r.labels")) + + # Should work with coord_flip + test <- guide_data(p + coord_flip(), "x") + expect_equal(test[[1]]$.label, c("100", "200", "300", "400")) + + skip_if_not_installed("sf") + nc <- sf::st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE) + p <- ggplot(nc) + + geom_sf() + + coord_sf(label_axes = c("ENEN")) + b <- ggplot_build(p) + + # Should extract graticule information + test <- guide_data(b, "x.sec") + expect_equal(test[[1]]$.value, seq(-84, -76, by = 2)) + + test <- guide_data(b, "y.sec") + expect_equal(test[[1]]$.value, seq(34, 36.5, by = 0.5)) + + # Should error when aesthetic is inappropriate + expect_error( + guide_data(b, "theta"), + "Cannot extract" + ) +}) + # Visual tests ------------------------------------------------------------ test_that("axis guides are drawn correctly", {