From 2fc7410b6639744c01ed1dd7044393ac4857feef Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Wed, 11 Oct 2023 12:24:01 +0200 Subject: [PATCH 01/14] first draft --- R/guide-axis-stack.R | 149 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 R/guide-axis-stack.R diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R new file mode 100644 index 0000000000..c8b7079d42 --- /dev/null +++ b/R/guide-axis-stack.R @@ -0,0 +1,149 @@ + + +guide_axis_stack <- function(title = waiver(), first = "axis", ..., + spacing = NULL, order = 0, position = waiver()) { + + check_object(spacing, is.unit, "{.cls unit}", allow_null = TRUE) + + # Validate guides + axes <- list2(first, ...) + axes <- lapply(axes, validate_guide) + + # Check available aesthetics + available <- lapply(axes, `[[`, name = "available_aes") + available <- vapply(available, function(x) all(c("x", "y") %in% x), logical(1)) + if (all(!available)) { + cli::cli_abort(paste0( + "{.fn guide_axis_stack} can only use guides that handle {.field x} and ", + "{.field y} aesthetics." + )) + } + + # Remove guides that don't support x/y aesthetics + if (any(!available)) { + remove <- which(!available) + removed <- vapply(axes[remove], snake_class, character(1)) + axes[remove] <- NULL + cli::cli_warn(c(paste0( + "{.fn guide_axis_stack} cannot use the following guide{?s}: ", + "{.and {.fn {removed}}}." + ), i = "Guides need to handle {.field x} and {.field y} aesthetics.")) + } + + params <- lapply(axes, `[[`, name = "params") + + new_guide( + title = title, + guides = axes, + guide_params = params, + available_aes = c("x", "y"), + order = order, + position = position, + name = "stacked_axis", + super = GuideAxisStack + ) +} + +GuideAxisStack <- ggproto( + "GuideAxisStack", GuideAxis, + + params = list( + title = waiver(), + name = "stacked_axis", + guides = list(), + guide_params = list(), + hash = character(), + position = waiver(), + direction = NULL, + order = 0 + ), + + available_aes = c("x", "y"), + + hashables = exprs(title, lapply(guides, snake_class), name), + + train = function(self, params = self$params, scale, aesthetic = NULL, ...) { + position <- arg_match0(params$position, .trbl, arg_nm = "position") + for (i in seq_along(params$guides)) { + params$guide_params[[i]]$position <- position + params$guide_params[[i]] <- params$guides[[i]]$train( + params = params$guide_params[[i]], + scale = scale, aesthetic = aesthetic, + ... + ) + } + params + }, + + transform = function(self, params, coord, panel_params) { + for (i in seq_along(params$guides)) { + params$guide_params[[i]] <- params$guides[[i]]$transform( + params = params$guide_params[[i]], + coord = coord, panel_params = panel_params + ) + } + params + }, + + get_layer_key = function(params, layers) { + for (i in seq_along(params$guides)) { + params$guide_params[[i]] <- params$guides[[i]]$get_layer_key( + params = params$guide_params[[i]], + layers = layers + ) + } + params + }, + + draw = function(self, theme, params = self$params) { + grobs <- list() + for (i in seq_along(params$guides)) { + grobs[[i]] <- params$guides[[i]]$draw(theme, params$guide_params[[i]]) + } + + # Remove empty grobs + grobs <- grobs[!vapply(grobs, is.zero, logical(1))] + if (length(grobs) == 0) { + return(zeroGrob()) + } + along <- seq_along(grobs) + + # Get sizes + widths <- inject(unit.c(!!!lapply(grobs, grobWidth))) + heights <- inject(unit.c(!!!lapply(grobs, grobHeight))) + + # Set spacing + position <- params$position + if (is.null(params$spacing)) { + aes <- if (position %in% c("top", "bottom")) "x" else "y" + spacing <- paste("axis.ticks.length", aes, position, sep = ".") + spacing <- calc_element(spacing, theme) + } else { + spacing <- params$spacing + } + + # Reorder grobs/sizes if necessary + if (position %in% c("top", "left")) { + along <- rev(along) + widths <- rev(widths) + heights <- rev(heights) + } + + if (position %in% c("bottom", "top")) { + gt <- gtable(widths = unit(1, "npc"), heights = heights) + gt <- gtable_add_grob(gt, grobs, t = along, l = 1, name = "axis", clip = "off") + gt <- gtable_add_row_space(gt, height = spacing) + } else { + gt <- gtable(widths = widths, heights = unit(1, "npc")) + gt <- gtable_add_grob(gt, grobs, t = 1, l = along, name = "axis", clip = "off") + gt <- gtable_add_col_space(gt, width = spacing) + } + + absoluteGrob( + grob = gList(gt), + width = gtable_width(gt), + height = gtable_height(gt) + ) + } +) + From e74763b217673a545c7e8c7908899e7e52c6e9d1 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Wed, 11 Oct 2023 12:54:52 +0200 Subject: [PATCH 02/14] Sprinkle some comments --- R/guide-axis-stack.R | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index c8b7079d42..27d2988b59 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -48,10 +48,13 @@ GuideAxisStack <- ggproto( "GuideAxisStack", GuideAxis, params = list( - title = waiver(), - name = "stacked_axis", + # List of guides to track the guide objects guides = list(), + # List of parameters to each guide guide_params = list(), + # Standard guide stuff + name = "stacked_axis", + title = waiver(), hash = character(), position = waiver(), direction = NULL, @@ -60,8 +63,10 @@ GuideAxisStack <- ggproto( available_aes = c("x", "y"), + # Doesn't depend on keys, but on member axis' class hashables = exprs(title, lapply(guides, snake_class), name), + # Sets position, loops through guides to train train = function(self, params = self$params, scale, aesthetic = NULL, ...) { position <- arg_match0(params$position, .trbl, arg_nm = "position") for (i in seq_along(params$guides)) { @@ -75,6 +80,7 @@ GuideAxisStack <- ggproto( params }, + # Just loops through guides transform = function(self, params, coord, panel_params) { for (i in seq_along(params$guides)) { params$guide_params[[i]] <- params$guides[[i]]$transform( @@ -85,6 +91,7 @@ GuideAxisStack <- ggproto( params }, + # Just loops through guides get_layer_key = function(params, layers) { for (i in seq_along(params$guides)) { params$guide_params[[i]] <- params$guides[[i]]$get_layer_key( @@ -96,6 +103,7 @@ GuideAxisStack <- ggproto( }, draw = function(self, theme, params = self$params) { + # Loop through every guide's draw method grobs <- list() for (i in seq_along(params$guides)) { grobs[[i]] <- params$guides[[i]]$draw(theme, params$guide_params[[i]]) @@ -129,6 +137,7 @@ GuideAxisStack <- ggproto( heights <- rev(heights) } + # Place guides in a gtable, apply spacing if (position %in% c("bottom", "top")) { gt <- gtable(widths = unit(1, "npc"), heights = heights) gt <- gtable_add_grob(gt, grobs, t = along, l = 1, name = "axis", clip = "off") From 590f251313c94a3ea2647f7ea4ce8e8e69cc9bfe Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Wed, 11 Oct 2023 12:55:17 +0200 Subject: [PATCH 03/14] roxygenate --- DESCRIPTION | 1 + NAMESPACE | 2 ++ R/guide-axis-stack.R | 39 ++++++++++++++++++++++++--- man/ggplot2-ggproto.Rd | 44 ++++++++++++++++--------------- man/guide_axis_stack.Rd | 58 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 man/guide_axis_stack.Rd diff --git a/DESCRIPTION b/DESCRIPTION index c8aa7ce475..a1bf2f0103 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -175,6 +175,7 @@ Collate: 'grouping.R' 'guide-.R' 'guide-axis.R' + 'guide-axis-stack.R' 'guide-legend.R' 'guide-bins.R' 'guide-colorbar.R' diff --git a/NAMESPACE b/NAMESPACE index 717abb2e18..d584b8325c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -211,6 +211,7 @@ export(GeomViolin) export(GeomVline) export(Guide) export(GuideAxis) +export(GuideAxisStack) export(GuideBins) export(GuideColourbar) export(GuideColoursteps) @@ -418,6 +419,7 @@ export(ggproto_parent) export(ggsave) export(ggtitle) export(guide_axis) +export(guide_axis_stack) export(guide_bins) export(guide_colorbar) export(guide_colorsteps) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index 27d2988b59..1148c59515 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -1,6 +1,35 @@ - - -guide_axis_stack <- function(title = waiver(), first = "axis", ..., +#' @include guide-axis.R +NULL + +#' Stacked axis guides +#' +#' This guide can stack other position guides that represent position scales, +#' like those created with [scale_(x|y)_continuous()][scale_x_continuous()] and +#' [scale_(x|y)_discrete()][scale_x_discrete()]. +#' +#' @inheritParams guide_axis +#' @param first A position guide given as one of the following: +#' * A string, for example `"axis"`. +#' * A call to a guide function, for example `guide_axis()`. +#' @param ... Additional guides to stack given in the same manner as `first`. +#' @param spacing A [unit()] objects that determines how far separate guides are +#' spaced apart. +#' +#' @details +#' The `first` guide will be placed closest to the panel and any subsequent +#' guides provided through `...` will follow in the given order. +#' +#' @export +#' +#' @examples +#' #' # A standard plot +#' p <- ggplot(mpg, aes(displ, hwy)) + +#' geom_point() + +#' theme(axis.line = element_line()) +#' +#' # A normal axis first, then a capped axis +#' p + guides(x = guide_axis_stack("axis", guide_axis(cap = "both"))) +guide_axis_stack <- function(first = "axis", ..., title = waiver(), spacing = NULL, order = 0, position = waiver()) { check_object(spacing, is.unit, "{.cls unit}", allow_null = TRUE) @@ -44,6 +73,10 @@ guide_axis_stack <- function(title = waiver(), first = "axis", ..., ) } +#' @rdname ggplot2-ggproto +#' @format NULL +#' @usage NULL +#' @export GuideAxisStack <- ggproto( "GuideAxisStack", GuideAxis, diff --git a/man/ggplot2-ggproto.Rd b/man/ggplot2-ggproto.Rd index 37a042dd68..d1c233e008 100644 --- a/man/ggplot2-ggproto.Rd +++ b/man/ggplot2-ggproto.Rd @@ -4,27 +4,28 @@ % R/geom-raster.R, R/annotation-raster.R, R/axis-secondary.R, R/coord-.R, % R/coord-cartesian-.R, R/coord-fixed.R, R/coord-flip.R, R/coord-map.R, % R/coord-polar.R, R/coord-quickmap.R, R/coord-transform.R, R/facet-.R, -% R/facet-grid-.R, R/facet-null.R, R/facet-wrap.R, R/stat-.R, R/geom-abline.R, -% R/geom-rect.R, R/geom-bar.R, R/geom-blank.R, R/geom-boxplot.R, R/geom-col.R, -% R/geom-path.R, R/geom-contour.R, R/geom-crossbar.R, R/geom-segment.R, -% R/geom-curve.R, R/geom-ribbon.R, R/geom-density.R, R/geom-density2d.R, -% R/geom-dotplot.R, R/geom-errorbar.R, R/geom-errorbarh.R, R/geom-function.R, -% R/geom-hex.R, R/geom-hline.R, R/geom-label.R, R/geom-linerange.R, -% R/geom-point.R, R/geom-pointrange.R, R/geom-quantile.R, R/geom-rug.R, -% R/geom-smooth.R, R/geom-spoke.R, R/geom-text.R, R/geom-tile.R, -% R/geom-violin.R, R/geom-vline.R, R/guide-.R, R/guide-axis.R, -% R/guide-legend.R, R/guide-bins.R, R/guide-colorbar.R, R/guide-colorsteps.R, -% R/guide-none.R, R/guide-old.R, R/layout.R, R/position-.R, -% R/position-dodge.R, R/position-dodge2.R, R/position-identity.R, -% R/position-jitter.R, R/position-jitterdodge.R, R/position-nudge.R, -% R/position-stack.R, R/scale-.R, R/scale-binned.R, R/scale-continuous.R, -% R/scale-date.R, R/scale-discrete-.R, R/scale-identity.R, R/stat-align.R, -% R/stat-bin.R, R/stat-bin2d.R, R/stat-bindot.R, R/stat-binhex.R, -% R/stat-boxplot.R, R/stat-contour.R, R/stat-count.R, R/stat-density-2d.R, -% R/stat-density.R, R/stat-ecdf.R, R/stat-ellipse.R, R/stat-function.R, -% R/stat-identity.R, R/stat-qq-line.R, R/stat-qq.R, R/stat-quantilemethods.R, -% R/stat-smooth.R, R/stat-sum.R, R/stat-summary-2d.R, R/stat-summary-bin.R, -% R/stat-summary-hex.R, R/stat-summary.R, R/stat-unique.R, R/stat-ydensity.R +% R/facet-grid-.R, R/facet-null.R, R/facet-wrap.R, R/stat-.R, +% R/geom-abline.R, R/geom-rect.R, R/geom-bar.R, R/geom-blank.R, +% R/geom-boxplot.R, R/geom-col.R, R/geom-path.R, R/geom-contour.R, +% R/geom-crossbar.R, R/geom-segment.R, R/geom-curve.R, R/geom-ribbon.R, +% R/geom-density.R, R/geom-density2d.R, R/geom-dotplot.R, R/geom-errorbar.R, +% R/geom-errorbarh.R, R/geom-function.R, R/geom-hex.R, R/geom-hline.R, +% R/geom-label.R, R/geom-linerange.R, R/geom-point.R, R/geom-pointrange.R, +% R/geom-quantile.R, R/geom-rug.R, R/geom-smooth.R, R/geom-spoke.R, +% R/geom-text.R, R/geom-tile.R, R/geom-violin.R, R/geom-vline.R, R/guide-.R, +% R/guide-axis.R, R/guide-axis-stack.R, R/guide-legend.R, R/guide-bins.R, +% R/guide-colorbar.R, R/guide-colorsteps.R, R/guide-none.R, R/guide-old.R, +% R/layout.R, R/position-.R, R/position-dodge.R, R/position-dodge2.R, +% R/position-identity.R, R/position-jitter.R, R/position-jitterdodge.R, +% R/position-nudge.R, R/position-stack.R, R/scale-.R, R/scale-binned.R, +% R/scale-continuous.R, R/scale-date.R, R/scale-discrete-.R, +% R/scale-identity.R, R/stat-align.R, R/stat-bin.R, R/stat-bin2d.R, +% R/stat-bindot.R, R/stat-binhex.R, R/stat-boxplot.R, R/stat-contour.R, +% R/stat-count.R, R/stat-density-2d.R, R/stat-density.R, R/stat-ecdf.R, +% R/stat-ellipse.R, R/stat-function.R, R/stat-identity.R, R/stat-qq-line.R, +% R/stat-qq.R, R/stat-quantilemethods.R, R/stat-smooth.R, R/stat-sum.R, +% R/stat-summary-2d.R, R/stat-summary-bin.R, R/stat-summary-hex.R, +% R/stat-summary.R, R/stat-unique.R, R/stat-ydensity.R \docType{data} \name{ggplot2-ggproto} \alias{ggplot2-ggproto} @@ -89,6 +90,7 @@ \alias{GeomVline} \alias{Guide} \alias{GuideAxis} +\alias{GuideAxisStack} \alias{GuideLegend} \alias{GuideBins} \alias{GuideColourbar} diff --git a/man/guide_axis_stack.Rd b/man/guide_axis_stack.Rd new file mode 100644 index 0000000000..63ae75b003 --- /dev/null +++ b/man/guide_axis_stack.Rd @@ -0,0 +1,58 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/guide-axis-stack.R +\name{guide_axis_stack} +\alias{guide_axis_stack} +\title{Stacked axis guides} +\usage{ +guide_axis_stack( + first = "axis", + ..., + title = waiver(), + spacing = NULL, + order = 0, + position = waiver() +) +} +\arguments{ +\item{first}{A position guide given as one of the following: +\itemize{ +\item A string, for example \code{"axis"}. +\item A call to a guide function, for example \code{guide_axis()}. +}} + +\item{...}{Additional guides to stack given in the same manner as \code{first}.} + +\item{title}{A character string or expression indicating a title of guide. +If \code{NULL}, the title is not shown. By default +(\code{\link[=waiver]{waiver()}}), the name of the scale object or the name +specified in \code{\link[=labs]{labs()}} is used for the title.} + +\item{spacing}{A \code{\link[=unit]{unit()}} objects that determines how far separate guides are +spaced apart.} + +\item{order}{A positive \code{integer} of length 1 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 0 (default), +the order is determined by a secret algorithm.} + +\item{position}{Where this guide should be drawn: one of top, bottom, +left, or right.} +} +\description{ +This guide can stack other position guides that represent position scales, +like those created with \link[=scale_x_continuous]{scale_(x|y)_continuous()} and +\link[=scale_x_discrete]{scale_(x|y)_discrete()}. +} +\details{ +The \code{first} guide will be placed closest to the panel and any subsequent +guides provided through \code{...} will follow in the given order. +} +\examples{ +#' # A standard plot +p <- ggplot(mpg, aes(displ, hwy)) + + geom_point() + + theme(axis.line = element_line()) + +# A normal axis first, then a capped axis +p + guides(x = guide_axis_stack("axis", guide_axis(cap = "both"))) +} From d23f78a225064dab12899a054d5fac33ee54042e Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:41:52 +0200 Subject: [PATCH 04/14] Add test --- tests/testthat/_snaps/guides/stacked-axes.svg | 148 ++++++++++++++++++ tests/testthat/test-guides.R | 14 ++ 2 files changed, 162 insertions(+) create mode 100644 tests/testthat/_snaps/guides/stacked-axes.svg diff --git a/tests/testthat/_snaps/guides/stacked-axes.svg b/tests/testthat/_snaps/guides/stacked-axes.svg new file mode 100644 index 0000000000..6d66656927 --- /dev/null +++ b/tests/testthat/_snaps/guides/stacked-axes.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +100 +200 +300 + + + + +100 +200 +300 + +100 +200 +300 +400 + + + + + +100 +200 +300 +400 + + + + + + + + + +100 +200 +300 +400 + + + + + +100 +200 +300 +400 + + + + +100 +200 +300 + + + + +100 +200 +300 +top +bottom +left +right +stacked axes + + diff --git a/tests/testthat/test-guides.R b/tests/testthat/test-guides.R index 65ff1a6b4d..155ed43fe1 100644 --- a/tests/testthat/test-guides.R +++ b/tests/testthat/test-guides.R @@ -524,6 +524,20 @@ test_that("axis guides can be capped", { expect_doppelganger("axis guides with capped ends", p) }) +test_that("guide_axis_stack stacks axes", { + + left <- guide_axis_stack("axis", guide_axis(cap = "both"), title = "left") + right <- guide_axis_stack("axis", guide_axis(cap = "both"), title = "right") + bottom <- guide_axis_stack("axis", guide_axis(cap = "both"), title = "bottom") + top <- guide_axis_stack("axis", guide_axis(cap = "both"), title = "top") + + p <- ggplot(mtcars, aes(hp, disp)) + + geom_point() + + theme(axis.line = element_line()) + + guides(x = bottom, x.sec = top, y = left, y.sec = right) + expect_doppelganger("stacked axes", p) +}) + test_that("guides are positioned correctly", { df <- data_frame(x = 1, y = 1, z = factor("a")) From 26c6c4cb6ab66a16ecae819928eaba5de71deed2 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Tue, 31 Oct 2023 12:19:09 +0100 Subject: [PATCH 05/14] Add pkgdown item --- _pkgdown.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/_pkgdown.yml b/_pkgdown.yml index 7dbedc3062..3b673b89d7 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -126,6 +126,7 @@ reference: - guide_colourbar - guide_legend - guide_axis + - guide_axis_stack - guide_bins - guide_coloursteps - guide_none From 5cbb5aeb33f78ac1e6f877e8dc85d6707acf66d5 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Tue, 31 Oct 2023 12:58:21 +0100 Subject: [PATCH 06/14] pass along position/direction --- R/guide-axis-stack.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index 1148c59515..aac66999aa 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -135,11 +135,15 @@ GuideAxisStack <- ggproto( params }, - draw = function(self, theme, params = self$params) { + draw = function(self, theme, position = NULL, direction = NULL, + params = self$params) { # Loop through every guide's draw method grobs <- list() for (i in seq_along(params$guides)) { - grobs[[i]] <- params$guides[[i]]$draw(theme, params$guide_params[[i]]) + grobs[[i]] <- params$guides[[i]]$draw( + theme, position = position, direction = direction, + params = params$guide_params[[i]] + ) } # Remove empty grobs From ff27cc6c461e2cbb4fc91398626c79b033d855f9 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:28:32 +0100 Subject: [PATCH 07/14] measure size of theta axes --- R/guide-axis-theta.R | 65 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/R/guide-axis-theta.R b/R/guide-axis-theta.R index 22a2db06a1..16c006beed 100644 --- a/R/guide-axis-theta.R +++ b/R/guide-axis-theta.R @@ -215,7 +215,63 @@ GuideAxisTheta <- ggproto( # we don't need to measure grob sizes nor arrange the layout. # There is a fallback in `$assemble_drawing()` that takes care of this # for non-polar coordinates. - NULL + if (is.null(params$stack_offset)) { + return(NULL) + } + + # However, when this guide is part of a stacked axis guide, we need to + # know the width of the 'ring' that this guide occupies to correctly + # position the next guide + + offset <- convertUnit(elements$offset, "cm", valueOnly = TRUE) + + key <- params$key + key <- vec_slice(key, !is.na(key$.label) & nzchar(key$.label)) + labels <- key$.label + if (length(labels) == 0 || inherits(elements$text, "element_blank")) { + return(list(offset = offset)) + } + + # Resolve text angle + if (is.waive(params$angle %||% waiver())) { + angle <- elements$text$angle + } else { + angle <- flip_text_angle(params$angle - rad2deg(key$theta)) + } + angle <- key$theta + deg2rad(angle) + + # Set margin + margin <- rep(max(elements$text$margin), length.out = 4) + + # Measure size of each individual label + single_labels <- lapply(labels, function(lab) { + element_grob( + elements$text, label = lab, + margin = margin, margin_x = TRUE, margin_y = TRUE + ) + }) + widths <- width_cm(single_labels) + heights <- height_cm(single_labels) + + # Set text justification + hjust <- 0.5 - sin(angle) / 2 + vjust <- 0.5 - cos(angle) / 2 + + # Calculate text bounding box + xmin <- widths * -hjust + xmax <- widths * (1 - hjust) + + ymin <- heights * -vjust + ymax <- heights * (1 - vjust) + + # Convert to corner coordinates + x <- vec_interleave(xmin, xmin, xmax, xmax) + y <- vec_interleave(ymin, ymax, ymax, ymin) + + # Rotate y coordinate to get maximum height + rotate <- rep(angle, each = 4) + height <- x * sin(rotate) + y * cos(rotate) + list(offset = max(height)) }, arrange_layout = function(key, sizes, params) { @@ -223,8 +279,13 @@ GuideAxisTheta <- ggproto( }, assemble_drawing = function(grobs, layout, sizes, params, elements) { + if (params$position %in% c("theta", "theta.sec")) { - return(inject(grobTree(!!!grobs))) + # We append an 'offset' slot in case this guide is part + # of a stacked guide + grobs <- inject(gList(!!!grobs)) + offset <- unit(sizes$offset %||% 0, "cm") + return(gTree(offset = offset, children = grobs)) } # As a fallback, we adjust the viewport to act like regular axes. From 33e29c1dd6ced7358c348ea6991a1fa661764c1f Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:30:10 +0100 Subject: [PATCH 08/14] stacked axis is valid theta axis --- R/guide-axis-stack.R | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index aac66999aa..8f9b877808 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -65,7 +65,7 @@ guide_axis_stack <- function(first = "axis", ..., title = waiver(), title = title, guides = axes, guide_params = params, - available_aes = c("x", "y"), + available_aes = c("x", "y", "theta", "r"), order = order, position = position, name = "stacked_axis", @@ -94,14 +94,14 @@ GuideAxisStack <- ggproto( order = 0 ), - available_aes = c("x", "y"), + available_aes = c("x", "y", "theta", "r"), # Doesn't depend on keys, but on member axis' class hashables = exprs(title, lapply(guides, snake_class), name), # Sets position, loops through guides to train train = function(self, params = self$params, scale, aesthetic = NULL, ...) { - position <- arg_match0(params$position, .trbl, arg_nm = "position") + position <- arg_match0(params$position, c(.trbl, "theta"), arg_nm = "position") for (i in seq_along(params$guides)) { params$guide_params[[i]]$position <- position params$guide_params[[i]] <- params$guides[[i]]$train( From 48fcc6c2f39f941235bb9583cbf7b570216b7451 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:31:54 +0100 Subject: [PATCH 09/14] stack theta axes --- R/guide-axis-stack.R | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index 8f9b877808..a104f38269 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -137,6 +137,36 @@ GuideAxisStack <- ggproto( draw = function(self, theme, position = NULL, direction = NULL, params = self$params) { + + position <- params$position %||% position + direction <- params$direction %||% direction + + if (params$position == "theta") { + # If we are a theta guide, we need to keep track how much space in the + # radial direction a guide occupies, and add that as an offset to the + # next guide. + offset <- unit(0, "cm") + spacing <- params$spacing %||% unit(2.25, "pt") + grobs <- list() + for (i in seq_along(params$guides)) { + # Add offset to params + pars <- params$guide_params[[i]] + pars$stack_offset <- offset + # Draw guide + grobs[[i]] <- params$guides[[i]]$draw( + theme, position = position, direction = direction, + params = pars + ) + # Increment offset + if (!is.null(grobs[[i]]$offset)) { + offset <- offset + spacing + grobs[[i]]$offset + offset <- convertUnit(offset, "cm") + } + } + grob <- inject(grobTree(!!!grobs)) + return(grob) + } + # Loop through every guide's draw method grobs <- list() for (i in seq_along(params$guides)) { @@ -158,7 +188,6 @@ GuideAxisStack <- ggproto( heights <- inject(unit.c(!!!lapply(grobs, grobHeight))) # Set spacing - position <- params$position if (is.null(params$spacing)) { aes <- if (position %in% c("top", "bottom")) "x" else "y" spacing <- paste("axis.ticks.length", aes, position, sep = ".") From 2d913b332a15c987ddb7296bbd7dae12a311e086 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:33:15 +0100 Subject: [PATCH 10/14] incorporate offset into theta guide --- R/guide-axis-theta.R | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/R/guide-axis-theta.R b/R/guide-axis-theta.R index 16c006beed..ce0c527deb 100644 --- a/R/guide-axis-theta.R +++ b/R/guide-axis-theta.R @@ -158,6 +158,21 @@ GuideAxisTheta <- ggproto( elements }, + build_decor = function(decor, grobs, elements, params) { + if (is.null(params$stack_offset) || !("theta" %in% names(decor))) { + # Just use regular method if we do not need to offset the guide + decor <- GuideAxis$build_decor(decor, grobs, elements, params) + return(decor) + } + if (empty(decor)) { + return(zeroGrob()) + } + # Add the stacking offset to positions + x <- unit(decor$x, "npc") + sin(decor$theta) * params$stack_offset + y <- unit(decor$y, "npc") + cos(decor$theta) * params$stack_offset + element_grob(elements$line, x = x, y = y) + }, + build_labels = function(key, elements, params) { key <- vec_slice(key, !vec_detect_missing(key$.label %||% NA)) @@ -179,9 +194,15 @@ GuideAxisTheta <- ggproto( # Position angle in radians theta <- key$theta + # Add the stacking offset if necessary + offset <- elements$offset + if (!is.null(params$stack_offset)) { + offset <- offset + params$stack_offset + } + # Offset distance to displace text away from outer circle line - xoffset <- elements$offset * sin(theta) - yoffset <- elements$offset * cos(theta) + xoffset <- offset * sin(theta) + yoffset <- offset * cos(theta) # Note that element_grob expects 1 angle for *all* labels, so we're # rendering one grob per label to propagate angle properly @@ -197,14 +218,14 @@ GuideAxisTheta <- ggproto( }, build_ticks = function(key, elements, params, position = params$position) { - + offset <- params$stack_offset major <- theta_tickmarks( vec_slice(key, (key$.type %||% "major") == "major"), - elements$ticks, elements$major_length + elements$ticks, elements$major_length, offset = offset ) minor <- theta_tickmarks( vec_slice(key, (key$.type %||% "major") == "minor"), - elements$minor, elements$minor_length + elements$minor, elements$minor_length, offset = offset ) grobTree(major, minor, name = "ticks") @@ -320,7 +341,7 @@ GuideAxisTheta <- ggproto( } ) -theta_tickmarks <- function(key, element, length) { +theta_tickmarks <- function(key, element, length, offset = NULL) { n_breaks <- nrow(key) if (n_breaks < 1 || inherits(element, "element_blank")) { return(zeroGrob()) @@ -331,6 +352,9 @@ theta_tickmarks <- function(key, element, length) { x <- rep(key$x, each = 2) y <- rep(key$y, each = 2) length <- rep(c(0, 1), times = n_breaks) * length + if (!is.null(offset)) { + length <- length + offset + } minor <- element_grob( element, From a150b6a95e2fdfb1784e54cd86963a3a5f27494c Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:34:54 +0100 Subject: [PATCH 11/14] fix angle/justification for radial axes --- R/guide-axis-stack.R | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index a104f38269..37367db79a 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -88,6 +88,7 @@ GuideAxisStack <- ggproto( # Standard guide stuff name = "stacked_axis", title = waiver(), + angle = waiver(), hash = character(), position = waiver(), direction = NULL, @@ -104,6 +105,7 @@ GuideAxisStack <- ggproto( position <- arg_match0(params$position, c(.trbl, "theta"), arg_nm = "position") for (i in seq_along(params$guides)) { params$guide_params[[i]]$position <- position + params$guide_params[[i]]$angle <- params$guide_params[[i]]$angle %|W|% params$angle params$guide_params[[i]] <- params$guides[[i]]$train( params = params$guide_params[[i]], scale = scale, aesthetic = aesthetic, @@ -208,16 +210,29 @@ GuideAxisStack <- ggproto( gt <- gtable(widths = unit(1, "npc"), heights = heights) gt <- gtable_add_grob(gt, grobs, t = along, l = 1, name = "axis", clip = "off") gt <- gtable_add_row_space(gt, height = spacing) + vp <- exec( + viewport, + y = unit(as.numeric(position == "bottom"), "npc"), + height = grobHeight(gt), + just = opposite_position(position) + ) } else { gt <- gtable(widths = widths, heights = unit(1, "npc")) gt <- gtable_add_grob(gt, grobs, t = 1, l = along, name = "axis", clip = "off") gt <- gtable_add_col_space(gt, width = spacing) + vp <- exec( + viewport, + x = unit(as.numeric(position == "left"), "npc"), + width = grobWidth(gt), + just = opposite_position(position) + ) } absoluteGrob( grob = gList(gt), width = gtable_width(gt), - height = gtable_height(gt) + height = gtable_height(gt), + vp = vp ) } ) From a7db4cabee18a4807b52716e5f9c8c4d70418b6d Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:50:32 +0100 Subject: [PATCH 12/14] enable theta.sec --- R/guide-axis-stack.R | 7 +++++-- R/guide-axis-theta.R | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/R/guide-axis-stack.R b/R/guide-axis-stack.R index 37367db79a..2fdd73b34e 100644 --- a/R/guide-axis-stack.R +++ b/R/guide-axis-stack.R @@ -102,7 +102,10 @@ GuideAxisStack <- ggproto( # Sets position, loops through guides to train train = function(self, params = self$params, scale, aesthetic = NULL, ...) { - position <- arg_match0(params$position, c(.trbl, "theta"), arg_nm = "position") + position <- arg_match0( + params$position, c(.trbl, "theta", "theta.sec"), + arg_nm = "position" + ) for (i in seq_along(params$guides)) { params$guide_params[[i]]$position <- position params$guide_params[[i]]$angle <- params$guide_params[[i]]$angle %|W|% params$angle @@ -143,7 +146,7 @@ GuideAxisStack <- ggproto( position <- params$position %||% position direction <- params$direction %||% direction - if (params$position == "theta") { + if (position %in% c("theta", "theta.sec")) { # If we are a theta guide, we need to keep track how much space in the # radial direction a guide occupies, and add that as an offset to the # next guide. diff --git a/R/guide-axis-theta.R b/R/guide-axis-theta.R index ce0c527deb..5cafb09b69 100644 --- a/R/guide-axis-theta.R +++ b/R/guide-axis-theta.R @@ -167,6 +167,9 @@ GuideAxisTheta <- ggproto( if (empty(decor)) { return(zeroGrob()) } + if (params$position == "theta.sec") { + decor$theta <- decor$theta + pi + } # Add the stacking offset to positions x <- unit(decor$x, "npc") + sin(decor$theta) * params$stack_offset y <- unit(decor$y, "npc") + cos(decor$theta) * params$stack_offset From 247ff9144af0a201fd3a3e49264aa4a6f0404ef3 Mon Sep 17 00:00:00 2001 From: Teun van den Brand <49372158+teunbrand@users.noreply.github.com> Date: Fri, 24 Nov 2023 20:06:51 +0100 Subject: [PATCH 13/14] add radial test --- .../_snaps/guides/stacked-radial-axes.svg | 143 ++++++++++++++++++ tests/testthat/test-guides.R | 11 ++ 2 files changed, 154 insertions(+) create mode 100644 tests/testthat/_snaps/guides/stacked-radial-axes.svg diff --git a/tests/testthat/_snaps/guides/stacked-radial-axes.svg b/tests/testthat/_snaps/guides/stacked-radial-axes.svg new file mode 100644 index 0000000000..240e16d958 --- /dev/null +++ b/tests/testthat/_snaps/guides/stacked-radial-axes.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +100 +200 +300 + + + + +100 +200 +300 + + + + +100 +200 +300 + + + + +100 +200 +300 + + + + + +100 +200 +300 +400 + + + + + +100 +200 +300 +400 + + + + + + + + + +100 +200 +300 +400 + + + + + +100 +200 +300 +400 +hp +left +right +stacked radial axes + + diff --git a/tests/testthat/test-guides.R b/tests/testthat/test-guides.R index 6f2f7adcd8..9c1e621a56 100644 --- a/tests/testthat/test-guides.R +++ b/tests/testthat/test-guides.R @@ -622,6 +622,17 @@ test_that("guide_axis_stack stacks axes", { theme(axis.line = element_line()) + guides(x = bottom, x.sec = top, y = left, y.sec = right) expect_doppelganger("stacked axes", p) + + bottom <- guide_axis_stack("axis_theta", guide_axis_theta(cap = "both")) + top <- guide_axis_stack("axis_theta", guide_axis_theta(cap = "both")) + + p <- ggplot(mtcars, aes(hp, disp)) + + geom_point() + + theme(axis.line = element_line()) + + coord_radial(start = 0.25 * pi, end = 1.75 * pi, donut = 0.5) + + guides(theta = top, theta.sec = bottom, r = left, r.sec = right) + expect_doppelganger("stacked radial axes", p) + }) test_that("logticks look as they should", { From 6b101b1978cb9a8ae4df5af09a694549e63a6eb0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 6 Dec 2023 09:56:33 +0100 Subject: [PATCH 14/14] Add news bullet --- NEWS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS.md b/NEWS.md index 96ca2cdf69..992d5cc395 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # ggplot2 (development version) +* New `guide_axis_stack()` to combine other axis guides on top of one another. + * New `guide_custom()` function for drawing custom graphical objects (grobs) unrelated to scales in legend positions (#5416).