Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shared legend feature stopped workin with ggplot2 v3.5.0 #202

Open
ycl6 opened this issue Mar 5, 2024 · 13 comments
Open

Shared legend feature stopped workin with ggplot2 v3.5.0 #202

ycl6 opened this issue Mar 5, 2024 · 13 comments

Comments

@ycl6
Copy link

ycl6 commented Mar 5, 2024

A pull request and commit made to ggplot2 last Dec tidyverse/ggplot2#5488 means from v3.5.0 onwards cowplot's shared legends feature will not work as before.

Below is the reproducible demo.

The plot_component_names() that looks for the "guide-box" pattern now returns multiple matches.
Because guide-box-right is the first match returned by plot_component_names(), therefore only the shared right-sided legend works.

library(ggplot2)
library(cowplot)

set.seed(1123)
dsamp = diamonds[sample(nrow(diamonds), 1000), ]

p1 = ggplot(dsamp, aes(carat, price, color = clarity)) +
    geom_point() + theme(legend.position="none")
p2 = ggplot(dsamp, aes(carat, depth, color = clarity)) +
    geom_point() + theme(legend.position="none")

prow = plot_grid(p1, p2, align = 'vh', nrow = 1)

legend1 = get_legend(
  p1 + theme(legend.position = "right")
)
#> Warning in get_plot_component(plot, "guide-box"): Multiple components found;
#> returning the first one. To return all, use `return_all = TRUE`.

plot_grid(prow, legend1, nrow = 1, rel_widths = c(7, 1))

legend2 = get_legend(
    p1 + guides(color = guide_legend(nrow = 1)) + 
            theme(legend.position = "bottom")
)
#> Warning in get_plot_component(plot, "guide-box"): Multiple components found;
#> returning the first one. To return all, use `return_all = TRUE`.

# Bottom legend not showing up
plot_grid(prow, legend2, ncol = 1, rel_heights = c(7, 1))

# get_legend, multiple 'guide-box' matches
# position = "right"
plot1 = as_gtable(p1 + theme(legend.position = "right"))
grob_names1 = plot_component_names(plot1)
grob_names1
#>  [1] "background"       "spacer"           "axis-l"           "spacer"          
#>  [5] "axis-t"           "panel"            "axis-b"           "spacer"          
#>  [9] "axis-r"           "spacer"           "xlab-t"           "xlab-b"          
#> [13] "ylab-l"           "ylab-r"           "guide-box-right"  "guide-box-left"  
#> [17] "guide-box-bottom" "guide-box-top"    "guide-box-inside" "subtitle"        
#> [21] "title"            "caption"
which(grepl("guide-box", grob_names1))
#> [1] 15 16 17 18 19

# position = "bottom"
plot2 = as_gtable(p1 + theme(legend.position = "bottom"))
grob_names2 = plot_component_names(plot2)
grob_names2
#>  [1] "background"       "spacer"           "axis-l"           "spacer"          
#>  [5] "axis-t"           "panel"            "axis-b"           "spacer"          
#>  [9] "axis-r"           "spacer"           "xlab-t"           "xlab-b"          
#> [13] "ylab-l"           "ylab-r"           "guide-box-right"  "guide-box-left"  
#> [17] "guide-box-bottom" "guide-box-top"    "guide-box-inside" "subtitle"        
#> [21] "title"            "caption"
which(grepl("guide-box", grob_names2))
#> [1] 15 16 17 18 19

Created on 2024-03-05 with reprex v2.1.0

@clauswilke
Copy link
Contributor

Thanks for bringing this up. I'm happy to consider a PR that addresses this. I haven't used this feature a lot myself lately and I haven't paid much attention to how ggplot2's legends have changed so I'm not sure what the best approach is. Happy to hear suggestions.

@ycl6
Copy link
Author

ycl6 commented Mar 5, 2024

Hi @clauswilke It now seems overly complicated when combining plots with legends at various locations and still uses the shared legend feature.

If cowplot limits users to use one legend position per plot_grid() joining (like what ggplot2 was before), then it is still possible to use multiple plot_grid() to combine plots and legends to appear in different positions (I think? It's not something I done before).

I have not figured out how to determine from all these arbitrary positions introduced in v3.5.0, which are the actual ones set in guides() or by legend.position in theme() in the plot object. Once this is known, then the pattern used to grep component names can be adapted to grep the correct name.

If users applied more than 1 legend positions, then maybe provides a warning and default position to right, or maybe an error and ask users to re-adjust?

@MarkErik
Copy link

MarkErik commented Mar 5, 2024

I'm also running into this issue, but sadly my knowledge of the inner workings of ggplot and R is limited, so I'm not really sure what potential work-arounds would be for me, other than staying back on ggplot 3.4.

I'm using grid arrange in a Rmd file, where I have legends positioned in the top or bottom.

Here's an example of a visualization where I combine 2 charts, and for one of the charts position the legend in the bottom:

legend_b <- get_legend(gender_worries_plot + theme(legend.position="bottom"))

grid.arrange(title1,class_worries_plot,gender_worries_plot,legend_b,
             layout_matrix = rbind(c(1,1), c(2,3),  c(NA,4)),
             widths = c(1.4,0.8),
             heights = unit.c(grobHeight(title1)+1.3*textmargin, 
                              unit(1,"null"),
                              grobHeight(legend_b)),
             vp=vpscale)
Screen Shot 2024-03-05 at 6 28 22 PM

@clauswilke
Copy link
Contributor

Maybe @teunbrand has a suggestion on what the best way forward is?

Teun, I don't think we need a general solution that works with multiple legends, but if there is only one legend the function should reliably return it regardless of where it is located in the plot.

@clauswilke
Copy link
Contributor

Ok, here is a version of the function that seems to work for the various possible legend positions. It simply returns the first non-zero legend each time.

library(ggplot2)
library(cowplot)

get_legend_35 <- function(plot) {
  # return all legend candidates
  legends <- get_plot_component(plot, "guide-box", return_all = TRUE)
  # find non-zero legends
  nonzero <- vapply(legends, \(x) !inherits(x, "zeroGrob"), TRUE)
  idx <- which(nonzero)
  # return first non-zero legend if exists, and otherwise first element (which will be a zeroGrob) 
  if (length(idx) > 0) {
    return(legends[[idx[1]]])
  } else {
    return(legends[[1]])
  }
}

set.seed(1123)
dsamp = diamonds[sample(nrow(diamonds), 1000), ]

p1 = ggplot(dsamp, aes(carat, price, color = clarity)) +
  geom_point() + theme(legend.position="none")
p2 = ggplot(dsamp, aes(carat, depth, color = clarity)) +
  geom_point() + theme(legend.position="none")

prow = plot_grid(p1, p2, align = 'vh', nrow = 1)

# right legend
legend1 = get_legend_35(
  p1 + theme(legend.position = "right")
)
plot_grid(prow, legend1, nrow = 1, rel_widths = c(7, 1))

# bottom legend
legend2 = get_legend_35(
  p1 + guides(color = guide_legend(nrow = 1)) + 
    theme(legend.position = "bottom")
)
plot_grid(prow, legend2, ncol = 1, rel_heights = c(7, 1))

# no legend (negative control)
legend3 = get_legend_35(
  p1 + guides(color = "none")
)
plot_grid(prow, legend3, ncol = 1, rel_heights = c(7, 1))

Created on 2024-03-06 with reprex v2.0.2

If you guys could try it that would be great. And you can also use it as workaround for now.

@clauswilke
Copy link
Contributor

clauswilke commented Mar 6, 2024

Slightly more general function that can also return a legend other than the first, in case there are multiple ones.

get_legend_35 <- function(plot, legend_number = 1) {
  # find all legend candidates
  legends <- get_plot_component(plot, "guide-box", return_all = TRUE)
  # find non-zero legends
  idx <- which(vapply(legends, \(x) !inherits(x, "zeroGrob"), TRUE))
  # return either the chosen or the first non-zero legend if it exists,
  # and otherwise the first element (which will be a zeroGrob) 
  if (length(idx) >= legend_number) {
    return(legends[[idx[legend_number]]])
  } else if (length(idx) >= 0) {
    return(legends[[idx[1]]])
  } else {
    return(legends[[1]])
  }
}

@teunbrand
Copy link
Contributor

Maybe @teunbrand has a suggestion on what the best way forward is?

The first non-zero legend solution you pointed out seems like a good compromise.
I think getting a legend by position rather than by number might make stuff a little bit more intuitive.

library(ggplot2)

get_legend <- function(plot, legend = NULL) {
  
  gt <- ggplotGrob(plot)
  
  pattern <- "guide-box"
  if (!is.null(legend)) {
    pattern <- paste0(pattern, "-", legend)
  }
  
  indices <- grep(pattern, gt$layout$name)

  not_empty <- !vapply(
    gt$grobs[indices], 
    inherits, what = "zeroGrob", 
    FUN.VALUE = logical(1)
  )
  indices <- indices[not_empty]
  
  if (length(indices) > 0) {
    return(gt$grobs[[indices[1]]])
  }
  return(NULL)
}

p <- ggplot(mpg, aes(displ, hwy, colour = factor(cyl), shape = factor(year))) +
  geom_point() +
  guides(shape = guide_legend(position = "bottom"))

plot(get_legend(p))

plot(get_legend(p, "bottom"))

Created on 2024-03-06 with reprex v2.1.0

plot(get_legend(p, "bottom"))

Created on 2024-03-06 with reprex v2.1.0

@ycl6
Copy link
Author

ycl6 commented Mar 6, 2024

Hi, I tested both functions (#202 (comment) and #202 (comment)), they produced the same output that my function relies on. Thanks!

library(scRUtils)
#> Loading required package: grid

data(sce)

plotProjections(sce, "label", dimname = c("TSNE", "UMAP"), text_by = "label", 
                feat_desc = "Cluster", point_size = 2)

plotProjections(sce, "label", dimname = c("TSNE", "UMAP"), text_by = "label", 
                feat_desc = "Cluster", point_size = 2, legend_pos= "bottom")

Created on 2024-03-06 with reprex v2.1.0

@clauswilke
Copy link
Contributor

Thanks @teunbrand, this makes sense. Could you point me to the complete list of possible legend positions, so I can write the appropriate documentation? Is it right, left, bottom, top, inside, or are there other options?

@teunbrand
Copy link
Contributor

Sure, the possible options are 'guide-box-right', 'guide-box-left', 'guide-box-bottom', 'guide-box-top' and 'guide-box-inside'. They're added in the following lines of ggplot2's source code:

https://github.com/tidyverse/ggplot2/blob/fc62903c76d736510dbed24a36c42ef826762262/R/plot-build.R#L460-L504

@kevinwolz
Copy link

kevinwolz commented Nov 7, 2024

@clauswilke any further thoughts on this? Looks like some activity, but maybe not yet complete as it's not closed? Thanks!

@clauswilke
Copy link
Contributor

Yes, I'm aware, I need to fix this. I plan to get to it once the fall semester is over, early December.

@timbainbridge
Copy link

When I had a grob already, rather than a ggplot, teunbrand's solution didn't work as is. This tweak fixed it,

get_legend2 <- function(plot, legend = NULL) {
  if (is.ggplot(plot)) {
    gt <- ggplotGrob(plot)
  } else {
    if (is.grob(plot)) {
      gt <- plot
    } else {
      stop("Plot object is neither a ggplot nor a grob.")
    }
  }
  pattern <- "guide-box"
  if (!is.null(legend)) {
    pattern <- paste0(pattern, "-", legend)
  }
  indices <- grep(pattern, gt$layout$name)
  not_empty <- !vapply(
    gt$grobs[indices], 
    inherits, what = "zeroGrob", 
    FUN.VALUE = logical(1)
  )
  indices <- indices[not_empty]
  if (length(indices) > 0) {
    return(gt$grobs[[indices[1]]])
  }
  return(NULL)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants