From cc01027c04ad255a47b12fd9b3283d0c38bbf1fc Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Sat, 26 Oct 2024 12:43:19 +0200 Subject: [PATCH] Bring back legends (#27) * draft base legend * add base legend tests * document `guide_legend_base()` * rename file * text setup can take position directly * export GuideLegendBase * stabilise grabbing attributes * implement `guide_legend_cross()` * add tests for cross legend * replace `dim_order` by `swap` * document cross legend * draft `key_split_group` * lut key * add lut group test * small utility * reintroduce guide_legend_group * add group legend tests * add `legendry.group.spacing` as element * small tweaks * document --- NAMESPACE | 7 + R/compose-crux.R | 4 +- R/guide-legend-base.R | 393 ++++++++++++++++++ R/guide-legend-cross.R | 367 ++++++++++++++++ R/guide-legend-group.R | 386 +++++++++++++++++ R/guide_colring.R | 4 +- R/key-group.R | 146 +++++++ R/legendry-package.R | 4 + R/primitive-.R | 4 +- R/primitive-box.R | 4 +- R/primitive-fence.R | 4 +- R/primitive-labels.R | 2 +- R/primitive-title.R | 2 +- R/themes.R | 8 +- R/utils-checks.R | 24 ++ R/utils-text.R | 17 +- R/utils.R | 24 +- man/common_parameters.Rd | 4 + man/guide_axis_base.Rd | 5 +- man/guide_axis_nested.Rd | 5 +- man/guide_colbar.Rd | 5 +- man/guide_colring.Rd | 5 +- man/guide_colsteps.Rd | 5 +- man/guide_legend_base.Rd | 121 ++++++ man/guide_legend_cross.Rd | 122 ++++++ man/guide_legend_group.Rd | 112 +++++ man/key_group.Rd | 77 ++++ man/key_range.Rd | 1 + man/key_specialty.Rd | 1 + man/key_standard.Rd | 1 + man/legendry_extensions.Rd | 11 +- man/theme_guide.Rd | 5 +- .../legend-cross-orientations.svg | 338 +++++++++++++++ .../legend-cross-single-scale.svg | 80 ++++ .../legend-cross-two-scales-swapped-order.svg | 81 ++++ .../legend-cross-two-scales.svg | 81 ++++ .../legend-cross-with-double-reverse.svg | 80 ++++ .../guide-legend-group/bottom-bottomtitle.svg | 91 ++++ .../guide-legend-group/bottom-lefttitle.svg | 91 ++++ .../guide-legend-group/bottom-righttitle.svg | 91 ++++ .../guide-legend-group/bottom-toptitle.svg | 91 ++++ .../guide-legend-group/right-bottomtitle.svg | 91 ++++ .../guide-legend-group/right-lefttitle.svg | 91 ++++ .../guide-legend-group/right-righttitle.svg | 91 ++++ .../guide-legend-group/right-toptitle.svg | 91 ++++ .../custom-legend-design.svg | 95 +++++ .../standard-legend-design.svg | 95 +++++ tests/testthat/_snaps/key-group.md | 9 + tests/testthat/test-guide-legend-cross.R | 154 +++++++ tests/testthat/test-guide-legend-group.R | 82 ++++ tests/testthat/test-guide_legend_base.R | 102 +++++ tests/testthat/test-key-group.R | 72 ++++ 52 files changed, 3843 insertions(+), 34 deletions(-) create mode 100644 R/guide-legend-base.R create mode 100644 R/guide-legend-cross.R create mode 100644 R/guide-legend-group.R create mode 100644 R/key-group.R create mode 100644 man/guide_legend_base.Rd create mode 100644 man/guide_legend_cross.Rd create mode 100644 man/guide_legend_group.Rd create mode 100644 man/key_group.Rd create mode 100644 tests/testthat/_snaps/guide-legend-cross/legend-cross-orientations.svg create mode 100644 tests/testthat/_snaps/guide-legend-cross/legend-cross-single-scale.svg create mode 100644 tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales-swapped-order.svg create mode 100644 tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales.svg create mode 100644 tests/testthat/_snaps/guide-legend-cross/legend-cross-with-double-reverse.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/bottom-bottomtitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/bottom-lefttitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/bottom-righttitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/bottom-toptitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/right-bottomtitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/right-lefttitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/right-righttitle.svg create mode 100644 tests/testthat/_snaps/guide-legend-group/right-toptitle.svg create mode 100644 tests/testthat/_snaps/guide_legend_base/custom-legend-design.svg create mode 100644 tests/testthat/_snaps/guide_legend_base/standard-legend-design.svg create mode 100644 tests/testthat/_snaps/key-group.md create mode 100644 tests/testthat/test-guide-legend-cross.R create mode 100644 tests/testthat/test-guide-legend-group.R create mode 100644 tests/testthat/test-guide_legend_base.R create mode 100644 tests/testthat/test-key-group.R diff --git a/NAMESPACE b/NAMESPACE index 3c7970e..5699443 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,6 +11,8 @@ export(GizmoGrob) export(GizmoHistogram) export(GizmoStepcap) export(GuideColring) +export(GuideLegendBase) +export(GuideLegendGroup) export(PrimitiveBox) export(PrimitiveBracket) export(PrimitiveFence) @@ -45,8 +47,13 @@ export(guide_axis_nested) export(guide_colbar) export(guide_colring) export(guide_colsteps) +export(guide_legend_base) +export(guide_legend_cross) +export(guide_legend_group) export(key_auto) export(key_bins) +export(key_group_lut) +export(key_group_split) export(key_log) export(key_manual) export(key_map) diff --git a/R/compose-crux.R b/R/compose-crux.R index e45b42c..5382675 100644 --- a/R/compose-crux.R +++ b/R/compose-crux.R @@ -105,7 +105,7 @@ ComposeCrux <- ggproto( ), setup_elements = function(params, elements, theme) { - elements$title <- setup_legend_title(theme, params$direction) + elements$title <- setup_legend_title(theme, direction = params$direction) theme <- theme + params$theme Guide$setup_elements(params, elements, theme) }, @@ -204,7 +204,7 @@ ComposeCrux <- ggproto( gt, title = self$build_title(params$title, elems, params), position = elems$title_position, - with(elems$title, rotate_just(angle, hjust, vjust)) + get_just(elems$title) ) if (!is.null(elems$margin)) { gt <- gtable_add_padding(gt, elems$margin) diff --git a/R/guide-legend-base.R b/R/guide-legend-base.R new file mode 100644 index 0000000..8ae560d --- /dev/null +++ b/R/guide-legend-base.R @@ -0,0 +1,393 @@ +# Constructor ------------------------------------------------------------- + +#' Custom legend guide +#' +#' This legend closely mirrors `ggplot2::guide_legend()`, but has two +#' adjustments. First, `guide_legend_base()` supports a `design` argument +#' for a more flexible layout. Secondly, the `legend.spacing.y` theme element +#' is observed verbatim instead of overruled. +#' +#' @param key A [standard key][key_standard] specification. Defaults to +#' [`key_auto()`]. See more information in the linked topic. +#' @param design Specification of the legend layout. One of the following: +#' * `NULL` (default) to use the layout algorithm of +#' [`guide_legend()`][ggplot2::guide_legend()]. +#' * A `` string representing a cell layout wherein `#` defines +#' an empty cell. See examples. +#' * A `` representing a cell layout wherein `NA` defines an +#' empty cell. See examples. Non-string atomic vectors will be treated with +#' `as.matrix()`. +#' @param nrow,ncol A positive `` setting the desired dimensions of +#' the legend layout. When `NULL` (default), the dimensions will be derived +#' from the `design` argument or fit to match the number of keys. +#' @param reverse A `` whether the order of keys should be inverted. +#' @inheritParams common_parameters +#' +#' @return A `` object. +#' @export +#' @family standalone guides +#' @family legend guides +#' +#' @examples +#' # A dummy plot +#' p <- ggplot(data.frame(x = 1:3, type = c("tic", "tac", "toe"))) + +#' aes(x, x, shape = type) + +#' geom_point(na.rm = TRUE) + +#' scale_shape_manual(values = c(1, 4, NA)) +#' +#' # A design string, each character giving a cell value. +#' # Newlines separate rows, white space is ignored. +#' design <- " +#' 123 +#' 213 +#' 321 +#' " +#' +#' # Alternatively, the same can be specified using a matrix directly +#' # design <- matrix(c(1, 2, 3, 2, 1, 3, 3, 2, 1), 3, 3, byrow = TRUE) +#' +#' p + guides(shape = guide_legend_base(design = design)) +#' +#' # Empty cells can be created using `#` +#' design <- " +#' #2# +#' 1#3 +#' " +#' +#' # Alternatively: +#' # design <- matrix(c(NA, 1, 2, NA, NA, 3), nrow = 2) +#' +#' p + guides(shape = guide_legend_base(design = design)) +guide_legend_base <- function( + key = NULL, + title = waiver(), + theme = NULL, + design = NULL, + nrow = NULL, + ncol = NULL, + reverse = FALSE, + override.aes = list(), + position = NULL, + direction = NULL, + order = 0 +) { + + check_position(position, allow_null = TRUE) + check_argmatch(direction, c("horizontal", "vertical"), allow_null = TRUE) + check_bool(reverse) + check_number_whole(nrow, min = 1, allow_null = TRUE) + check_number_whole(ncol, min = 1, allow_null = TRUE) + + design <- validate_design(design, allow_null = TRUE) + if (!is.null(design)) { + ignored <- c( + if (!is.null(nrow)) "nrow", + if (!is.null(ncol)) "ncol" + ) + if (length(ignored) > 0) { + cli::cli_warn( + "The {.and {.arg {ignored}}} argument{?s} {?is/are} ignored \\ + when the {.arg design} argument is provided." + ) + } + nrow <- NULL + ncol <- NULL + } + + new_guide( + key = key, + title = title, + design = design, + nrow = nrow, + ncol = ncol, + override.aes = override.aes, + reverse = reverse, + theme = theme, + position = position, + direction = direction, + order = order, + super = GuideLegendBase + ) +} + +# Class ------------------------------------------------------------------- + +#' @export +#' @rdname legendry_extensions +#' @format NULL +#' @usage NULL +GuideLegendBase <- ggproto( + "GuideLegendBase", GuideLegend, + + params = new_params( + override.aes = list(), reverse = FALSE, + key = NULL, nrow = NULL, ncol = NULL, design = NULL + ), + + extract_key = standard_extract_key, + + draw = function(self, theme, position = NULL, direction = NULL, + params = self$params) { + # We ensure we know the 'byrow' setting from the beginning + params$byrow <- params$theme$legend.byrow %||% theme$legend.byrow %||% FALSE + ggproto_parent(Guide, self)$draw( + theme = theme, position = position, + direction = direction, params = params + ) + }, + + setup_params = function(params) { + params$direction <- arg_match0( + params$direction, + c("horizontal", "vertical"), + arg_nm = "direction" + ) + params$n_breaks <- nrow(params$key) + # We embed the design into the key as `.row`/`.col` columns + params$key <- apply_design( + params$key, params$design, + params$nrow, params$ncol, + params$direction, params$byrow + ) + params + }, + + setup_elements = function(params, elements, theme) { + + theme <- theme + params$theme + params$theme <- NULL + + text_position <- theme$legend.text.position %||% "right" + elements$text <- setup_legend_text(theme, text_position) + + title_position <- theme$legend.title.position %||% + switch(params$direction, vertical = "top", horizontal = "left") + elements$title <- setup_legend_title(theme, title_position) + + elements <- Guide$setup_elements(params, elements, theme) + elements[c("text_position", "title_position")] <- + list(text_position, title_position) + elements + }, + + build_decor = function(decor, grobs, elements, params) { + decor <- render_legend_glyphs( + index = seq_len(params$n_breaks), + decor = decor, background = elements$key, + default_size = c(elements$width_cm, elements$height_cm) * 10 + ) + decor <- decor[params$key$.index] + names(decor) <- paste("key", params$key$.row, params$key$.col, sep = "-") + decor + }, + + measure_grobs = function(grobs, params, elements) { + + # Get width of keys per column + col <- params$key$.col + widths <- map_dbl(grobs$decor, `[[`, i = "width") + widths <- pmax(by_group(widths, col, max), elements$width_cm) + + # Weave in width of labels, depending on label position + label_widths <- by_group(width_cm(grobs$labels), col, max) + widths <- switch( + elements$text_position, + left = list(label_widths, widths), + right = list(widths, label_widths), + list(pmax(label_widths, widths)) + ) + widths <- vec_interleave(!!!widths, elements$spacing_x %||% 0) + widths <- widths[-length(widths)] # Remove last spacer + + # Get height of keys per row + row <- params$key$.row + heights <- map_dbl(grobs$decor, `[[`, i = "height") + heights <- pmax(by_group(heights, row, max), elements$height_cm) + + # Weave in height of labels, depending on label position + label_heights <- by_group(height_cm(grobs$labels), row, max) + heights <- switch( + elements$text_position, + top = list(label_heights, heights), + bottom = list(heights, label_heights), + list(pmax(label_heights, heights)) + ) + heights <- vec_interleave(!!!heights, elements$spacing_y %||% 0) + heights <- heights[-length(heights)] # Remove last spacer + + list(widths = widths, heights = heights) + }, + + arrange_layout = function(key, sizes, params, elements) { + + row <- key$.row + col <- key$.col + + # Account for spacing in between keys + key_row <- row * 2 - 1 + key_col <- col * 2 - 1 + + # Resolve position of labels relative to keys + position <- elements$text_position + key_row <- key_row + switch(position, top = row, bottom = row - 1, 0) + lab_row <- key_row + switch(position, top = -1, bottom = 1, 0) + key_col <- key_col + switch(position, left = col, right = col - 1, 0) + lab_col <- key_col + switch(position, left = -1, right = 1, 0) + + data_frame0( + key_row = key_row, + key_col = key_col, + label_row = lab_row, + label_col = lab_col + ) + } +) + +# Helpers ----------------------------------------------------------------- + +render_legend_glyphs <- function(index, decor, background, default_size) { + lapply(index, function(i) { + glyphs <- lapply(decor, function(dec) { + data <- vec_slice(dec$data, i) + if (!(data$.draw %||% TRUE)) { + return(zeroGrob()) + } + key <- dec$draw_key(data, dec$params, default_size) + set_key_size(key, data$linewidth, data$size, default_size / 10) + }) + gTree( + width = max(map_dbl(glyphs, get_width_attr), 0, na.rm = TRUE), + height = max(map_dbl(glyphs, get_height_attr), 0, na.rm = TRUE), + children = inject(gList(background, !!!glyphs)) + ) + }) +} + +set_key_size <- function(key, lwd = NULL, size = NULL, default = NULL) { + width <- get_width_attr(key, default = NULL) + height <- get_height_attr(key, default = NULL) + if (!is.null(width) && !is.null(height)) { + return(key) + } + if (!is.null(size) || !is.null(lwd)) { + size <- size[1] %||% 0 %|NA|% 0 + lwd <- lwd[1] %||% 0 %|NA|% 0 + size <- (size + lwd) / 10 + } else { + size <- NULL + } + attr(key, "width") <- width %||% size %||% default[1] + attr(key, "height") <- height %||% size %||% default[2] + key +} + +apply_design <- function( + key, design = NULL, nrow = NULL, ncol = NULL, + direction = "horizontal", byrow = FALSE +) { + n_breaks <- nrow(key) + + # Handle case where there is no design, à la ggplot2::guide_legend + if (is.null(design)) { + if (is.null(nrow) && is.null(ncol)) { + if (direction == "horizontal") { + nrow <- ceiling(n_breaks / 5) + } else { + ncol <- ceiling(n_breaks / 20) + } + } + nrow <- nrow %||% ceiling(n_breaks / ncol) + ncol <- ncol %||% ceiling(n_breaks / nrow) + + design <- matrix( + seq_len(nrow * ncol), + nrow = nrow, ncol = ncol, + byrow = byrow + ) + } + + max_design <- max(design, na.rm = TRUE) + if (isTRUE(max_design < n_breaks)) { + cause <- if (is.null(design)) { + "{.arg nrow} * {.arg ncol} ({nrow * ncol}) is insufficient " + } else { + "The {.arg design} argument has insufficient levels " + } + cli::cli_warn( + paste0(cause, "to accommodate the number of breaks ({n_breaks}).") + ) + } + + key$.index <- seq_len(nrow(key)) + + index <- match(design, key$.index) + rows <- as.vector(row(design)) + cols <- as.vector(col(design)) + + key <- vec_slice(key, index) + key$.row <- rows + key$.col <- cols + vec_slice(key, !is.na(key$.index)) +} + +validate_design <- function(design = NULL, trim = TRUE, allow_null = TRUE) { + if (is.null(design)) { + if (allow_null) { + return(NULL) + } + cli::cli_abort("The {.arg design} argument cannot be {.code NULL}.") + } + design <- parse_design_character(design) + if (!is.matrix(design) && is.atomic(design)) { + design <- as.matrix(design) + } + check_matrix(design) + if (typeof(design) == "character") { + design[design == "#"] <- NA + } + levels <- unique(sort(design)) + design <- matrix( + match(design, levels), + nrow = nrow(design), + ncol = ncol(design) + ) + + if (trim) { + filled <- !is.na(design) + design <- design[rowSums(filled) > 0, colSums(filled) > 0, drop = FALSE] + } + + if (!is.numeric(levels)) { + attr(design, "levels") <- levels + } + + design +} + +parse_design_character <- function(design, call = caller_env()) { + + if (!is.character(design)) { + return(design) + } + + # Check is here to ensure scalar character + check_string(design, allow_empty = FALSE, call = call) + + # Inspired by patchwork::as_areas() + design <- trimws(strsplit(design, "\n")[[1]]) + design <- strsplit(design[nzchar(design)], "") + + nrow <- length(design) + ncol <- lengths(design) + if (length(unique(ncol)) != 1L) { + cli::cli_abort( + "The {.arg design} argument must be rectangular.", + call = call + ) + } + + matrix( + unlist(design, FALSE, FALSE), + nrow = nrow, ncol = ncol[1], byrow = TRUE + ) +} diff --git a/R/guide-legend-cross.R b/R/guide-legend-cross.R new file mode 100644 index 0000000..82ce59a --- /dev/null +++ b/R/guide-legend-cross.R @@ -0,0 +1,367 @@ +# Constructor ------------------------------------------------------------- + +#' Cross legend guide +#' +#' This is a legend type similar to [`guide_legend()`][ggplot2::guide_legend()] +#' that displays crosses, or: interactions, between two variables. +#' +#' @param key One of the following key specifications: +#' * A [group split][key_group_split] specification when using the legend to +#' display a compound variable like `paste(var1, var2)`. +#' * A [standard key][key_standard] specification, like [`key_auto()`], when +#' crossing two separate variables across two scales. +#' @param swap A `` which when `TRUE` exchanges the column and row +#' variables in the displayed legend. +#' @param col_text An `` object giving adjustments to text for +#' the column labels. Can be `NULL` to display column labels in equal fashion +#' to the row labels. +#' @param reverse A `` whether the order of the keys should be +#' inverted, where the first value controls the row order and second value +#' the column order. Input as `` will be recycled. +#' @inheritParams common_parameters +#' +#' @return A `` object. +#' @export +#' @family standalone guides +#' @family legend guides +#' +#' @examples +#' # Standard use for single aesthetic. The default is to split labels to +#' # disentangle aesthetics that are already crossed (by e.g. `paste()`) +#' ggplot(mpg, aes(displ, hwy)) + +#' geom_point(aes(colour = paste(year, drv))) + +#' guides(colour = "legend_cross") +#' +#' # If legends should be merged between identical aesthetics, both need the +#' # same legend type. +#' ggplot(mpg, aes(displ, hwy)) + +#' geom_point(aes(colour = paste(year, drv), shape = paste(year, drv))) + +#' guides(colour = "legend_cross", shape = "legend_cross") +#' +#' # Crossing two aesthetics requires a shared title and `key = "auto"`. The +#' # easy way to achieve this is to predefine a shared guide. +#' my_guide <- guide_legend_cross(key = "auto", title = "My title") +#' +#' ggplot(mpg, aes(displ, hwy)) + +#' geom_point(aes(colour = drv, shape = factor(year))) + +#' guides(colour = my_guide, shape = my_guide) +#' +#' # You can cross more than 2 aesthetics but not more than 2 unique aesthetics. +#' ggplot(mpg, aes(displ, hwy)) + +#' geom_point(aes(colour = drv, shape = factor(year), size = factor(drv))) + +#' scale_size_ordinal() + +#' guides(colour = my_guide, shape = my_guide, size = my_guide) +#' +#' # You can merge an aesthetic that is already crossed with an aesthetic that +#' # contributes to only one side of the cross. +#' ggplot(mpg, aes(displ, hwy)) + +#' geom_point(aes(colour = paste(year, drv), shape = drv)) + +#' guides( +#' colour = guide_legend_cross(title = "My Title"), +#' shape = guide_legend_cross(title = "My Title", key = "auto") +#' ) +guide_legend_cross <- function( + key = NULL, + title = waiver(), + swap = FALSE, + col_text = element_text(angle = 90, vjust = 0.5), + override.aes = list(), + reverse = FALSE, + theme = NULL, + position = NULL, + direction = NULL, + order = 0 +) { + + check_position(position, allow_null = TRUE) + check_argmatch(direction, c("horizontal", "vertical"), allow_null = TRUE) + check_bool(swap) + + if (length(reverse) == 1L) { + check_bool(reverse) + } else { + check_length(reverse, exact = 2) + check_bool(reverse[1]) + check_bool(reverse[2]) + } + + dim_order <- if (swap) c("col", "row") else c("row", "col") + + new_guide( + key = key, + title = title, + dim_order = dim_order, + override.aes = override.aes, + col_text = col_text, + reverse = reverse, + theme = theme, + position = position, + direction = direction, + order = order, + super = GuideLegendCross + ) +} + +# Class ------------------------------------------------------------------- + +GuideLegendCross <- ggproto( + + "GuideLegendCross", GuideLegendBase, + + params = new_params( + override.aes = list(), reverse = FALSE, + key = NULL, dim_order = c("row", "col"), + col_text = NULL + ), + + hashables = exprs(title, "GuideLegendCross"), + + extract_key = function(scale, aesthetic, key = NULL, + dim_order = c("row", "col"), ...) { + + key <- standard_extract_key(scale, aesthetic, key %||% "group_split", ...) + grouping <- c(".label", ".group") + + # If we don't have grouping columns yet, + # we cannot start filling in the layout + if (!all(grouping %in% names(key))) { + return(key) + } + + # Start filling in layout, to be finalised later + row <- unique0(key[grouping[dim_order == "row"]][[1]]) + col <- unique0(key[grouping[dim_order == "col"]][[1]]) + grid <- vec_expand_grid(row = row, col = col) + + # Repeat key to match layout + i <- vec_match(grid, rename(key[grouping], grouping, dim_order)) + key <- vec_slice(key, i) + + # Fill in locations + key$.row_label <- grid$row + key$.col_label <- grid$col + key + }, + + merge = function(self, params, new_guide, new_params) { + + old_key <- params$key + new_key <- new_params$key + + columns <- c(".row_label", ".col_label") + old_ready <- all(columns %in% names(old_key)) + new_ready <- all(columns %in% names(new_key)) + + if (!old_ready) { + params$key <- cross_merge_incomplete(old_key, new_key, params$dim_order) + } else if (new_ready) { + params$key <- cross_merge_complete(old_key, new_key) + } else { + params$key <- cross_merge_partial(old_key, new_key) + } + + params$override.aes <- + merge_legend_override(params$override.aes, new_params$override.aes) + list(guide = self, params = params) + }, + + setup_params = function(params) { + + key <- params$key + params$n_breaks <- n_breaks <- nrow(key) + + key$.index <- vec_seq_along(key) + key$.row <- match_self(key$.row_label %||% seq_len(n_breaks)) + key$.col <- match_self(key$.col_label %||% rep_len(1, n_breaks)) + + reverse <- rep_len(params$reverse, 2L) + if (reverse[1]) { + nrows <- max(key$.row, na.rm = TRUE) + key$.row <- nrows - key$.row + 1L + } + if (reverse[2]) { + ncols <- max(key$.col, na.rm = TRUE) + key$.col <- ncols - key$.col + 1L + } + + params$key <- key + params + }, + + setup_elements = function(params, elements, theme) { + + theme <- theme + params$theme + params$theme <- NULL + + text_position <- theme$legend.text.position %||% "right" + title_position <- theme$legend.title.position %||% + switch(params$direction, vertical = "top", horizontal = "left") + elements$title <- setup_legend_title(theme, title_position) + + # Resolve text positions + row <- intersect(c("right", "left"), text_position)[1] %|NA|% "right" + col <- intersect(c("top", "bottom"), text_position)[1] %|NA|% "bottom" + + # Resolve text theming + elements$text_row <- setup_legend_text(theme, row) + elements$text_col <- combine_elements( + params$col_text, + setup_legend_text(theme, col) + ) + + elements <- Guide$setup_elements(params, elements, theme) + elements[c("row_position", "col_position")] <- list(row, col) + elements$title_position <- title_position + elements + }, + + build_labels = function(key, elements, params) { + + # Render row labels first + rows <- vec_slice(key, !duplicated(key$.row)) + rows$.label <- rows$.row_label %||% rows[[".label"]] + rows <- GuideLegendBase$build_labels(rows, list(text = elements[["text_row"]]), params) + + # Then column labels follow + cols <- vec_slice(key, !duplicated(key$.col)) + cols$.label <- cols$.col_label %||% cols[[".label"]] + cols <- GuideLegendBase$build_labels(cols, list(text = elements[["text_col"]]), params) + + # We don't combine them yet, as they need to be measured separately later + list(rows = rows, cols = cols) + }, + + measure_grobs = function(grobs, params, elements) { + + # Get width of keys per column + col <- params$key[[".col"]] + widths <- map_dbl(grobs$decor, `[[`, i = "width") + widths <- pmax(by_group(widths, col, max), elements$width_cm) + + # Weave in width of labels + widths <- pmax(widths, width_cm(grobs$labels$cols)) + widths <- vec_interleave(elements$spacing_x %||% 0, widths)[-1] + label_width <- max(width_cm(grobs$labels$rows)) + widths <- switch( + elements$row_position, + left = c(label_width, widths), + c(widths, label_width) + ) + + # Get height of keys per row + row <- params$key[[".row"]] + heights <- map_dbl(grobs$decor, `[[`, i = "height") + heights <- pmax(by_group(heights, row, max), elements$height_cm) + + # Weave in heights of labels + heights <- pmax(heights, height_cm(grobs$labels$rows)) + heights <- vec_interleave(elements$spacing_y %||% 0, heights)[-1] + label_height <- max(height_cm(grobs$labels$cols)) + heights <- switch( + elements$col_position, + top = c(label_height, heights), + c(heights, label_height) + ) + + list(widths = widths, heights = heights) + }, + + arrange_layout = function(key, sizes, params, elements) { + + # Account for spacing in between keys + key_row <- key[[".row"]] * 2 - 1 + key_col <- key[[".col"]] * 2 - 1 + + lab_row <- max(key_row) + 1 + lab_col <- max(key_col) + 1 + + if (elements$row_position == "left") { + key_col <- key_col + 1 + lab_col <- 1 + } + + if (elements$col_position == "top") { + key_row <- key_row + 1 + lab_row <- 1 + } + + cols <- unique(key_col) + rows <- unique(key_row) + + lab_row <- c(rows, rep(lab_row, length(cols))) + lab_col <- c(rep(lab_col, length(rows)), cols) + + list( + key_row = key_row, key_col = key_col, + label_row = lab_row, label_col = lab_col + ) + }, + + assemble_drawing = function(self, grobs, layout, sizes, params, elements) { + grobs$labels <- c(grobs$labels$rows, grobs$labels$cols) + GuideLegendBase$assemble_drawing(grobs, layout, sizes, params, elements) + } +) + + +# Helpers ----------------------------------------------------------------- + +cross_merge_complete <- function(old, new) { + columns <- c(".row_label", ".col_label") + if (!identical(old[columns], new[columns])) { + old_aes <- colnames(old)[!startsWith(colnames(old), ".")] + new_aes <- colnames(new)[!startsWith(colnames(new), ".")] + cli::cli_abort( + "Cannot merge legends for {.field {old_aes}} and {.field {new_aes}}." + ) + } + data_frame0(!!!defaults(old, new)) +} + +cross_merge_partial <- function(old, new) { + new_aes <- colnames(new)[!startsWith(colnames(new), ".")] + + row_match <- match(old$.row_label, new$.label) + col_match <- match(old$.col_label, new$.label) + + index <- if (!anyNA(row_match)) row_match else col_match + if (anyNA(index)) { + old_aes <- colnames(old)[!startsWith(colnames(old), ".")] + cli::cli_abort(c( + "Cannot match legend for {.field {new_aes}} aesthetic{?s}.", + i = "The labels mismatch those of the {.field {old_aes}} aesthetic{?s}." + )) + } + old[new_aes] <- new[index, new_aes] + old +} + +cross_merge_incomplete <- function(old, new, order = c("row", "col")) { + if (identical(old$.label, new$.label)) { + return(data_frame0(!!!defaults(old, new))) + } + + grid <- vec_expand_grid( + old = vec_seq_along(old), + new = vec_seq_along(new) + ) + + old <- vec_slice(old, grid$old) + new <- vec_slice(new, grid$new) + + order <- paste0(".", order, "_label") + old[order[1]] <- old$.label + new[order[2]] <- new$.label + data_frame0(!!!defaults(old, new)) +} + +merge_legend_override <- function(old, new) { + new <- c(old, new) + dup <- duplicated(names(new)) + if (any(dup)) { + cli::cli_warn( + "Duplicated {.arg override.aes} are ignored: {.field {names(new)[dup]}}." + ) + } + vec_slice(new, !dup) +} diff --git a/R/guide-legend-group.R b/R/guide-legend-group.R new file mode 100644 index 0000000..da61fac --- /dev/null +++ b/R/guide-legend-group.R @@ -0,0 +1,386 @@ +# Constructor ------------------------------------------------------------- + +#' Grouped legend +#' +#' This legend resembles `ggplot2::guide_legend()`, but has the ability to +#' keep groups in blocks with their own titles. +#' +#' @param key A [group key][key_group] specification. Defaults to +#' `key_group_split()` to split labels to find groups. +#' @param nrow,ncol A positive `` setting the desired dimensions of +#' the legend layout. Either `nrow` or `ncol` can be set, but not both, +#' @inheritParams common_parameters +#' +#' @return A `` object. +#' @export +#' @family standalone guides +#' @family legend guides +#' +#' @examples +#' # Standard plot for selection of `msleep` +#' df <- msleep[c(9, 28, 11, 5, 34, 54, 64, 24, 53), ] +#' +#' p <- ggplot(df) + +#' aes(bodywt, awake, colour = paste(order, name)) + +#' geom_point() +#' +#' # By default, groups are inferred from the name +#' p + guides(colour = "legend_group") +#' +#' # You can also use a look-up table for groups +#' # The lookup table can be more expansive than just the data: +#' # We're using the full 'msleep' data here instead of the subset +#' lut <- key_group_lut(msleep$name, msleep$order) +#' +#' p + aes(colour = name) + +#' guides(colour = guide_legend_group(key = lut)) +#' +#' # `nrow` and `ncol` apply within groups +#' p + guides(colour = guide_legend_group(nrow = 1)) +#' +#' # Groups are arranged according to `direction` +#' p + guides(colour = guide_legend_group(ncol = 1, direction = "horizontal")) + +#' theme(legend.title.position = "top") +#' +#' # Customising the group titles +#' p + guides(colour = "legend_group") + +#' theme( +#' legendry.legend.subtitle.position = "left", +#' legendry.legend.subtitle = element_text( +#' hjust = 1, vjust = 1, size = rel(0.9), +#' margin = margin(t = 5.5, r = 5.5) +#' ) +#' ) +#' +#' # Changing the spacing between groups +#' p + guides(colour = "legend_group") + +#' theme(legendry.group.spacing = unit(0, "cm")) +guide_legend_group <- function( + key = "group_split", + title = waiver(), + override.aes = list(), + nrow = NULL, + ncol = NULL, + theme = NULL, + position = NULL, + direction = NULL, + order = 0 +) { + + check_position(position, allow_null = TRUE) + check_argmatch(direction, c("horizontal", "vertical"), allow_null = TRUE) + check_number_whole(nrow, min = 1, allow_null = TRUE) + check_number_whole(ncol, min = 1, allow_null = TRUE) + check_exclusive(nrow, ncol) + + new_guide( + key = key, + title = title, + theme = theme, + override.aes = override.aes, + nrow = nrow, + ncol = ncol, + order = order, + available_aes = "any", + name = "legend_group", + direction = direction, + position = position, + super = GuideLegendGroup + ) +} + +# Class ------------------------------------------------------------------- + +#' @export +#' @rdname legendry_extensions +#' @format NULL +#' @usage NULL +GuideLegendGroup <- ggproto( + "GuideLegendGroup", GuideLegendBase, + + elements = list2( + !!!GuideLegendBase$elements, + subtitle_spacing = "legendry.group.spacing", + subtitle = "legendry.legend.subtitle", + subtitle_position = "legendry.legend.subtitle.position" + ), + + setup_params = function(params) { + params$direction <- direction <- arg_match0( + params$direction, + c("horizontal", "vertical"), + arg_nm = "direction" + ) + params$n_breaks <- nrow(params$key) + params$groups <- + group_design(params$key, params$nrow, params$ncol, direction) + params$key <- + apply_group_design(params$key, params$groups, direction, params$byrow) + params + }, + + setup_elements = function(params, elements, theme) { + theme <- theme + params$theme + params$theme <- NULL + + subtitle_position <- theme$legendry.legend.subtitle.position %||% "top" + elements$subtitle <- + setup_legend_title(theme, subtitle_position, element = elements$subtitle) + + elements <- GuideLegendBase$setup_elements(params, elements, theme) + elements$subtitle_position <- subtitle_position + elements + }, + + override_elements = function(params, elements, theme) { + elements <- GuideLegendBase$override_elements(params, elements, theme) + elements$subtitle_spacing <- convertUnit( + elements$subtitle_spacing %||% unit(0, "cm"), + "cm", valueOnly = TRUE + ) + elements + }, + + build_title = function(label, elements, params) { + main <- Guide$build_title(label, elements, params) + subtitles <- lapply( + params$groups$key, + function(lab) { + sub <- element_grob( + elements$subtitle, label = lab, + margin_x = TRUE, margin_y = TRUE + ) + sub$name <- grobName(sub, "guide.subtitle") + sub + } + ) + list(main = main, subtitles = subtitles) + }, + + measure_grobs = function(grobs, params, elements) { + measures <- GuideLegendBase$measure_grobs(grobs, params, elements) + measures$sub_widths <- width_cm( grobs$title$subtitles) + measures$sub_heights <- height_cm(grobs$title$subtitles) + measures + }, + + arrange_layout = function(key, sizes, params, elements) { + + layout <- GuideLegendBase$arrange_layout(key, sizes, params, elements) + + group <- as.integer(key$.group) + + key_row <- layout$key_row + key_col <- layout$key_col + lab_row <- layout$label_row + lab_col <- layout$label_col + + t <- by_group(pmin(key_row, lab_row), group, min) + b <- by_group(pmax(key_row, lab_row), group, max) + l <- by_group(pmin(key_col, lab_col), group, min) + r <- by_group(pmax(key_col, lab_col), group, max) + + widths <- sizes$widths + heights <- sizes$heights + sub_width <- by_group(sizes$sub_widths, l, max) + sub_height <- by_group(sizes$sub_heights, t, max) + spacing <- elements$subtitle_spacing + + position <- elements$subtitle_position + aligned_top <- all(t == t[1]) + aligned_left <- all(l == l[1]) + if (position != "top" & aligned_top) { + b[] <- max(b) # align bottom + } + if (position != "left" & aligned_left) { + r[] <- max(r) # align right + } + + subtitle_cell <- switch(position, top = t, left = l, bottom = b, right = r) + cells <- unique(subtitle_cell) + subtitle_cell <- subtitle_cell + match(subtitle_cell, cells) + + + + topleft <- position %in% c("top", "left") + if (topleft) { + spacing_index <- (subtitle_cell <- subtitle_cell - 1L) - 1L + } else { + spacing_index <- subtitle_cell + 1L + } + + just <- get_just(elements$subtitle) + insert <- if (topleft) insert_before else insert_after + + row_add <- col_add <- 0L + if (position %in% c("top", "bottom")) { + row_add <- findInterval(key_row, cells, left.open = !topleft) + t <- b <- subtitle_cell + heights <- insert(heights, cells, sub_height) + heights <- set_within(heights, spacing_index, spacing) + end <- unique(r) + start <- unique(l) + + if (aligned_top) { + widths <- set_within(widths, start - 1L, spacing) + } + widths <- insert_spillover(widths, start, end, sub_width, position, just$hjust) + + index <- reeindex(length(widths), start, end) + key_col <- index[key_col] + lab_col <- index[lab_col] + l <- index[l] - 1 + r <- index[r] + 1 + } else { + col_add <- findInterval(key_col, cells, left.open = !topleft) + l <- r <- subtitle_cell + widths <- insert(widths, cells, sub_width) + widths <- set_within(widths, spacing_index, spacing) + + start <- unique(t) + end <- unique(b) + + if (aligned_left) { + heights <- set_within(heights, start - 1L, spacing) + } + heights <- insert_spillover(heights, start, end, sub_height, position, just$vjust) + + index <- reeindex(length(heights), start, end) + key_row <- index[key_row] + lab_row <- index[lab_row] + t <- index[t] - 1 + b <- index[b] + 1 + } + + key_row <- key_row + row_add + lab_row <- lab_row + row_add + key_col <- key_col + col_add + lab_col <- lab_col + col_add + + groups <- params$groups + groups[, c("t", "r", "b", "l")] <- list(t, r, b, l) + + df <- cbind(key, key_row, key_col, label_row = lab_row, label_col = lab_col) + list(layout = df, heights = heights, widths = widths, groups = groups) + }, + + assemble_drawing = function(self, grobs, layout, sizes, params, elements) { + widths <- unit(layout$widths, "cm") + if (isTRUE(elements$stretch_x)) { + widths[unique0(layout$layout$key_col)] <- elements$key_width + } + heights <- unit(layout$heights, "cm") + if (isTRUE(elements$stretch_y)) { + heights[unique0(layout$layout$key_row)] <- elements$key_height + } + groups <- layout$groups + layout <- layout$layout + gt <- gtable(widths = widths, heights = heights) + + if (!is.zero(grobs$decor)) { + gt <- gtable_add_grob( + gt, grobs$decor, name = names(grobs$decor), + t = layout$key_row, l = layout$key_col, + clip = "off" + ) + } + if (!is.zero(grobs$labels)) { + gt <- gtable_add_grob( + gt, grobs$labels, name = names(grobs$labels) %||% + paste("label", layout$label_row, layout$label_col, sep = "-"), + t = layout$label_row, l = layout$label_col, + clip = "off" + ) + } + if (!is.zero(grobs$title$subtitles)) { + gt <- gtable_add_grob( + gt, grobs$title$subtitles, name = names(grobs$title$subtitles) %||% + paste0("subtitle-", seq_along(grobs$title$subtitles)), + t = groups$t, r = groups$r, b = groups$b, l = groups$l, clip = "off" + ) + } + gt <- self$add_title(gt, grobs$title$main, elements$title_position, + get_just(elements$title)) + gt <- gtable_add_padding(gt, unit(elements$padding, "cm")) + if (!is.zero(elements$background)) { + gt <- gtable_add_grob(gt, elements$background, name = "background", + clip = "off", t = 1, r = -1, b = -1, l = 1, z = -Inf) + } + gt + } +) + +# Helpers ----------------------------------------------------------------- + +group_design <- function(key, nrow = NULL, ncol = NULL, + direction = "vertical") { + groups <- vec_count(key$.group) + groups <- vec_slice(groups, order(match(groups$key, key$.group))) + n <- groups$count + + if (is.null(nrow) && is.null(ncol)) { + if (direction == "horizontal") { + nrow <- ceiling(n / 5) + } else { + ncol <- ceiling(n / 20) + } + } + + groups$nrow <- nrow %||% ceiling(n / ncol) + groups$ncol <- ceiling(n / groups$nrow) + groups +} + +apply_group_design <- function(key, groups, direction = "vertical", byrow = FALSE) { + + nrow <- rep(groups$nrow, groups$count) + ncol <- rep(groups$ncol, groups$count) + + index <- seq_len(sum(groups$count)) + sub_index <- vec_ave(index, key$.group, seq_along) + + if (byrow) { + row <- ceiling(sub_index / ncol) + col <- (sub_index - 1L) %% ncol + 1 + } else { + row <- (sub_index - 1L) %% nrow + 1 + col <- ceiling(sub_index / nrow) + } + + if (direction == "vertical") { + row <- row + rep(cumsum(c(0, groups$nrow[-nrow(groups)])), groups$count) + } else { + col <- col + rep(cumsum(c(0, groups$ncol[-nrow(groups)])), groups$count) + } + + key$.index <- index + key$.row <- row + key$.col <- col + key +} + +set_within <- function(x, i, value) { + i <- i[i > 0 & i <= length(x)] + x[i] <- value + x +} + +insert_spillover <- function(size, start, end, extra, position, just = NULL) { + cumsize <- cumsum(size) + extra_size <- pmax(0, extra - (cumsize[end] - c(0, cumsize)[start])) + just <- (just %||% 0.5) * c(1, -1) + c(0, 1) + + if (position %in% c("left", "right")) { + just <- rev(just) + } + + size <- insert_before(size, start, extra_size * just[1]) + insert_after(size, end + match(start, start), extra_size * just[2]) +} + +reeindex <- function(n, start, end) { + index <- seq_len(n) + new_index <- insert_before(index, start, NA) + new_index <- insert_after(new_index, end + match(start, start), NA) + match(index, new_index) +} diff --git a/R/guide_colring.R b/R/guide_colring.R index 3c59b13..c7907d7 100644 --- a/R/guide_colring.R +++ b/R/guide_colring.R @@ -231,7 +231,7 @@ GuideColring <- ggproto( }, setup_elements = function(params, elements, theme) { - elements$title <- setup_legend_title(theme, params$direction) + elements$title <- setup_legend_title(theme, direction = params$direction) theme$legend.frame <- theme$legend.frame %||% element_blank() Guide$setup_elements(params, elements, theme) }, @@ -293,7 +293,7 @@ GuideColring <- ggproto( gt <- gtable_add_padding(gt, margin) |> self$add_title( title, elems$title_position, - with(elems$title, rotate_just(angle, hjust, vjust)) + get_just(elems$title) ) |> gtable_add_padding(elems$margin) diff --git a/R/key-group.R b/R/key-group.R new file mode 100644 index 0000000..ad9a55d --- /dev/null +++ b/R/key-group.R @@ -0,0 +1,146 @@ +# Group keys -------------------------------------------------------------- + +#' Group keys +#' +#' @description +#' These functions are helper functions for working with grouped data as keys in +#' guides. They all share the goal of creating a guide key, but have different +#' methods. +#' +#' * `key_group_split()` is a function factory whose functions make an attempt +#' to infer groups from the scale's labels. +#' * `key_group_lut()` is a function factory whose functions use a look up table +#' to sort out group membership. +#' +#' @param sep A `` giving a [regular expression][base::regex] to +#' use for splitting labels provided by the scale using the +#' [`strsplit()`][base::strsplit] function. By defaults, labels are splitted +#' on any non-alphanumeric character. +#' @param reverse A `` which if `FALSE` (default) treats the first +#' part of the split string as groups and later parts as members. If `TRUE`, +#' treats the last part as groups. +#' @param members A vector including the scale's `breaks` values. +#' @param group A vector parallel to `members` giving the group of each member. +#' @param ungrouped A `` giving a group label to assign to the +#' scale's `breaks` that match no values in the `members` argument. +#' +#' @name key_group +#' @family keys +#' @return +#' A function to use as the `key` argument in a guide. +#' +#' @examples +#' # Example scale +#' values <- c("group A:value 1", "group A:value 2", "group B:value 1") +#' template <- scale_colour_discrete(limits = values) +#' +#' # Treat the 'group X' part as groups +#' key <- key_group_split(sep = ":") +#' key(template) +#' +#' # Treat the 'value X' part as groups +#' key <- key_group_split(sep = ":", reverse = TRUE) +#' key(template) +#' +#' # Example scale +#' template <- scale_colour_discrete(limits = msleep$name[c(1, 7, 9, 23, 24)]) +#' +#' # A lookup table can have more entries than needed +#' key <- key_group_lut(msleep$name, msleep$order) +#' key(template) +#' +#' # Or less entries than needed +#' key <- key_group_lut( +#' msleep$name[23:24], msleep$order[23:24], +#' ungrouped = "Other animals" +#' ) +#' key(template) +NULL + +#' @rdname key_group +#' @export +key_group_split <- function(sep = "[^[:alnum:]]+", reverse = FALSE) { + check_string(sep) + check_bool(reverse) + call <- current_call() + function(scale, aesthetic = NULL) { + group_from_split_label( + scale = scale, aesthetic = aesthetic, + sep = sep, reverse = reverse, + call = call + ) + } +} + +#' @rdname key_group +#' @export +key_group_lut <- function(members, group, ungrouped = "Other") { + check_string(ungrouped) + check_unique(members) + if (length(group) != length(members)) { + cli::cli_abort(c( + "{.arg group} must have the same length as {.arg members}.", + i = "{.arg group} has length {length(group)}.", + i = "{.arg members} has length {length(members)}." + )) + } + lut <- vec_split(members, group) + + function(scale, aesthetic = NULL) { + group_from_lut( + scale = scale, aesthetic = aesthetic, + lut = lut, ungrouped = ungrouped + ) + } +} + +# Helpers ----------------------------------------------------------------- + +group_from_split_label <- function(scale, aesthetic, sep = "[^[:alnum:]]+", + reverse = FALSE, call = caller_env()) { + + # Extract a standard key from the scale + aesthetic <- aesthetic %||% scale$aesthetics[1] + key <- Guide$extract_key(scale, aesthetic) + + # Reject expressions, as we cannot split these + if (!is.character(key$.label)) { + type <- obj_type_friendly(key$.label) + cli::cli_abort( + c("Cannot split the guide's {.field label}.", + i = "It must be a {.cls character} vector, not {type}."), + call = call + ) + } + + # Split labels + labels <- strsplit(key$.label, sep) + if (isTRUE(reverse)) { + i <- lengths(labels) + } else { + i <- rep(1L, length(labels)) + } + + groups <- vec_c(!!!Map(vec_slice, i = i, x = labels)) + labels <- lapply(Map(vec_slice, i = -i, x = labels), paste0, collapse = " ") + labels <- vec_c(!!!labels) + + key$.label <- labels + key$.group <- factor(groups, unique0(groups)) + vec_slice(key, order(key$.group)) + +} + +group_from_lut <- function(scale, aesthetic, lut, ungrouped = "Other") { + + # Extract a standard key from the scale + aesthetic <- aesthetic %||% scale$aesthetics[1] + key <- Guide$extract_key(scale, aesthetic) + + group <- lut$key[match_list(key$.value, lut$val)] %|NA|% ungrouped + if (!is.factor(group)) { + group <- factor(group, c(setdiff(unique(group), ungrouped), ungrouped)) + } + key$.group <- group + vec_slice(key, order(group)) +} diff --git a/R/legendry-package.R b/R/legendry-package.R index fa66ffe..75efd4c 100644 --- a/R/legendry-package.R +++ b/R/legendry-package.R @@ -74,5 +74,9 @@ NULL #' cases. #' * A `` between -360 and 360 for the text angle in degrees. #' +#' @param override.aes A named `` specifying aesthetic parameters of the +#' key glyphs. See details and examples in +#' [`guide_legend()`][ggplot2::guide_legend()]. +#' #' @keywords internal NULL diff --git a/R/primitive-.R b/R/primitive-.R index 2ec63d5..cebeee6 100644 --- a/R/primitive-.R +++ b/R/primitive-.R @@ -56,10 +56,10 @@ primitive_setup_elements <- function(params, elements, theme) { } theme <- theme + params$theme if (identical(elements$text, "legend.text")) { - elements$text <- setup_legend_text(theme, params$direction) + elements$text <- setup_legend_text(theme, direction = params$direction) } if (identical(elements$title, "legend.title")) { - elements$title <- setup_legend_title(theme, params$direction) + elements$title <- setup_legend_title(theme, direction = params$direction) } if (identical(elements$ticks_length, "legend.ticks.length")) { theme$legend.ticks.length <- theme$legend.ticks.length %||% diff --git a/R/primitive-box.R b/R/primitive-box.R index 0bf1e1c..efaf7d7 100644 --- a/R/primitive-box.R +++ b/R/primitive-box.R @@ -209,7 +209,7 @@ PrimitiveBox <- ggproto( sizes <- rev(sizes) } - attr(grobs, "sizes") <- sizes + attr(grobs, "size") <- sizes grobs }, @@ -227,7 +227,7 @@ PrimitiveBox <- ggproto( primitive_grob( grob = box, - size = unit(attr(box, "sizes"), "cm"), + size = unit(get_size_attr(box), "cm"), position = params$position, name = "box" ) diff --git a/R/primitive-fence.R b/R/primitive-fence.R index e99331a..4f8e971 100644 --- a/R/primitive-fence.R +++ b/R/primitive-fence.R @@ -266,7 +266,7 @@ PrimitiveFence <- ggproto( sizes <- rev(sizes) } - attr(grobs, "sizes") <- sizes + attr(grobs, "size") <- sizes grobs }, @@ -284,7 +284,7 @@ PrimitiveFence <- ggproto( primitive_grob( grob = fence, - size = unit(attr(fence, "sizes"), "cm"), + size = unit(get_size_attr(fence), "cm"), position = params$position, name = "fence" ) diff --git a/R/primitive-labels.R b/R/primitive-labels.R index 318599f..72e6bbd 100644 --- a/R/primitive-labels.R +++ b/R/primitive-labels.R @@ -133,7 +133,7 @@ PrimitiveLabels <- ggproto( vec_slice(key, index), elements$text, angle, offset, params$position, check_overlap = params$check_overlap ) - offset <- offset + attr(grob, "size") %||% 0 + offset <- offset + get_size_attr(grob) grobs[[i]] <- grob } if (params$position %in% c("top", "left")) grobs <- rev(grobs) diff --git a/R/primitive-title.R b/R/primitive-title.R index 9ca4e00..feafd6c 100644 --- a/R/primitive-title.R +++ b/R/primitive-title.R @@ -107,7 +107,7 @@ PrimitiveTitle <- ggproto( params$position, top = , bottom = height_cm(grobs), left = , right = width_cm(grobs), - attr(grobs, "offset") %||% 0 + get_attr(grobs, "offset", default = 0) ) }, diff --git a/R/themes.R b/R/themes.R index 06d4cdc..72b1e2a 100644 --- a/R/themes.R +++ b/R/themes.R @@ -36,8 +36,8 @@ #' `legendry.legend.minor.ticks.length`. #' * `mini.ticks.length` sets both `legendry.axis.mini.ticks.length` and #' `legendry.legend.mini.ticks.length`. -#' @param spacing A [``][grid::unit()] setting the -#' `legendry.guide.spacing` theme element. +#' @param spacing,group.spacing A [``][grid::unit()] setting both the +#' `legendry.guide.spacing` and `legendry.group.spacing` theme elements. #' @param key An [``][ggplot2::element_rect] setting the #' `legend.key` element. #' @param key.size,key.width,key.height A [``][grid::unit()] setting the @@ -96,6 +96,7 @@ theme_guide <- function( mini.ticks.length = NULL, spacing = NULL, + group.spacing = NULL, key = NULL, key.size = NULL, @@ -161,6 +162,7 @@ theme_guide <- function( legendry.legend.mini.ticks.length = mini.ticks.length, legendry.guide.spacing = spacing, + legendry.group.spacing = group.spacing, legend.key = key, legend.key.spacing = key.spacing, @@ -201,6 +203,7 @@ register_legendry_elements <- function() { legendry.axis.mini.ticks = element_line(), legendry.axis.mini.ticks.length = rel(0.5), legendry.guide.spacing = unit(2.25, "pt"), + legendry.group.spacing = rel(2), legendry.axis.subtitle = element_text(margin = margin(5.5, 5.5, 5.5, 5.5)), legendry.axis.subtitle.position = c("left", "top"), element_tree = list( @@ -219,6 +222,7 @@ register_legendry_elements <- function() { legendry.axis.mini.ticks = el_line("axis.ticks"), legendry.axis.mini.ticks.length = el_unit("axis.minor.ticks.length"), legendry.guide.spacing = el_unit("axis.ticks.length"), + legendry.group.spacing = el_unit("legend.key.spacing"), legendry.axis.subtitle = el_def("element_text", "axis.text"), legendry.axis.subtitle.position = el_def("character") ) diff --git a/R/utils-checks.R b/R/utils-checks.R index 394f140..f96bd5b 100644 --- a/R/utils-checks.R +++ b/R/utils-checks.R @@ -258,3 +258,27 @@ check_exclusive <- function( i = "Please use one, but not both." ), call = call) } + +check_matrix <- function( + x, allow_null = FALSE, zero_dim = FALSE, + arg = caller_arg(x), call = caller_env() +) { + check_object( + x, is.matrix, "a {.cls matrix}", allow_null = allow_null, + arg = arg, call = call + ) + + # Test dimensions + dim <- dim(x) + valid_dim <- length(dim) == 2 && !anyNA(dim) && + all(dim >= (0 + as.numeric(!zero_dim))) + + if (valid_dim) { + return(invisible(NULL)) + } + + cli::cli_abort( + "The {.arg {arg}} argument has invalid dimensions: {.value {dim}}.", + call = call + ) +} diff --git a/R/utils-text.R b/R/utils-text.R index fd001ae..0b71afd 100644 --- a/R/utils-text.R +++ b/R/utils-text.R @@ -1,7 +1,8 @@ -setup_legend_text <- function(theme, direction = "vertical") { - position <- calc_element("legend.text.position", theme) - position <- position %||% switch(direction, horizontal = "bottom", vertical = "right") +setup_legend_text <- function(theme, position = NULL, direction = "vertical") { + position <- position %||% + calc_element("legend.text.position", theme) %||% + switch(direction, horizontal = "bottom", vertical = "right") gap <- calc_element("legend.key.spacing", theme) %||% unit(0, "pt") margin <- calc_element("text", theme)$margin %||% margin() margin <- position_margin(position, margin, gap) @@ -18,14 +19,16 @@ setup_legend_text <- function(theme, direction = "vertical") { calc_element("legend.text", theme + text) } -setup_legend_title <- function(theme, direction = "vertical") { - position <- calc_element("legend.title.position", theme) - position <- position %||% switch(direction, horizontal = "left", vertical = "top") +setup_legend_title <- function(theme, position = NULL, direction = "vertical", + element = "legend.title") { + position <- position %||% + calc_element("legend.title.position", theme) %||% + switch(direction, horizontal = "left", vertical = "top") gap <- calc_element("legend.key.spacing", theme) %||% unit(0, "pt") margin <- calc_element("text", theme)$margin %||% margin() margin <- position_margin(position, margin, gap) title <- theme(text = element_text(hjust = 0, vjust = 0.5, margin = margin)) - calc_element("legend.title", theme + title) + calc_element(element, theme + title) } position_margin <- function(position, margin = margin(), gap = unit(0, "pt")) { diff --git a/R/utils.R b/R/utils.R index 3433942..8c6632a 100644 --- a/R/utils.R +++ b/R/utils.R @@ -70,8 +70,20 @@ eval_aes <- function( x } -get_size_attr <- function(x) { - attr(x, "size", exact = TRUE) %||% 0 +get_attr <- function(x, which, default = NULL) { + attr(x, which = which, exact = TRUE) %||% default +} + +get_size_attr <- function(x, default = 0) { + get_attr(x, "size", default = default) +} + +get_width_attr <- function(x, default = 0) { + get_attr(x, "width", default = default) +} + +get_height_attr <- function(x, default = 0) { + get_attr(x, "height", default = default) } pad <- function(x, length, fill = NA, where = "end") { @@ -260,3 +272,11 @@ insert_after <- function(x, i, value) { new[-i] <- x new } + +get_just <- function(element) { + rotate_just( + element$angle %||% 0, + element$hjust %||% 0.5, + element$vjust %||% 0.5 + ) +} diff --git a/man/common_parameters.Rd b/man/common_parameters.Rd index da74643..559bc88 100644 --- a/man/common_parameters.Rd +++ b/man/common_parameters.Rd @@ -37,6 +37,10 @@ probably want. Can be one of the following: cases. \item A \verb{} between -360 and 360 for the text angle in degrees. }} + +\item{override.aes}{A named \verb{} specifying aesthetic parameters of the +key glyphs. See details and examples in +\code{\link[ggplot2:guide_legend]{guide_legend()}}.} } \description{ This is a collection of common parameters so they needn't be re-documented diff --git a/man/guide_axis_base.Rd b/man/guide_axis_base.Rd index bd28e30..d562763 100644 --- a/man/guide_axis_base.Rd +++ b/man/guide_axis_base.Rd @@ -126,6 +126,9 @@ Other standalone guides: \code{\link{guide_axis_nested}()}, \code{\link{guide_colbar}()}, \code{\link{guide_colring}()}, -\code{\link{guide_colsteps}()} +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} } \concept{standalone guides} diff --git a/man/guide_axis_nested.Rd b/man/guide_axis_nested.Rd index d99761a..83c194b 100644 --- a/man/guide_axis_nested.Rd +++ b/man/guide_axis_nested.Rd @@ -164,6 +164,9 @@ Other standalone guides: \code{\link{guide_axis_base}()}, \code{\link{guide_colbar}()}, \code{\link{guide_colring}()}, -\code{\link{guide_colsteps}()} +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} } \concept{standalone guides} diff --git a/man/guide_colbar.Rd b/man/guide_colbar.Rd index 3b21908..9339db9 100644 --- a/man/guide_colbar.Rd +++ b/man/guide_colbar.Rd @@ -158,6 +158,9 @@ Other standalone guides: \code{\link{guide_axis_base}()}, \code{\link{guide_axis_nested}()}, \code{\link{guide_colring}()}, -\code{\link{guide_colsteps}()} +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} } \concept{standalone guides} diff --git a/man/guide_colring.Rd b/man/guide_colring.Rd index c444ef9..863bb4a 100644 --- a/man/guide_colring.Rd +++ b/man/guide_colring.Rd @@ -110,6 +110,9 @@ Other standalone guides: \code{\link{guide_axis_base}()}, \code{\link{guide_axis_nested}()}, \code{\link{guide_colbar}()}, -\code{\link{guide_colsteps}()} +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} } \concept{standalone guides} diff --git a/man/guide_colsteps.Rd b/man/guide_colsteps.Rd index 875bd21..a95f808 100644 --- a/man/guide_colsteps.Rd +++ b/man/guide_colsteps.Rd @@ -155,6 +155,9 @@ Other standalone guides: \code{\link{guide_axis_base}()}, \code{\link{guide_axis_nested}()}, \code{\link{guide_colbar}()}, -\code{\link{guide_colring}()} +\code{\link{guide_colring}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} } \concept{standalone guides} diff --git a/man/guide_legend_base.Rd b/man/guide_legend_base.Rd new file mode 100644 index 0000000..574dae4 --- /dev/null +++ b/man/guide_legend_base.Rd @@ -0,0 +1,121 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/guide-legend-base.R +\name{guide_legend_base} +\alias{guide_legend_base} +\title{Custom legend guide} +\usage{ +guide_legend_base( + key = NULL, + title = waiver(), + theme = NULL, + design = NULL, + nrow = NULL, + ncol = NULL, + reverse = FALSE, + override.aes = list(), + position = NULL, + direction = NULL, + order = 0 +) +} +\arguments{ +\item{key}{A \link[=key_standard]{standard key} specification. Defaults to +\code{\link[=key_auto]{key_auto()}}. See more information in the linked topic.} + +\item{title}{A \verb{} or \verb{} indicating the title of +the guide. If \code{NULL}, the title is not shown. The default, +\code{\link[ggplot2:waiver]{waiver()}}, takes the name of the scale object or +the name specified in \code{\link[ggplot2:labs]{labs()}} as the title.} + +\item{theme}{A \code{\link[ggplot2:theme]{}} object to style the guide individually or +differently from the plot's theme settings. The \code{theme} argument in the +guide overrides and is combined with the plot's theme.} + +\item{design}{Specification of the legend layout. One of the following: +\itemize{ +\item \code{NULL} (default) to use the layout algorithm of +\code{\link[ggplot2:guide_legend]{guide_legend()}}. +\item A \verb{} string representing a cell layout wherein \verb{#} defines +an empty cell. See examples. +\item A \verb{} representing a cell layout wherein \code{NA} defines an +empty cell. See examples. Non-string atomic vectors will be treated with +\code{as.matrix()}. +}} + +\item{nrow, ncol}{A positive \verb{} setting the desired dimensions of +the legend layout. When \code{NULL} (default), the dimensions will be derived +from the \code{design} argument or fit to match the number of keys.} + +\item{reverse}{A \verb{} whether the order of keys should be inverted.} + +\item{override.aes}{A named \verb{} specifying aesthetic parameters of the +key glyphs. See details and examples in +\code{\link[ggplot2:guide_legend]{guide_legend()}}.} + +\item{position}{A \verb{} giving the location of the guide. Can be one of \code{"top"}, +\code{"bottom"}, \code{"left"} or \code{"right"}.} + +\item{direction}{A \verb{} indicating the direction of the guide. Can be on of +\code{"horizontal"} or \code{"vertical"}.} + +\item{order}{A positive \verb{} that specifies the order of this guide among +multiple guides. This controls in which order guides are merged if there +are multiple guides for the same position. If \code{0} (default), the order is +determined by a hashing indicative settings of a guide.} +} +\value{ +A \verb{} object. +} +\description{ +This legend closely mirrors \code{ggplot2::guide_legend()}, but has two +adjustments. First, \code{guide_legend_base()} supports a \code{design} argument +for a more flexible layout. Secondly, the \code{legend.spacing.y} theme element +is observed verbatim instead of overruled. +} +\examples{ +# A dummy plot +p <- ggplot(data.frame(x = 1:3, type = c("tic", "tac", "toe"))) + + aes(x, x, shape = type) + + geom_point(na.rm = TRUE) + + scale_shape_manual(values = c(1, 4, NA)) + +# A design string, each character giving a cell value. +# Newlines separate rows, white space is ignored. +design <- " + 123 + 213 + 321 +" + +# Alternatively, the same can be specified using a matrix directly +# design <- matrix(c(1, 2, 3, 2, 1, 3, 3, 2, 1), 3, 3, byrow = TRUE) + +p + guides(shape = guide_legend_base(design = design)) + +# Empty cells can be created using `#` +design <- " + #2# + 1#3 +" + +# Alternatively: +# design <- matrix(c(NA, 1, 2, NA, NA, 3), nrow = 2) + +p + guides(shape = guide_legend_base(design = design)) +} +\seealso{ +Other standalone guides: +\code{\link{guide_axis_base}()}, +\code{\link{guide_axis_nested}()}, +\code{\link{guide_colbar}()}, +\code{\link{guide_colring}()}, +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} + +Other legend guides: +\code{\link{guide_legend_cross}()}, +\code{\link{guide_legend_group}()} +} +\concept{legend guides} +\concept{standalone guides} diff --git a/man/guide_legend_cross.Rd b/man/guide_legend_cross.Rd new file mode 100644 index 0000000..34270df --- /dev/null +++ b/man/guide_legend_cross.Rd @@ -0,0 +1,122 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/guide-legend-cross.R +\name{guide_legend_cross} +\alias{guide_legend_cross} +\title{Cross legend guide} +\usage{ +guide_legend_cross( + key = NULL, + title = waiver(), + swap = FALSE, + col_text = element_text(angle = 90, vjust = 0.5), + override.aes = list(), + reverse = FALSE, + theme = NULL, + position = NULL, + direction = NULL, + order = 0 +) +} +\arguments{ +\item{key}{One of the following key specifications: +\itemize{ +\item A \link[=key_group_split]{group split} specification when using the legend to +display a compound variable like \code{paste(var1, var2)}. +\item A \link[=key_standard]{standard key} specification, like \code{\link[=key_auto]{key_auto()}}, when +crossing two separate variables across two scales. +}} + +\item{title}{A \verb{} or \verb{} indicating the title of +the guide. If \code{NULL}, the title is not shown. The default, +\code{\link[ggplot2:waiver]{waiver()}}, takes the name of the scale object or +the name specified in \code{\link[ggplot2:labs]{labs()}} as the title.} + +\item{swap}{A \verb{} which when \code{TRUE} exchanges the column and row +variables in the displayed legend.} + +\item{col_text}{An \verb{} object giving adjustments to text for +the column labels. Can be \code{NULL} to display column labels in equal fashion +to the row labels.} + +\item{override.aes}{A named \verb{} specifying aesthetic parameters of the +key glyphs. See details and examples in +\code{\link[ggplot2:guide_legend]{guide_legend()}}.} + +\item{reverse}{A \verb{} whether the order of the keys should be +inverted, where the first value controls the row order and second value +the column order. Input as \verb{} will be recycled.} + +\item{theme}{A \code{\link[ggplot2:theme]{}} object to style the guide individually or +differently from the plot's theme settings. The \code{theme} argument in the +guide overrides and is combined with the plot's theme.} + +\item{position}{A \verb{} giving the location of the guide. Can be one of \code{"top"}, +\code{"bottom"}, \code{"left"} or \code{"right"}.} + +\item{direction}{A \verb{} indicating the direction of the guide. Can be on of +\code{"horizontal"} or \code{"vertical"}.} + +\item{order}{A positive \verb{} that specifies the order of this guide among +multiple guides. This controls in which order guides are merged if there +are multiple guides for the same position. If \code{0} (default), the order is +determined by a hashing indicative settings of a guide.} +} +\value{ +A \verb{} object. +} +\description{ +This is a legend type similar to \code{\link[ggplot2:guide_legend]{guide_legend()}} +that displays crosses, or: interactions, between two variables. +} +\examples{ +# Standard use for single aesthetic. The default is to split labels to +# disentangle aesthetics that are already crossed (by e.g. `paste()`) +ggplot(mpg, aes(displ, hwy)) + + geom_point(aes(colour = paste(year, drv))) + + guides(colour = "legend_cross") + +# If legends should be merged between identical aesthetics, both need the +# same legend type. +ggplot(mpg, aes(displ, hwy)) + + geom_point(aes(colour = paste(year, drv), shape = paste(year, drv))) + + guides(colour = "legend_cross", shape = "legend_cross") + +# Crossing two aesthetics requires a shared title and `key = "auto"`. The +# easy way to achieve this is to predefine a shared guide. +my_guide <- guide_legend_cross(key = "auto", title = "My title") + +ggplot(mpg, aes(displ, hwy)) + + geom_point(aes(colour = drv, shape = factor(year))) + + guides(colour = my_guide, shape = my_guide) + +# You can cross more than 2 aesthetics but not more than 2 unique aesthetics. +ggplot(mpg, aes(displ, hwy)) + + geom_point(aes(colour = drv, shape = factor(year), size = factor(drv))) + + scale_size_ordinal() + + guides(colour = my_guide, shape = my_guide, size = my_guide) + +# You can merge an aesthetic that is already crossed with an aesthetic that +# contributes to only one side of the cross. +ggplot(mpg, aes(displ, hwy)) + + geom_point(aes(colour = paste(year, drv), shape = drv)) + + guides( + colour = guide_legend_cross(title = "My Title"), + shape = guide_legend_cross(title = "My Title", key = "auto") + ) +} +\seealso{ +Other standalone guides: +\code{\link{guide_axis_base}()}, +\code{\link{guide_axis_nested}()}, +\code{\link{guide_colbar}()}, +\code{\link{guide_colring}()}, +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_group}()} + +Other legend guides: +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_group}()} +} +\concept{legend guides} +\concept{standalone guides} diff --git a/man/guide_legend_group.Rd b/man/guide_legend_group.Rd new file mode 100644 index 0000000..2a3cf7c --- /dev/null +++ b/man/guide_legend_group.Rd @@ -0,0 +1,112 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/guide-legend-group.R +\name{guide_legend_group} +\alias{guide_legend_group} +\title{Grouped legend} +\usage{ +guide_legend_group( + key = "group_split", + title = waiver(), + override.aes = list(), + nrow = NULL, + ncol = NULL, + theme = NULL, + position = NULL, + direction = NULL, + order = 0 +) +} +\arguments{ +\item{key}{A \link[=key_group]{group key} specification. Defaults to +\code{key_group_split()} to split labels to find groups.} + +\item{title}{A \verb{} or \verb{} indicating the title of +the guide. If \code{NULL}, the title is not shown. The default, +\code{\link[ggplot2:waiver]{waiver()}}, takes the name of the scale object or +the name specified in \code{\link[ggplot2:labs]{labs()}} as the title.} + +\item{override.aes}{A named \verb{} specifying aesthetic parameters of the +key glyphs. See details and examples in +\code{\link[ggplot2:guide_legend]{guide_legend()}}.} + +\item{nrow, ncol}{A positive \verb{} setting the desired dimensions of +the legend layout. Either \code{nrow} or \code{ncol} can be set, but not both,} + +\item{theme}{A \code{\link[ggplot2:theme]{}} object to style the guide individually or +differently from the plot's theme settings. The \code{theme} argument in the +guide overrides and is combined with the plot's theme.} + +\item{position}{A \verb{} giving the location of the guide. Can be one of \code{"top"}, +\code{"bottom"}, \code{"left"} or \code{"right"}.} + +\item{direction}{A \verb{} indicating the direction of the guide. Can be on of +\code{"horizontal"} or \code{"vertical"}.} + +\item{order}{A positive \verb{} that specifies the order of this guide among +multiple guides. This controls in which order guides are merged if there +are multiple guides for the same position. If \code{0} (default), the order is +determined by a hashing indicative settings of a guide.} +} +\value{ +A \verb{} object. +} +\description{ +This legend resembles \code{ggplot2::guide_legend()}, but has the ability to +keep groups in blocks with their own titles. +} +\examples{ +# Standard plot for selection of `msleep` +df <- msleep[c(9, 28, 11, 5, 34, 54, 64, 24, 53), ] + +p <- ggplot(df) + + aes(bodywt, awake, colour = paste(order, name)) + + geom_point() + +# By default, groups are inferred from the name +p + guides(colour = "legend_group") + +# You can also use a look-up table for groups +# The lookup table can be more expansive than just the data: +# We're using the full 'msleep' data here instead of the subset +lut <- key_group_lut(msleep$name, msleep$order) + +p + aes(colour = name) + + guides(colour = guide_legend_group(key = lut)) + +# `nrow` and `ncol` apply within groups +p + guides(colour = guide_legend_group(nrow = 1)) + +# Groups are arranged according to `direction` +p + guides(colour = guide_legend_group(ncol = 1, direction = "horizontal")) + + theme(legend.title.position = "top") + +# Customising the group titles +p + guides(colour = "legend_group") + + theme( + legendry.legend.subtitle.position = "left", + legendry.legend.subtitle = element_text( + hjust = 1, vjust = 1, size = rel(0.9), + margin = margin(t = 5.5, r = 5.5) + ) + ) + +# Changing the spacing between groups +p + guides(colour = "legend_group") + + theme(legendry.group.spacing = unit(0, "cm")) +} +\seealso{ +Other standalone guides: +\code{\link{guide_axis_base}()}, +\code{\link{guide_axis_nested}()}, +\code{\link{guide_colbar}()}, +\code{\link{guide_colring}()}, +\code{\link{guide_colsteps}()}, +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()} + +Other legend guides: +\code{\link{guide_legend_base}()}, +\code{\link{guide_legend_cross}()} +} +\concept{legend guides} +\concept{standalone guides} diff --git a/man/key_group.Rd b/man/key_group.Rd new file mode 100644 index 0000000..004576d --- /dev/null +++ b/man/key_group.Rd @@ -0,0 +1,77 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/key-group.R +\name{key_group} +\alias{key_group} +\alias{key_group_split} +\alias{key_group_lut} +\title{Group keys} +\usage{ +key_group_split(sep = "[^[:alnum:]]+", reverse = FALSE) + +key_group_lut(members, group, ungrouped = "Other") +} +\arguments{ +\item{sep}{A \verb{} giving a \link[base:regex]{regular expression} to +use for splitting labels provided by the scale using the +\code{\link[base:strsplit]{strsplit()}} function. By defaults, labels are splitted +on any non-alphanumeric character.} + +\item{reverse}{A \verb{} which if \code{FALSE} (default) treats the first +part of the split string as groups and later parts as members. If \code{TRUE}, +treats the last part as groups.} + +\item{members}{A vector including the scale's \code{breaks} values.} + +\item{group}{A vector parallel to \code{members} giving the group of each member.} + +\item{ungrouped}{A \verb{} giving a group label to assign to the +scale's \code{breaks} that match no values in the \code{members} argument.} +} +\value{ +A function to use as the \code{key} argument in a guide. +} +\description{ +These functions are helper functions for working with grouped data as keys in +guides. They all share the goal of creating a guide key, but have different +methods. +\itemize{ +\item \code{key_group_split()} is a function factory whose functions make an attempt +to infer groups from the scale's labels. +\item \code{key_group_lut()} is a function factory whose functions use a look up table +to sort out group membership. +} +} +\examples{ +# Example scale +values <- c("group A:value 1", "group A:value 2", "group B:value 1") +template <- scale_colour_discrete(limits = values) + +# Treat the 'group X' part as groups +key <- key_group_split(sep = ":") +key(template) + +# Treat the 'value X' part as groups +key <- key_group_split(sep = ":", reverse = TRUE) +key(template) + +# Example scale +template <- scale_colour_discrete(limits = msleep$name[c(1, 7, 9, 23, 24)]) + +# A lookup table can have more entries than needed +key <- key_group_lut(msleep$name, msleep$order) +key(template) + +# Or less entries than needed +key <- key_group_lut( + msleep$name[23:24], msleep$order[23:24], + ungrouped = "Other animals" +) +key(template) +} +\seealso{ +Other keys: +\code{\link{key_range}}, +\code{\link{key_specialty}}, +\code{\link{key_standard}} +} +\concept{keys} diff --git a/man/key_range.Rd b/man/key_range.Rd index 7f01723..b5911fa 100644 --- a/man/key_range.Rd +++ b/man/key_range.Rd @@ -96,6 +96,7 @@ key_range_map(presidential, start = start, end = end, name = name) } \seealso{ Other keys: +\code{\link{key_group}}, \code{\link{key_specialty}}, \code{\link{key_standard}} } diff --git a/man/key_specialty.Rd b/man/key_specialty.Rd index 87e703b..067cc88 100644 --- a/man/key_specialty.Rd +++ b/man/key_specialty.Rd @@ -50,6 +50,7 @@ key_bins()(template) } \seealso{ Other keys: +\code{\link{key_group}}, \code{\link{key_range}}, \code{\link{key_standard}} } diff --git a/man/key_standard.Rd b/man/key_standard.Rd index d78aa16..5d53e11 100644 --- a/man/key_standard.Rd +++ b/man/key_standard.Rd @@ -123,6 +123,7 @@ key_none() } \seealso{ Other keys: +\code{\link{key_group}}, \code{\link{key_range}}, \code{\link{key_specialty}} } diff --git a/man/legendry_extensions.Rd b/man/legendry_extensions.Rd index 885f110..75fa7fd 100644 --- a/man/legendry_extensions.Rd +++ b/man/legendry_extensions.Rd @@ -2,10 +2,11 @@ % Please edit documentation in R/compose-.R, R/compose-crux.R, % R/compose-ontop.R, R/compose-sandwich.R, R/compose-stack.R, % R/gizmo-barcap.R, R/gizmo-density.R, R/gizmo-grob.R, R/gizmo-histogram.R, -% R/gizmo-stepcap.R, R/guide_colring.R, R/legendry-package.R, -% R/primitive-box.R, R/primitive-bracket.R, R/primitive-fence.R, -% R/primitive-labels.R, R/primitive-line.R, R/primitive-spacer.R, -% R/primitive-ticks.R, R/primitive-title.R +% R/gizmo-stepcap.R, R/guide-legend-base.R, R/guide-legend-group.R, +% R/guide_colring.R, R/legendry-package.R, R/primitive-box.R, +% R/primitive-bracket.R, R/primitive-fence.R, R/primitive-labels.R, +% R/primitive-line.R, R/primitive-spacer.R, R/primitive-ticks.R, +% R/primitive-title.R \docType{data} \name{Compose} \alias{Compose} @@ -18,6 +19,8 @@ \alias{GizmoGrob} \alias{GizmoHistogram} \alias{GizmoStepcap} +\alias{GuideLegendBase} +\alias{GuideLegendGroup} \alias{GuideColring} \alias{legendry_extensions} \alias{PrimitiveBox} diff --git a/man/theme_guide.Rd b/man/theme_guide.Rd index 2390916..951094b 100644 --- a/man/theme_guide.Rd +++ b/man/theme_guide.Rd @@ -19,6 +19,7 @@ theme_guide( minor.ticks.length = NULL, mini.ticks.length = NULL, spacing = NULL, + group.spacing = NULL, key = NULL, key.size = NULL, key.width = NULL, @@ -80,8 +81,8 @@ theme_guide( \code{legendry.legend.mini.ticks.length}. }} -\item{spacing}{A [\verb{}][grid::unit()] setting the -\code{legendry.guide.spacing} theme element.} +\item{spacing, group.spacing}{A [\verb{}][grid::unit()] setting both the +\code{legendry.guide.spacing} and \code{legendry.group.spacing} theme elements.} \item{key}{An \code{\link[ggplot2:element]{}} setting the \code{legend.key} element.} diff --git a/tests/testthat/_snaps/guide-legend-cross/legend-cross-orientations.svg b/tests/testthat/_snaps/guide-legend-cross/legend-cross-orientations.svg new file mode 100644 index 0000000..2986e6d --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-cross/legend-cross-orientations.svg @@ -0,0 +1,338 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +colour + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + + + +b + + + + +A + + + + +B + + + + +C + + + + + + + + + + + + + + + + + + +colour + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + + + +b + + + + +A + + + + +B + + + + +C + + + + + + + + + + + + + + + + + + +colour + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + + + +b + + + + +A + + + + +B + + + + +C + + + + + + + + + + + + + + + + + + +colour + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +a + + + + +b + + + + +A + + + + +B + + + + +C + + + + + + diff --git a/tests/testthat/_snaps/guide-legend-cross/legend-cross-single-scale.svg b/tests/testthat/_snaps/guide-legend-cross/legend-cross-single-scale.svg new file mode 100644 index 0000000..677b42d --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-cross/legend-cross-single-scale.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + +1 +2 +3 +4 +5 +x +y + +z + + + + + + + + + + + +1 +2 +A +B +C +legend cross single scale + + diff --git a/tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales-swapped-order.svg b/tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales-swapped-order.svg new file mode 100644 index 0000000..3f96bc5 --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales-swapped-order.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + +1 +2 +3 +4 +5 +x +y + +cross legend + + + + + + + + + + + + +1 +2 +A +B +C +legend cross two scales swapped order + + diff --git a/tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales.svg b/tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales.svg new file mode 100644 index 0000000..0e8951b --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-cross/legend-cross-two-scales.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + +1 +2 +3 +4 +5 +x +y + +cross legend + + + + + + + + + + + + +A +B +C +1 +2 +legend cross two scales + + diff --git a/tests/testthat/_snaps/guide-legend-cross/legend-cross-with-double-reverse.svg b/tests/testthat/_snaps/guide-legend-cross/legend-cross-with-double-reverse.svg new file mode 100644 index 0000000..801244e --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-cross/legend-cross-with-double-reverse.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + +1 +2 +3 +4 +5 +x +y + +z + + + + + + + + + + + +1 +2 +A +B +C +legend cross with double reverse + + diff --git a/tests/testthat/_snaps/guide-legend-group/bottom-bottomtitle.svg b/tests/testthat/_snaps/guide-legend-group/bottom-bottomtitle.svg new file mode 100644 index 0000000..850aee5 --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/bottom-bottomtitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +bottom-bottomtitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/bottom-lefttitle.svg b/tests/testthat/_snaps/guide-legend-group/bottom-lefttitle.svg new file mode 100644 index 0000000..b815565 --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/bottom-lefttitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +bottom-lefttitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/bottom-righttitle.svg b/tests/testthat/_snaps/guide-legend-group/bottom-righttitle.svg new file mode 100644 index 0000000..e5b6762 --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/bottom-righttitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +bottom-righttitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/bottom-toptitle.svg b/tests/testthat/_snaps/guide-legend-group/bottom-toptitle.svg new file mode 100644 index 0000000..d1bbefb --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/bottom-toptitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +bottom-toptitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/right-bottomtitle.svg b/tests/testthat/_snaps/guide-legend-group/right-bottomtitle.svg new file mode 100644 index 0000000..77502ae --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/right-bottomtitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +right-bottomtitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/right-lefttitle.svg b/tests/testthat/_snaps/guide-legend-group/right-lefttitle.svg new file mode 100644 index 0000000..74813e0 --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/right-lefttitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +right-lefttitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/right-righttitle.svg b/tests/testthat/_snaps/guide-legend-group/right-righttitle.svg new file mode 100644 index 0000000..55ccd0b --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/right-righttitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +right-righttitle + + diff --git a/tests/testthat/_snaps/guide-legend-group/right-toptitle.svg b/tests/testthat/_snaps/guide-legend-group/right-toptitle.svg new file mode 100644 index 0000000..64f06c4 --- /dev/null +++ b/tests/testthat/_snaps/guide-legend-group/right-toptitle.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +12 +15 +18 +21 + + + + + + + + +0 +200 +400 +600 +bodywt +awake + +Animals + + + + + + + + + + + + + + + + +Cow +Goat +Dog +Domestic cat +Lion +Donkey +Baboon +Human +Artiodactyla +Carnivora +Perissodactyla +Primates +right-toptitle + + diff --git a/tests/testthat/_snaps/guide_legend_base/custom-legend-design.svg b/tests/testthat/_snaps/guide_legend_base/custom-legend-design.svg new file mode 100644 index 0000000..9292eb4 --- /dev/null +++ b/tests/testthat/_snaps/guide_legend_base/custom-legend-design.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A +B +C +D +E +F +G + + + + + + + + + + + + + + +A +B +C +D +E +F +G +x +x + +x + + + + + + + + + + + + + + +A +B +C +D +G +F +E +custom legend design + + diff --git a/tests/testthat/_snaps/guide_legend_base/standard-legend-design.svg b/tests/testthat/_snaps/guide_legend_base/standard-legend-design.svg new file mode 100644 index 0000000..e337e0f --- /dev/null +++ b/tests/testthat/_snaps/guide_legend_base/standard-legend-design.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +A +B +C +D +E +F +G + + + + + + + + + + + + + + +A +B +C +D +E +F +G +x +x + +x + + + + + + + + + + + + + + +A +B +C +D +E +F +G +standard legend design + + diff --git a/tests/testthat/_snaps/key-group.md b/tests/testthat/_snaps/key-group.md new file mode 100644 index 0000000..b426669 --- /dev/null +++ b/tests/testthat/_snaps/key-group.md @@ -0,0 +1,9 @@ +# key_group_split works correctly + + Code + key_group_split(sep = ":")(scale, "colour") + Condition + Error in `key_group_split()`: + ! Cannot split the guide's label. + i It must be a vector, not a list. + diff --git a/tests/testthat/test-guide-legend-cross.R b/tests/testthat/test-guide-legend-cross.R new file mode 100644 index 0000000..d4099a3 --- /dev/null +++ b/tests/testthat/test-guide-legend-cross.R @@ -0,0 +1,154 @@ +test_that("legend cross labels can be placed anywhere", { + + p <- ggplot(data.frame(colour = c("A:a", "B:a", "C:a", "A:b", "C:b"))) + + geom_point(aes(1:5, 1:5, colour = colour)) + + scale_colour_discrete(guide = guide_legend_cross()) + + build <- ggplot_build(p) + guide <- build$plot$guides$get_guide("colour") + params <- build$plot$guides$get_params("colour") + params[c("position", "direction")] <- list("right", "vertical") + + sets <- list( + c("left", "bottom"), + c("left", "top"), + c("right", "bottom"), + c("right", "top") + ) + + grobs <- lapply(sets, function(set) { + guide$draw( + theme_get() + theme(legend.text.position = set), + params = params + ) + }) + + gt <- gtable(unit(c(0.5, 0.5), "null"), unit(c(0.5, 0.5), "null")) + gt <- gtable_add_grob( + gt, grobs, + t = c(2, 1, 2, 1), l = c(1, 1, 2, 2) + ) + + vdiffr::expect_doppelganger( + "legend cross orientations", + gt + ) +}) + +test_that("cross legend can be constructed from single scale", { + df <- data.frame( + x = 1:5, y = 1:5, + z = c("A:1", "A:2", "B:2", "C:1", "C:2") + ) + + p <- ggplot(df, aes(x, y, colour = z)) + + geom_point() + + guides(colour = "legend_cross") + + vdiffr::expect_doppelganger( + "legend cross single scale", + p + ) + + p <- ggplot(df, aes(x, y, colour = z)) + + geom_point() + + guides(colour = guide_legend_cross(reverse = c(TRUE, TRUE))) + + vdiffr::expect_doppelganger( + "legend cross with double reverse", + p + ) +}) + +test_that("cross legend can be constructed from dual scales", { + + df <- data.frame( + x = 1:5, y = 1:5, + v = c("A", "A", "B", "C", "C"), + w = c("1", "2", "2", "1", "2") + ) + + guide <- guide_legend_cross(title = "cross legend", key = "auto") + + p <- ggplot(df, aes(x, y, colour = v, shape = w)) + + geom_point() + + scale_colour_discrete(guide = guide) + + scale_shape_discrete(guide = guide) + + vdiffr::expect_doppelganger( + "legend cross two scales", + p + ) + + guide <- guide_legend_cross(title = "cross legend", key = "auto", + swap = TRUE) + + p <- ggplot(df, aes(x, y, colour = v, shape = w)) + + geom_point() + + scale_colour_discrete(guide = guide) + + scale_shape_discrete(guide = guide) + + vdiffr::expect_doppelganger( + "legend cross two scales swapped order", + p + ) +}) + +test_that("merge strategies work as intended", { + + df <- data.frame( + x = 1:5, y = 1:5, + v = c("A", "A", "B", "C", "C"), + w = c("1", "2", "2", "1", "2") + ) + + guide <- guide_legend_cross(title = "cross legend", key = "auto") + + # Uses the 'incomplete' strategy + p <- ggplot(df, aes(x, y, colour = v, shape = w)) + + geom_point() + + scale_colour_discrete(guide = guide) + + scale_shape_discrete(guide = guide) + + build <- ggplot_build(p) + key <- build$plot$guides$get_params(1L)$key + expect_equal(key$.row_label, c("A", "A", "B", "B", "C", "C")) + expect_equal(key$.col_label, c("1", "2", "1", "2", "1", "2")) + + # Uses the 'partial' strategy + p <- ggplot(df, aes(x, y, colour = paste(v, w), shape = w)) + + geom_point() + + scale_colour_discrete(guide = guide_legend_cross(title = "cross legend")) + + scale_shape_discrete(guide = guide) + + build <- ggplot_build(p) + key <- build$plot$guides$get_params(1L)$key + expect_equal(key$.row_label, c("1", "1", "1", "2", "2", "2")) + expect_equal(key$.col_label, factor(c("A", "B", "C", "A", "B", "C"))) + # The B-1 combination does not exist in the data + expect_true(is.na(key$colour[2])) + expect_false(is.na(key$shape[2])) + + # Uses the 'complete' strategy + guide <- guide_legend_cross(title = "cross legend") + p <- ggplot(df, aes(x, y, colour = paste(v, w), shape = paste(v, w))) + + geom_point() + + scale_colour_discrete(guide = guide) + + scale_shape_discrete(guide = guide) + + build <- ggplot_build(p) + key <- build$plot$guides$get_params(1L)$key + expect_equal(key$.row_label, c("1", "1", "1", "2", "2", "2")) + expect_equal(key$.col_label, factor(c("A", "B", "C", "A", "B", "C"))) + # The B-1 combination does not exist in the data + expect_true(is.na(key$colour[2])) + expect_true(is.na(key$shape[2])) + + # Edge cases + a <- data.frame(foo = 1:2, .row_label = 1:2, .col_label = 1:2) + b <- data.frame(bar = 3:4, .row_label = 3:4, .col_label = 3:4) + expect_error(cross_merge_complete(a, b), "Cannot merge") + expect_error(cross_merge_partial(a, b), "Cannot match") + d <- data.frame(qux = 1:2, .label = c("A", "B")) + expect_equal(d, cross_merge_incomplete(d, d)) +}) diff --git a/tests/testthat/test-guide-legend-group.R b/tests/testthat/test-guide-legend-group.R new file mode 100644 index 0000000..87801f6 --- /dev/null +++ b/tests/testthat/test-guide-legend-group.R @@ -0,0 +1,82 @@ +test_that("guide_legend_group works in both direction with all subtitles", { + + df <- msleep[c(9, 28, 11, 5, 34, 54, 24, 53), ] + + base <- ggplot(df, aes(bodywt, awake)) + + geom_point(aes(colour = paste0(order, ".", name))) + + scale_colour_discrete( + name = "Animals", + guide = guide_legend_group(ncol = 2) + ) + + theme_test() + + theme( + legend.key = element_rect(colour = NA, fill = "grey90"), + legend.title.position = "top" + ) + + vdiffr::expect_doppelganger( + "right-toptitle", + base + theme( + legend.position = "right", + legendry.legend.subtitle.position = "top" + ) + ) + + vdiffr::expect_doppelganger( + "right-lefttitle", + base + theme( + legend.position = "right", + legendry.legend.subtitle.position = "left" + ) + ) + + vdiffr::expect_doppelganger( + "right-righttitle", + base + theme( + legend.position = "right", + legendry.legend.subtitle.position = "right" + ) + ) + + vdiffr::expect_doppelganger( + "right-bottomtitle", + base + theme( + legend.position = "right", + legendry.legend.subtitle.position = "bottom" + ) + ) + + vdiffr::expect_doppelganger( + "bottom-toptitle", + base + theme( + legend.position = "bottom", + legendry.legend.subtitle.position = "top" + ) + ) + + vdiffr::expect_doppelganger( + "bottom-lefttitle", + base + theme( + legend.position = "bottom", + legendry.legend.subtitle.position = "left", + legendry.legend.subtitle = element_text(angle = 90, hjust = 1) + ) + ) + + vdiffr::expect_doppelganger( + "bottom-righttitle", + base + theme( + legend.position = "bottom", + legendry.legend.subtitle.position = "right", + legendry.legend.subtitle = element_text(angle = 270) + ) + ) + + vdiffr::expect_doppelganger( + "bottom-bottomtitle", + base + theme( + legend.position = "bottom", + legendry.legend.subtitle.position = "bottom" + ) + ) +}) diff --git a/tests/testthat/test-guide_legend_base.R b/tests/testthat/test-guide_legend_base.R new file mode 100644 index 0000000..2f1279c --- /dev/null +++ b/tests/testthat/test-guide_legend_base.R @@ -0,0 +1,102 @@ + +test_that("guide_legend_base can parse different designs", { + + guide <- guide_legend_base() + expect_null(guide$params$design) + + example_design <- matrix(1:4, nrow = 2, byrow = TRUE) + + guide <- guide_legend_base(design = example_design) + expect_equal(guide$params$design, example_design) + + guide <- guide_legend_base(design = c("AB\nCD")) + expect_equal(guide$params$design, example_design, ignore_attr = TRUE) + + guide <- guide_legend_base(design = 1:4) + expect_equal(guide$params$design, as.matrix(1:4)) + + # Check warning is emitted when there is a conflict + expect_warning( + guide_legend_base(design = example_design, ncol = 2), + "`ncol` argument is ignored" + ) + + # Check error is thrown when design is invalid + expect_error( + guide_legend_base(design = "AB\nCDE"), + "must be rectangular" + ) + +}) + +test_that("design application is correct", { + + data <- data.frame(value = 1:8) + + # Default horizontal + test <- apply_design(data, direction = "horizontal") + expect_equal( + test, + data.frame(value = 1:8, .index = 1:8, .row = rep(1:2, 4), .col = rep(1:4, each = 2L)) + ) + + # Default vertical + test <- apply_design(data, direction = "vertical") + expect_equal( + test, + data.frame(value = 1:8, .index = 1:8, .row = 1:8, .col = rep(1L, 8L)) + ) + + # Vertical with fixed columns + test <- apply_design(data, ncol = 2, direction = "vertical") + expect_equal( + test, + data.frame(value = 1:8, .index = 1:8, .row = rep(1:4, 2), .col = rep(1:2, each = 4L)) + ) + + # Custom design + design <- matrix(c(1:3, 8, NA, 4, 7:5), nrow = 3) + test <- apply_design(data, design = design) + expect_equal( + test, + data.frame( + value = c(1L, 2L, 3L, 8L, 4L, 7L, 6L, 5L), + .index = c(1L, 2L, 3L, 8L, 4L, 7L, 6L, 5L), + .row = c(1L, 2L, 3L, 1L, 3L, 1L, 2L, 3L), + .col = rep(1:3, c(3L, 2L, 3L)) + ) + ) + + # Warning about flawed design + design <- matrix(c(1:3, NA, NA, 4, 7:5), nrow = 3) + expect_warning( + apply_design(data, design = design), + "insufficient levels" + ) + + # Warning about flawed ncol/nrow + expect_warning( + apply_design(data, nrow = 2, ncol = 2), + "insufficient levels" + ) +}) + +test_that("guide_legend_base can draw a custom design", { + + design <- "1#7\n2#6\n345" + + df <- data.frame(x = LETTERS[1:7]) + + p <- ggplot(df, aes(x, x, fill = x)) + + geom_tile() + + vdiffr::expect_doppelganger( + "standard legend design", + p + guides(fill = guide_legend_base()) + ) + + vdiffr::expect_doppelganger( + "custom legend design", + p + guides(fill = guide_legend_base(design = design)) + ) +}) diff --git a/tests/testthat/test-key-group.R b/tests/testthat/test-key-group.R new file mode 100644 index 0000000..c2d444c --- /dev/null +++ b/tests/testthat/test-key-group.R @@ -0,0 +1,72 @@ + +test_that("key_group_split works correctly", { + + scale <- scale_colour_discrete() + scale$train(c("A:B", "C:D", "E:F")) + + # Standard case + test <- key_group_split(sep = ":")(scale, "colour") + expect_equal( + test[c(".label", ".group")], + data.frame(.label = c("B", "D", "F"), .group = factor(c("A", "C", "E"))) + ) + + # Test reverse argument + test <- key_group_split(sep = ":", reverse = TRUE)(scale, "colour") + expect_equal( + test[c(".label", ".group")], + data.frame(.label = c("A", "C", "E"), .group = factor(c("B", "D", "F"))) + ) + + # Missing label + scale <- scale_colour_discrete() + scale$train(c("A", "C:D", "E:F")) + + test <- key_group_split(sep = ":")(scale, "colour") + expect_equal( + test[c(".label", ".group")], + data.frame(.label = c("", "D", "F"), .group = factor(c("A", "C", "E"))) + ) + + # Too many labels + scale <- scale_colour_discrete() + scale$train(c("A:B", "C:D", "E:F:G")) + + test <- key_group_split(sep = ":")(scale, "colour") + expect_equal( + test[c(".label", ".group")], + data.frame(.label = c("B", "D", "F G"), .group = factor(c("A", "C", "E"))) + ) + + # Expression labels + scale <- scale_colour_discrete(labels = expression(A, B, C)) + scale$train(c("A", "B", "C")) + expect_snapshot( + key_group_split(sep = ":")(scale, "colour"), + error = TRUE + ) +}) + +test_that("key_group_lut works as intended", { + + levels <- c("Coffee", "Tea", "Soda", "Water") + groups <- rep(c("Hot drinks", "Cold drinks"), each = 2) + + sc <- scale_colour_discrete() + sc$train(levels) + sc$train("Car") + + key <- key_group_lut(levels, groups) + test <- key(sc, "colour") + + expect_equal(test$.label, c(levels, "Car")) + expect_equal(test$.group, factor(c(groups, "Other"), unique(c(groups, "Other")))) + + # Mismatched lengths + levels <- c("A", "B") + groups <- c("X", "X", "Y") + expect_error( + key_group_lut(levels, groups), + "must have the same length" + ) +})