diff --git a/NEWS.md b/NEWS.md index a15a099b5c..d86340c476 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,13 @@ # ggplot2 (development version) +* The spacing between legend keys and their labels, in addition to legends + and their titles, is now controlled by the text's `margin` setting. Not + specifying margins will automatically add appropriate text margins. To + control the spacing within a legend between keys, the new + `key.spacing.{x/y}` argument can be used. This leaves the + `legend.spacing` dedicated to controlling the spacing between + different guides (#5455). + * In the theme element hierarchy, parent elements that are a strict subclass of child elements now confer their subclass upon the children (#5457). diff --git a/R/guide-bins.R b/R/guide-bins.R index f20adee759..1f2228c8c3 100644 --- a/R/guide-bins.R +++ b/R/guide-bins.R @@ -341,13 +341,6 @@ GuideBins <- ggproto( } key$.label[c(1, n_labels)[!params$show.limits]] <- "" - just <- switch( - params$direction, - horizontal = elements$text$vjust, - vertical = elements$text$hjust, - 0.5 - ) - if (params$direction == "vertical") { key$.value <- 1 - key$.value } @@ -356,7 +349,6 @@ GuideBins <- ggproto( elements$text, label = key$.label, x = unit(key$.value, "npc"), - y = rep(just, nrow(key)), margin_x = FALSE, margin_y = TRUE, flip = params$direction == "vertical" diff --git a/R/guide-colorbar.R b/R/guide-colorbar.R index 7e71eaba0c..265d6bec61 100644 --- a/R/guide-colorbar.R +++ b/R/guide-colorbar.R @@ -307,9 +307,6 @@ GuideColourbar <- ggproto( ticks_length = unit(0.2, "npc"), background = "legend.background", margin = "legend.margin", - spacing = "legend.spacing", - spacing.x = "legend.spacing.x", - spacing.y = "legend.spacing.y", key = "legend.key", key.height = "legend.key.height", key.width = "legend.key.width", @@ -413,17 +410,10 @@ GuideColourbar <- ggproto( return(list(labels = zeroGrob())) } - just <- if (params$direction == "horizontal") { - elements$text$vjust - } else { - elements$text$hjust - } - list(labels = flip_element_grob( elements$text, label = validate_labels(key$.label), x = unit(key$.value, "npc"), - y = rep(just, nrow(key)), margin_x = FALSE, margin_y = TRUE, flip = params$direction == "vertical" diff --git a/R/guide-legend.R b/R/guide-legend.R index 341bee47c8..fe383f81d0 100644 --- a/R/guide-legend.R +++ b/R/guide-legend.R @@ -42,6 +42,10 @@ #' @param keyheight A numeric or a [grid::unit()] object specifying #' the height of the legend key. Default value is `legend.key.height` or #' `legend.key.size` in [theme()]. +#' @param key.spacing,key.spacing.x,key.spacing.y A numeric or [grid::unit()] +#' object specifying the distance between key-label pairs in the horizontal +#' direction (`key.spacing.x`), vertical direction (`key.spacing.y`) or both +#' (`key.spacing`). #' @param direction A character string indicating the direction of the guide. #' One of "horizontal" or "vertical." #' @param default.unit A character string indicating [grid::unit()] @@ -143,6 +147,9 @@ guide_legend <- function( # Key size keywidth = NULL, keyheight = NULL, + key.spacing = NULL, + key.spacing.x = NULL, + key.spacing.y = NULL, # General direction = NULL, @@ -156,12 +163,24 @@ guide_legend <- function( ... ) { # Resolve key sizes - if (!inherits(keywidth, c("NULL", "unit"))) { + if (!(is.null(keywidth) || is.unit(keywidth))) { keywidth <- unit(keywidth, default.unit) } - if (!inherits(keyheight, c("NULL", "unit"))) { + if (!(is.null(keyheight) || is.unit(keyheight))) { keyheight <- unit(keyheight, default.unit) } + + # Resolve spacing + key.spacing.x <- key.spacing.x %||% key.spacing + if (!is.null(key.spacing.x) || is.unit(key.spacing.x)) { + key.spacing.x <- unit(key.spacing.x, default.unit) + } + key.spacing.y <- key.spacing.y %||% key.spacing + if (!is.null(key.spacing.y) || is.unit(key.spacing.y)) { + key.spacing.y <- unit(key.spacing.y, default.unit) + } + + if (!is.null(title.position)) { title.position <- arg_match0(title.position, .trbl) } @@ -187,6 +206,8 @@ guide_legend <- function( # Key size keywidth = keywidth, keyheight = keyheight, + key.spacing.x = key.spacing.x, + key.spacing.y = key.spacing.y, # General direction = direction, @@ -226,9 +247,10 @@ GuideLegend <- ggproto( keywidth = NULL, keyheight = NULL, + key.spacing.x = NULL, + key.spacing.y = NULL, # General - direction = NULL, override.aes = list(), nrow = NULL, ncol = NULL, @@ -249,9 +271,6 @@ GuideLegend <- ggproto( elements = list( background = "legend.background", margin = "legend.margin", - spacing = "legend.spacing", - spacing.x = "legend.spacing.x", - spacing.y = "legend.spacing.y", key = "legend.key", key.height = "legend.key.height", key.width = "legend.key.width", @@ -436,13 +455,35 @@ GuideLegend <- ggproto( elements$text$size %||% 11 gap <- unit(gap * 0.5, "pt") # Should maybe be elements$spacing.{x/y} instead of the theme's spacing? - elements$hgap <- width_cm( theme$legend.spacing.x %||% gap) - elements$vgap <- height_cm(theme$legend.spacing.y %||% gap) + + if (params$direction == "vertical") { + # For backward compatibility, vertical default is no spacing + vgap <- params$key.spacing.y %||% unit(0, "pt") + } else { + vgap <- params$key.spacing.y %||% gap + } + + elements$hgap <- width_cm( params$key.spacing.x %||% gap) + elements$vgap <- height_cm(vgap) elements$padding <- convertUnit( elements$margin %||% margin(), "cm", valueOnly = TRUE ) + # When no explicit margin has been set, either in this guide or in the + # theme, we set a default text margin to leave a small gap in between + # the label and the key. + if (is.null(params$label.theme$margin %||% theme$legend.text$margin) && + !inherits(elements$text, "element_blank")) { + i <- match(params$label.position, .trbl[c(3, 4, 1, 2)]) + elements$text$margin[i] <- elements$text$margin[i] + gap + } + if (is.null(params$title.theme$margin %||% theme$legend.title$margin) && + !inherits(elements$title, "element_blank")) { + i <- match(params$title.position, .trbl[c(3, 4, 1, 2)]) + elements$title$margin[i] <- elements$title$margin[i] + gap + } + # Evaluate backgrounds early if (!is.null(elements$background)) { elements$background <- ggname( @@ -527,22 +568,23 @@ GuideLegend <- ggproto( hgap <- elements$hgap %||% 0 widths <- switch( params$label.position, - "left" = list(label_widths, hgap, widths, hgap), - "right" = list(widths, hgap, label_widths, hgap), - list(pmax(label_widths, widths), hgap * (!byrow)) + "left" = list(label_widths, widths, hgap), + "right" = list(widths, label_widths, hgap), + list(pmax(label_widths, widths), hgap) ) widths <- head(vec_interleave(!!!widths), -1) vgap <- elements$vgap %||% 0 heights <- switch( params$label.position, - "top" = list(label_heights, vgap, heights, vgap), - "bottom" = list(heights, vgap, label_heights, vgap), - list(pmax(label_heights, heights), vgap * (byrow)) + "top" = list(label_heights, heights, vgap), + "bottom" = list(heights, label_heights, vgap), + list(pmax(label_heights, heights), vgap) ) heights <- head(vec_interleave(!!!heights), -1) has_title <- !is.zero(grobs$title) + if (has_title) { # Measure title title_width <- width_cm(grobs$title) @@ -551,14 +593,14 @@ GuideLegend <- ggproto( # Combine title with rest of the sizes based on its position widths <- switch( params$title.position, - "left" = c(title_width, hgap, widths), - "right" = c(widths, hgap, title_width), + "left" = c(title_width, widths), + "right" = c(widths, title_width), c(widths, max(0, title_width - sum(widths))) ) heights <- switch( params$title.position, - "top" = c(title_height, vgap, heights), - "bottom" = c(heights, vgap, title_height), + "top" = c(title_height, heights), + "bottom" = c(heights, title_height), c(heights, max(0, title_height - sum(heights))) ) } @@ -595,20 +637,20 @@ GuideLegend <- ggproto( switch( params$label.position, "top" = { - key_row <- key_row * 2 - label_row <- label_row * 2 - 2 + key_row <- key_row + df$R + label_row <- key_row - 1 }, "bottom" = { - key_row <- key_row * 2 - 2 - label_row <- label_row * 2 + key_row <- key_row + df$R - 1 + label_row <- key_row + 1 }, "left" = { - key_col <- key_col * 2 - label_col <- label_col * 2 - 2 + key_col <- key_col + df$C + label_col <- key_col - 1 }, "right" = { - key_col <- key_col * 2 - 2 - label_col <- label_col * 2 + key_col <- key_col + df$C - 1 + label_col <- key_col + 1 } ) @@ -617,8 +659,8 @@ GuideLegend <- ggproto( switch( params$title.position, "top" = { - key_row <- key_row + 2 - label_row <- label_row + 2 + key_row <- key_row + 1 + label_row <- label_row + 1 title_row <- 2 title_col <- seq_along(sizes$widths) + 1 }, @@ -627,8 +669,8 @@ GuideLegend <- ggproto( title_col <- seq_along(sizes$widths) + 1 }, "left" = { - key_col <- key_col + 2 - label_col <- label_col + 2 + key_col <- key_col + 1 + label_col <- label_col + 1 title_row <- seq_along(sizes$heights) + 1 title_col <- 2 }, diff --git a/man/guide_legend.Rd b/man/guide_legend.Rd index 21dcbe7833..224de5587a 100644 --- a/man/guide_legend.Rd +++ b/man/guide_legend.Rd @@ -17,6 +17,9 @@ guide_legend( label.vjust = NULL, keywidth = NULL, keyheight = NULL, + key.spacing = NULL, + key.spacing.x = NULL, + key.spacing.y = NULL, direction = NULL, default.unit = "line", override.aes = list(), @@ -74,6 +77,11 @@ the width of the legend key. Default value is \code{legend.key.width} or the height of the legend key. Default value is \code{legend.key.height} or \code{legend.key.size} in \code{\link[=theme]{theme()}}.} +\item{key.spacing, key.spacing.x, key.spacing.y}{A numeric or \code{\link[grid:unit]{grid::unit()}} +object specifying the distance between key-label pairs in the horizontal +direction (\code{key.spacing.x}), vertical direction (\code{key.spacing.y}) or both +(\code{key.spacing}).} + \item{direction}{A character string indicating the direction of the guide. One of "horizontal" or "vertical."} diff --git a/tests/testthat/_snaps/guides/legend-with-widely-spaced-keys.svg b/tests/testthat/_snaps/guides/legend-with-widely-spaced-keys.svg new file mode 100644 index 0000000000..ee015eb369 --- /dev/null +++ b/tests/testthat/_snaps/guides/legend-with-widely-spaced-keys.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +10 +15 +20 +25 +30 +35 + + + + + + + + + + +100 +200 +300 +400 +disp +mpg + +factor(carb) + + + + + + + + + + + + +1 +2 +3 +4 +6 +8 +legend with widely spaced keys + + diff --git a/tests/testthat/_snaps/guides/rotated-guide-titles-and-labels.svg b/tests/testthat/_snaps/guides/rotated-guide-titles-and-labels.svg index fa41704f31..875a76f42f 100644 --- a/tests/testthat/_snaps/guides/rotated-guide-titles-and-labels.svg +++ b/tests/testthat/_snaps/guides/rotated-guide-titles-and-labels.svg @@ -79,11 +79,11 @@ -5.0 -7.5 -10.0 -12.5 -15.0 +5.0 +7.5 +10.0 +12.5 +15.0 rotated guide titles and labels diff --git a/tests/testthat/_snaps/guides/vertical-gap-of-1cm-between-guide-title-and-guide.svg b/tests/testthat/_snaps/guides/vertical-gap-of-1cm-between-guide-title-and-guide.svg index a2fd873e1e..73345206cb 100644 --- a/tests/testthat/_snaps/guides/vertical-gap-of-1cm-between-guide-title-and-guide.svg +++ b/tests/testthat/_snaps/guides/vertical-gap-of-1cm-between-guide-title-and-guide.svg @@ -55,35 +55,35 @@ 3.0 x y - -y - - - - - - - - - - - -1.0 -1.5 -2.0 -2.5 -3.0 - -factor(x) - - - - - - -1 -2 -3 + +y + + + + + + + + + + + +1.0 +1.5 +2.0 +2.5 +3.0 + +factor(x) + + + + + + +1 +2 +3 vertical gap of 1cm between guide title and guide diff --git a/tests/testthat/test-guides.R b/tests/testthat/test-guides.R index 2ea4d3a696..0d029c8b1b 100644 --- a/tests/testthat/test-guides.R +++ b/tests/testthat/test-guides.R @@ -338,6 +338,16 @@ test_that("guide_colourbar warns about discrete scales", { }) +test_that("guide_legend uses key.spacing correctly", { + p <- ggplot(mtcars, aes(disp, mpg, colour = factor(carb))) + + geom_point() + + guides(colour = guide_legend( + ncol = 2, key.spacing.y = 1, key.spacing.x = 2 + )) + + expect_doppelganger("legend with widely spaced keys", p) +}) + # Visual tests ------------------------------------------------------------ test_that("axis guides are drawn correctly", { @@ -636,10 +646,10 @@ test_that("guides title and text are positioned correctly", { scale_fill_continuous(name = "the\ncontinuous\ncolorscale") ) expect_doppelganger("vertical gap of 1cm between guide title and guide", - p + theme(legend.spacing.y = grid::unit(1, "cm")) + p + theme(legend.title = element_text(margin = margin(b = 1, unit = "cm"))) ) expect_doppelganger("horizontal gap of 1cm between guide and guide text", - p + theme(legend.spacing.x = grid::unit(1, "cm")) + p + theme(legend.text = element_text(margin = margin(l = 1, unit = "cm"))) ) # now test label positioning, alignment, etc @@ -654,8 +664,8 @@ test_that("guides title and text are positioned correctly", { expect_doppelganger("guide title and text positioning and alignment via themes", p + theme( - legend.title = element_text(hjust = 0.5, margin = margin(t = 30)), - legend.text = element_text(hjust = 1, margin = margin(l = 5, t = 10, b = 10)) + legend.title = element_text(hjust = 0.5, margin = margin(t = 30, b = 5.5)), + legend.text = element_text(hjust = 1, margin = margin(l = 10.5, t = 10, b = 10)) ) )