wilkelab / cowplot

cowplot: Streamlined Plot Theme and Plot Annotations for ggplot2
https://wilkelab.org/cowplot/
703 stars 84 forks source link

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

Open ycl6 opened 7 months ago

ycl6 commented 7 months ago

A pull request and commit made to ggplot2 last Dec https://github.com/tidyverse/ggplot2/pull/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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

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 commented 7 months ago

Hi, I tested both functions (https://github.com/wilkelab/cowplot/issues/202#issuecomment-1981765769 and https://github.com/wilkelab/cowplot/issues/202#issuecomment-1981802127), 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 commented 7 months ago

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 commented 7 months ago

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