thomasp85 / patchwork

The Composer of ggplots
https://patchwork.data-imaginist.com
Other
2.48k stars 163 forks source link

Arrange legends, collect specific aesthetics. #288

Open iferres opened 2 years ago

iferres commented 2 years ago

Hi, I was wondering if it is possible to collect only specific legends and manipulate their positions independently. For instance:

library(ggplot2)
library(dplyr)
library(magrittr)

g1 <- mpg %>%
  mutate(class = as.factor(class)) %>%
  filter(class == "compact") %>%
  ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) + 
  geom_point(size = 3) + 
  scale_color_discrete(drop = FALSE) +
  ggtitle("Compact")

g2 <- mpg %>%
  mutate(class = as.factor(class)) %>%
  filter(class == "midsize") %>%
  ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) + 
  geom_point(size = 3) +
  scale_color_discrete(drop = FALSE) +
  ggtitle("Midsize")

library(patchwork)
g1 + 
  g2 +
  plot_layout(guides = "collect") &
  theme_bw() +
  theme(legend.position = "bottom")

imagen

In the above example I would like to collect the color scale only, and set it at a side (... + theme(legend.position = "right")), whereas leaving the alpha scales at the bottom of each plot (... + theme(legend.position = "bottom")),.

Is it possible?

I didn't use facets in this case on purpose, it is just a reprex to illustrate my question.

Thank you for this amazing package, btw.

twest820 commented 2 years ago

+1 on this. I've a case where each plot requires a distinct color legend but they all share linetypes. In the interest of readability and space efficiency it's thus desirable to collect the linetype legend but, so far, nothing I've tried has been successful. Right now (patchwork 1.1.2) it looks like there's not support for theme() statements at different scopes even though the documentation reads like guides = "auto" could in theory figure out the color legends aren't collectable while the linetypes are. However, to get a truly good looking layout I'd also need both plot and panel level theme(legend.position = '...') statements, which isn't currently supported per the documentation.

(What I have is actually more complicated than this, in that it'd be ideal to collect two of three linetypes while leaving the third integrated with the color legend, but I'm fairly sure that's not supported by ggplot.)

Marc-Ruebsam commented 1 year ago

I was looking for a way to combine legends in a similar way it is possible to combine plots. The only solution I could think of is to separate the legends from the plots and assemble them individually. The "separate" part could probably be improved (had to hack around with coord_cartesian() and override.aes() to get rid of the plotting data), but here is a solution that at least works:

## libraries
library(ggplot2)
library(dplyr)
library(magrittr)
library(patchwork)

## plots without points
g1 <-
  mpg %>%
  mutate(class = as.factor(class)) %>%
  filter(class == "compact") %>%
  ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) + 
  scale_color_discrete(drop = FALSE) +
  ggtitle("Compact")
g2 <-
  mpg %>%
  mutate(class = as.factor(class)) %>%
  filter(class == "midsize") %>%
  ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) + 
  scale_color_discrete(drop = FALSE) +
  ggtitle("Midsize")
## plots with points without legends
g1_point <-
  g1 +
  geom_point( size = 3 ) +
  theme( legend.position = "none" )
g2_point <-
  g2 +
  geom_point( size = 3 ) +
  theme( legend.position = "none" )
## legends without points
g1_alpha <-
  g1 +
  geom_point( size = 0, stroke = 0 ) +
  coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) +
  guides( color = "none", alpha = guide_legend( override.aes = list( size = 3 ) ) ) +
  theme(
    plot.background = element_blank(),
    panel.background = element_blank(),
    axis.title = element_blank(),
    axis.text = element_blank(),
    axis.ticks = element_blank(),
    legend.box.spacing = unit( 0, "pt" ),
    legend.direction = "horizontal",
    legend.position = "bottom",
    panel.grid = element_blank(),
    plot.title = element_blank(),
    plot.subtitle = element_blank(),
    plot.caption = element_blank(),
    plot.tag = element_blank(),
    strip.background = element_blank(),
    strip.text = element_blank() )
g2_alpha <-
  g2 +
  geom_point( size = 0, stroke = 0 ) +
  coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) +
  guides( color = "none", alpha = guide_legend( override.aes = list( size = 3 ) ) ) +
  theme(
    plot.background = element_blank(),
    panel.background = element_blank(),
    axis.title = element_blank(),
    axis.text = element_blank(),
    axis.ticks = element_blank(),
    legend.box.spacing = unit( 0, "pt" ),
    legend.direction = "horizontal",
    legend.position = "bottom",
    panel.grid = element_blank(),
    plot.title = element_blank(),
    plot.subtitle = element_blank(),
    plot.caption = element_blank(),
    plot.tag = element_blank(),
    strip.background = element_blank(),
    strip.text = element_blank() )
g1_color <-
  g1 +
  geom_point( size = 0, stroke = 0 ) +
  coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) +
  guides( color = guide_legend( override.aes = list( size = 3 ) ), alpha = "none" ) +
  theme(
    plot.background = element_blank(),
    panel.background = element_blank(),
    axis.title = element_blank(),
    axis.text = element_blank(),
    axis.ticks = element_blank(),
    legend.box.spacing = unit( 0, "pt" ),
    panel.grid = element_blank(),
    plot.title = element_blank(),
    plot.subtitle = element_blank(),
    plot.caption = element_blank(),
    plot.tag = element_blank(),
    strip.background = element_blank(),
    strip.text = element_blank() )
## plotting
g1_point +
g2_point +
g1_color +
g1_alpha +
g2_alpha +
plot_spacer() +
plot_layout( ncol = 3, height = c(1,0), width = c(1,1,0), guides = "keep" )

image

Marc-Ruebsam commented 1 year ago

My problem is about arranging multiple legends. Taking a similar example to before:

## setup
dataMPG <- mpg %>% filter( trans == "auto(l4)" ) %>% mutate(across( where(is.character), ~ factor(.x) ))
pMPG <- ggplot( dataMPG, aes( x = displ, y = cty ) )
## individual plots
pMPG_color <- pMPG + geom_point( aes( color = manufacturer ) )
pMPG_shape <- pMPG + geom_point( aes( shape = drv ) ) + scale_shape_manual( values = c(21,22,23) )
pMPG_size <- pMPG + geom_point( aes( size = hwy ) )
pMPG_alpha <- pMPG + geom_point( aes( alpha = cyl ) )

If you have many legends (can be multiple from one plot or from multiple plots using guides = "collect"), there is no good way to arrange them. The default behavior tries it's best, but often plots large parts of the legends outside the plotting area and leaves a lot of valuable space empty. Using direction, nrow or ncol in guide_legend() and legend.direction or legend.box in theme() improves the result, but really we need a way to arrange legends like plots in patchwork.

## plotting attempt
pMPG_color + guides( color = guide_legend( ncol = 1 ) ) +
pMPG_shape +                                                                                      
pMPG_size +                                        
pMPG_alpha +                                         
plot_layout( guides = "collect" ) &
theme( legend.direction = "vertical", legend.box = "horizontal" )

image

Using the approach described above, we can get a solution I want (code below). EDIT: I've discovered that the override.aes is not needed, which makes the whole thing a lot more easy. If more than one scale is present per subplot, it has to be extracted individually, as in the above example.

image

But the result is still not optimal (inconsistent padding between guides because of equal vertical distirbution of the three legend subplots, but different number of legend elements). Maybe this could be improved with better plot_layout()!?

Also, this procedure does not allow for merging of legends as guides = "collect" does. This would all have to be done manually.

Note: In the code below I left plot.background, panel.background and legend.background colored. This helps to understand the padding we see for the legends. Feel free to change theme_grey() to any theme you use for the subplots.

## theme for legends without plots
theme_legend <- function() {
  theme_grey() %+replace%
  theme(
    legend.box.spacing = unit( 0, "pt" ),
    legend.spacing = unit( 0, "pt" ),
    legend.justification = c("left", "top"),
    legend.box.just = "top",
    plot.background = element_rect( fill = "lightblue" ),
    panel.background = element_rect( fill = "darkblue" ),
    legend.background = element_rect( fill = "darkgreen" ),
    # plot.background = element_blank(),
    # panel.background = element_blank(),
    axis.title = element_blank(),
    axis.text = element_blank(),
    axis.ticks = element_blank(),
    panel.grid = element_blank(),
    plot.title = element_blank(),
    plot.subtitle = element_blank(),
    plot.caption = element_blank(),
    plot.tag = element_blank(),
    strip.background = element_blank(),
    strip.text = element_blank() )
}
## legends without plots
pMPG_color_guide <- pMPG_color + coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) + theme_legend()
pMPG_shape_guide <- pMPG_shape + coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) + theme_legend()
pMPG_size_guide <- pMPG_size + coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) + theme_legend()
pMPG_alpha_guide <- pMPG_alpha + coord_cartesian( xlim = c(Inf, Inf), ylim = c(Inf, Inf) ) + theme_legend()
## plots without legends
pMPG_color <- pMPG_color + theme( legend.position = "none" )
pMPG_shape <- pMPG_shape + theme( legend.position = "none" )
pMPG_size <- pMPG_size + theme( legend.position = "none" )
pMPG_alpha <- pMPG_alpha + theme( legend.position = "none" )
## plotting
design <- c(
  area(1, 1, 3, 1),
  area(1, 2, 3, 2),
  area(4, 1, 6, 1),
  area(4, 2, 6, 2),
  area(1, 3, 6, 3),
  area(1, 4, 2, 4),
  area(3, 4, 4, 4),
  area(5, 4, 6, 4) )
pMPG_color +
pMPG_shape +
pMPG_size +
pMPG_alpha +
pMPG_color_guide +
pMPG_shape_guide +
pMPG_size_guide +
pMPG_alpha_guide +
plot_layout( design = design, widths = c(1,1,0,0) )
twest820 commented 1 year ago

@Marc-Ruebsam, I ended up with the same approach of hacking together figures that are just legends and positioning them with plot_layout(design). Since I need a linetype legend I used transparent geom_segment()s instead of size zero points and then guides(linetype = guide_legend(override.aes = list(alpha))).

I've found a bit more control over spacing is possible with approaches like theme(legend.margin = margin(r = -2.5, unit = "line")). Most aspects of legend layout at that detailed of a level are, in my experience, probably better taken as ggplot limitations than patchwork issues.

Marc-Ruebsam commented 1 year ago

@twest820, thank you for the confirmation! I just realized that the override.aes isn't needed in my problem example. I'll edit my above code accordingly.

The remaining problem with the padding of the legends cannot be fixed. At least I cannot see a way without manually adjusting the heights to reflect the number of entries in the legend. Otherwise patchwork would need a feature to automatically adjust the height according to it's contents in the layout:

image

design <- c(
  area(1, 1, 3, 1),
  area(1, 2, 3, 2),
  area(4, 1, 6, 1),
  area(4, 2, 6, 2),
  area(1, 3, 6, 3),
  area(1, 4, 2, 4),
  area(3, 4, 4, 4),
  area(5, 4, 6, 4)
plot(design)
thomasp85 commented 1 year ago

Thanks for this discussion - it is definitely an area that could be improved

chelseafowler commented 11 months ago

I have a similar question about arranging and combining legends. I have some data I'm trying to use this exact technique on, the issue is that not all my legend items are present across all plots, which is creating something exactly like the hwy legend in the original post. So using the 'compact' and 'mid-size' example at the top of the thread, what if I wanted to merge the 'hwy' legend item so it appeared once but showed the information for both plots? So the hwy legend would read 25, 27.5, 30, 35, 40, instead of two separate legends.

Marc-Ruebsam commented 11 months ago

@chelseafowler: If I understand your problem correctly it is about merging legends, rather than arranging them!?

I don't think patchwork handles merging of legends with different breaks. Neither does ggplot2 by itself. You can however make sure that breaks, labels and title match and patchwork will automatically drop the duplicate for you (which can also be used to merge different legend ascetics), similar to why we include the scale_color_discrete(drop = FALSE). I'll achieve this here by setting the limits of the alpha scale to the range of the data including both classes:

## libraries
library(ggplot2)
library(dplyr)
library(patchwork)

## data
mpg_data <-
  mpg %>%
  mutate(class = as.factor(class)) %>%
  filter(class %in% c("compact","midsize"))

## plots
g1_point <-
  mpg_data %>% filter(class == "compact") %>%
  ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) + 
  geom_point(size = 3) +
  scale_alpha_continuous(limits = range(mpg_data$hwy)) +
  scale_color_discrete(drop = FALSE) +
  ggtitle("Compact")
g2_point <-
  mpg_data %>% filter(class == "midsize") %>%
  ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) + 
  geom_point(size = 3) +
  scale_alpha_continuous(limits = range(mpg_data$hwy)) +
  scale_color_discrete(drop = FALSE) +
  ggtitle("Midsize")
## plotting
g1_point +
g2_point +
plot_layout(guides = "collect") &
theme_bw() +
theme(legend.position = "bottom")

image

Note that the breaks are chosen automatically here, while I have adjusted the limits. If you want to adjust the breaks (e.g. to 25, 27.5, 30, 35, 40), this can be achieved by using scale_alpha_continuous(breaks = ...) in addition to limits or by using a discrete scale. Whatever suites your data better. Depending on your data, you could also benefit from factors and use the drop = FALSE approach.

Maybe facets would be an easier solution here?

## all in one
mpg %>%
filter(class %in% c("compact","midsize")) %>%
ggplot(aes(x = cyl, y = cty, color = class, alpha = hwy)) +
theme_bw() +
theme(legend.position = "bottom") + 
geom_point(size = 3) +
facet_grid(cols = vars(class))

I feel like there could be a feature request in here to merge legends with the same title automatically by adjusting the limits/breaks, when guides = "collect" is specified. But I am not sure how feasible this is.