patchwork 1.3.0

  ggplot2, gt, patchwork

  Thomas Lin Pedersen

I’m excited to present patchwork 1.3.0, our package for creating multifigure plot compositions. This versions adds table support and improves support for “free"ing components to span across multiple grid cells.

You can install patchwork from CRAN with:

install.packages("patchwork")

You can see a full list of changes in the release notes

Tables are figures too

The new and shiny feature of the release is that patchwork now has native support for gt objects, making it possible to compose beautifully formatted tables together with your figures. This has been made possible through Teun Van den Brand’s effort to provide grob output to gt. While this means that you can now pass in gt objects to wrap_elements() in the same way as other supported data types, it also goes one step further, using the semantics of the table design to add table specific formatting options through the new wrap_table() function.

But let’s take a step back and see how the simplest support works in reality:

p1 <- ggplot(airquality) +
  geom_line(aes(x = Day, y = Temp, colour = month.name[Month])) +
  labs(colour = "Month")

aq <- airquality[sample(nrow(airquality), 10), ]
p1 + gt(aq) + ggtitle("Sample of the dataset")

A few things can be gathered already from this small example. Tables can have titles (and subtitles, captions, and tags) like regular plots (in that sense they behave like wrap_elements() output). Also, and this is perhaps more interesting, patchwork is aware that the first row is special (a header row), and thus places that on top of the panel area so that the plot region of the left plot is aligned with the body of the table, not the full table.

Lastly, we see that tables often have a fixed size, contrary to plots which can shrink and expand based on how much room they have. Because of this, our table is overflowing it’s region in the plot above creating a not-so-great look.

Let’s see how we can use wrap_table() to control some of these behaviors. First, while we could decrease the font size in the table to make it smaller, we could also allow it some more space instead. We could do this by using plot_layout(widths = ...) but it would require a fair amount of guessing on our side to get it just right. Thankfully, patchwork is smart enough to figure it out for us and we can instruct it to do so using the space argument in wrap_table(). Setting it to "free_y" instructs it to fix the width to the table width but keep the height free:

p1 + wrap_table(aq, space = "free_y")

Setting space to "fixed" would constrain both the width and the height of the area it occupies. Since we only have a single row in our layout this would leave us with some empty horizontal space:

p1 + wrap_table(aq, space = "fixed")

If the space is fixed in the y direction and the table has any source notes or footnotes, these will behave like the column header and be placed outside the panel area depending on the panel setting

aq_footer <- gt(aq) |>
  tab_source_note("This is not part of the table body")
p1 + wrap_table(aq_footer, space = "fixed")

While the space argument is great for making the composition look good and the table well placed in the whole, it can also serve a different purpose of making sure that rows (or columns) are aligned with the axis of a plot. There are no facilities to ensure that the breaks order matches between plots and tables so that is the responsibility of the user, but otherwise this is a great way to use tables to directly augment a plot:

p2 <- ggplot(airquality) +
  geom_boxplot(aes(x = month.name[Month], y = Temp)) +
  theme(axis.text.x = element_blank(), axis.title.x = element_blank()) +
  scale_x_discrete(expand = c(0, 0.5))

# Construct our table
table <- rbind(
  tapply(airquality$Temp, airquality$Month, max),
  tapply(airquality$Temp, airquality$Month, median),
  tapply(airquality$Temp, airquality$Month, min)
)
colnames(table) <- month.name[5:9]
table <- data.frame(
  Measure = c("Max", "Median", "Min"),
  table
)
table <- gt(table, rowname_col = "Measure") |>
  cols_width(contains(month.name) ~ px(100)) |>
  cols_align(align = "center") |>
  cols_align(align = "right", columns = "Measure")

p2 / wrap_table(table, space = "fixed")

Circling back, there was another argument to wrap_table() we didn’t get into yet. In the plot above, we see that the row names are conveniently aligned with the axis rather than the panel of the plot above, in the same way as the headers where placed outside the panel area. This is a nice default and generally makes sense for the semantics of a table, but you might want something different. The panel argument allows you to control this exact behavior. It takes "body", "full", "rows", or "cols" which indicate what portion of the table should be inside the panel area. The default is "body" which places row and column names outside the panel. "full", on the contrary, places everything inside, while "rows" and "cols" are half versions that allows you to keep either column or row names outside the panel respectively.

# Place all rows (including the header row) inside the panel area
p1 + wrap_table(aq, panel = "rows", space = "free_y")

Just like the tables support ggplot2-like titles, they also support tags, meaning that patchworks auto-tagging works as expected. It can be turned off using the ignore_tag argument but often you’d want to treat it as a figure in the figure text:

p1 + wrap_table(aq, panel = "rows", space = "free_y") +
  plot_annotation(tag_levels = "A") &
  theme(plot.tag = element_text(margin = margin(0, 6, 6, 0)))

Accesibility

We truly believe that the features laid out above will be a boon for augmenting your data visualisation with data that can be read precisely at a glance. However, we would be remiss to not note how tables that are part of a patchwork visualisation doesn’t have the same accessibility featurees as a gt table included directly in e.g. an HTML output. This is because graphics are rasterised into a PNG file and thus looses all semantical information that is inherent in a table. This should be kept in mind when providing Alt text for your figures so you ensure they are legible for everyone.

Future

The support on the patchwork end is likely done at this point, but the conversion to grobs that has been added to gt is still somewhat young and will improve over time. It is likely that markdown formatting (through marquee) and other niceties will get added, leading to even more power in composing tables with plots using patchwork as the glue between them. As with the support for gt in typst the support for gt in patchwork is part of our larger effort to bring the power of gt to more environments and create a single unified solution to table styling.

With freedom comes great responsibility

The second leg of this release concerns the free() function which was introduced in the last release. I devoted a whole section of my posit::conf talk this year to talk about free() and how it was a good thing to say no to requests for functionality until you have a solution that fits into your API and doesn’t add clutter. I really like how the API for free() turned out but I also knew it could do more. In this release it delivers on those promises with two additional arguments.

Which side?

As it were, free() could only be used to completely turn off alignment of a plot, e.g. like below:

p1 <- ggplot(mtcars) +
  geom_bar(aes(y = factor(gear), fill = factor(gear))) +
  scale_y_discrete(
    "",
    labels = c("3 gears are often enough",
               "But, you know, 4 is a nice number",
               "I would def go with 5 gears in a modern car")
  )
p2 <- ggplot(mtcars) + geom_point(aes(mpg, disp))

free(p1) / p2

We can see that panel alignment has been turned off both to the left and to the right (and top and bottom if it were visible). But perhaps you are only interested in un-aligning the left side, keeping the legend to the right of both plots. Now you can, thanks to the side argument which takes a string containing one or more of the t, r, b, and l characters to indicate which sides to apply the freeing to (default is "trbl" meaning “target all sides”).

free(p1, side = "l") / p2

Freeing works inside nested patchworks, where you can target various sides at various levels:

p3 <- ggplot(mtcars) +
  geom_boxplot(aes(y = factor(gear), disp)) +
  scale_y_discrete(
    "",
    labels = c("... and 3",
               "4 of them",
               "5 gears")
  )


nested <- p2 / free(p1, side = "l")

free(nested, side = "r") /
  p3

What does “freeing” means anyway?

While being able to target specific sides is pretty great in and off itself, we are not done yet. After being able to not align panels the most requested feature was the possibility of moving the axis title closer to the axis text if alignment had pushed it apart. Consider again our unfreed patchwork:

p1 / p2

While we can “fix” it by letting the top panel stretch, another way to improve upon it would be to move the dangling y-axis title of the bottom plot closer to the axis. Enter the type argument to free() which informs patchwork how to not align the input. The default ("panel") works just as free() always has, but the other two values opens up some new nifty goodies. Setting type = "label" does exactly what we discussed above, freeing the label from alignment so it sticks together with the axis and axis text:

p1 /
  free(p2, type = "label")

The other type is "space" which works slightly different. Using this you tell patchwork to not reserve any space for what the side(s) contain. This is perfect in situation where you already have empty space next to it that can fit the content. Consider this plot:

plot_spacer() + p1 +
  p2 + p2

Ugh, the axis text of the top plot pushes everything apart even though there is ample of space for it in the empty region on the left. This is where type = "space" comes in handy:

plot_spacer() + free(p1, type = "space", side = "l") +
  p2 + p2

Of course, such power comes with the responsibility of you ensuring there is actually space for it — otherwise it will escape out of the figure area:

free(p1, type = "space", side = "l") /
  p2

All the different types of freeing can be stacked on top of each other so you can have a plot that keeps the left axis label together with the axis while also stretches the right side to take up empty space:

p1 /
  free(free(p2, "panel", "r"), "label", "l")

But as always, don’t go overboard. If you find yourself needing to use an elaborate combination of stacked free() calls there is a good chance that something with your core composition needs rethinking.

The rest

The above are the clear highlights of this release. It also contains the standard bug fixes — especially in the area of axis collecting which was introduced in the last release and came with a bunch of edge cases that were unaccounted for. There is also a new utility function: merge() which is an alternative to the - operator that I don’t think many users understood or used. It allows you to merge all plots together into a nested patchwork so that the right hand side is added to a new composition.

Acknowledgements

Thank you to all people who have contributed issues, code and comments to this release:

@BenVolpe94, @daniellembecker, @dchiu911, @ericKuo722, @Fan-iX, @IndrajeetPatil, @jack-davison, @karchern, @laresbernardo, @marchtaylor, @mariadelmarq, @Maschette, @michaeltopper1, @mkoohafkan, @n-kall, @person-c, @pettyalex, @petzi53, @phispu, @psychelzh, @rinivarg, @selkamand, @Soham6298, @svraka, @teng-gao, @teunbrand, @thomasp85, @timz0605, @wish1832, and @Yunuuuu.