19

I have generated a simple plot in R that shows the correlation coefficients for a set of data. Currently, the legend colorbar on the right side of the plot is a fraction of the entire plot size.

I would like the legend colorbar to be same height as the plot. I thought that I could use the legend.key.height to do this, but I have found that is not the case. I investigated the grid package unit function and found that there were some normalized units in there but when I tried them (unit(1, "npc")), the colorbar was way too tall and went off the page.

How can I make the legend the same height as the plot itself?

A full self contained example is below:

library(ggplot2)

corrs <- structure(list(Var1 = structure(c(1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L), levels = c("Var1", "Var2", "Var3"), class = "factor"), Var2 = structure(c(1L, 1L, 1L, 2L, 2L, 2L, 3L, 3L, 3L), levels = c("Var1", "Var2", "Var3"), class = "factor"), value = c(1, -0.11814395012334, -0.91732952510938, -0.969618394505233, 1, -0.00122085912153125, -0.191116513684392, -0.0373711776919663, 1)), class = "data.frame", row.names = c(NA, -9L))

ggplot(corrs, aes(x = Var1, y = Var2, fill = value)) +
  geom_tile() +
  theme(
    panel.border = element_blank(),
    axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1),
    aspect.ratio = 1,
    legend.position = "right",
    legend.key.height = unit(1, "inch")
  )

Created on 2022-12-29 with reprex v2.0.2

tjebo
  • 21,977
  • 7
  • 58
  • 94
Justace Clutter
  • 2,097
  • 3
  • 18
  • 31

4 Answers4

18

Edit Updating to ggplot v3.0.0

This is messy, but based on this answer, and delving deeper into the ggplot grob, the legend can be positioned precisely.

# Load the needed libraries
library(ggplot2)
library(gtable)  # 
library(grid)
library(scales)
library(reshape2)


# Generate a collection of sample data
variables = c("Var1", "Var2", "Var3")
data = matrix(runif(9, -1, 1), 3, 3)
diag(data) = 1
colnames(data) = variables
rownames(data) = variables

# Generate the plot
corrs = data
plot  = ggplot(melt(corrs), aes(x = Var1, y = Var2, fill = value)) + 
   geom_tile() +
   theme_bw() + 
   theme(panel.border = element_blank()) +
   theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1)) +
   theme(aspect.ratio = 1) +
   # theme(legend.position = "right", legend.key.height = unit(1, "inch")) +
   labs(x = "", y = "", fill = "", title = "Correlation Coefficients") +
   scale_fill_gradient2(limits = c(-1, 1), breaks = c(-1, -.5, 0, .5, 1), expand = c(0,0), 
      low = muted("red"), mid = "black", high = muted("blue")) +  # Modified line
   geom_text(parse = TRUE, aes(label = sprintf("%.2f", value)), size = 3, color = "white") +
   scale_x_discrete(expand = c(0,0)) +  # New line
   scale_y_discrete(expand = c(0,0))    # New line
plot

# Get the ggplot grob
gt = ggplotGrob(plot)

# Get the legend
leg = gtable_filter(gt, "guide-box")

# Raster height
leg[[1]][[1]][[1]][[1]][[1]][[2]]$height = unit(1, "npc")

# Positions for labels and tick marks - five breaks, therefore, five positions
pos = unit.c(unit(0.01,"npc"), unit(.25, "npc"), unit(.5, "npc"), unit(.75, "npc"), unit(.99, "npc"))

# Positions the labels 
leg[[1]][[1]][[1]][[1]][[1]][[3]]$children[[1]]$y = pos

# Positions the tick marks
leg[[1]][[1]][[1]][[1]][[1]][[5]]$y0 = pos
leg[[1]][[1]][[1]][[1]][[1]][[5]]$y1 = pos

# Legend key height ?
leg[[1]][[1]][[1]][[1]]$heights = unit.c(rep(unit(0, "mm"), 3),
                                         unit(1, "npc"),
                                         unit(0, "mm"))
# Legend height
leg[[1]][[1]]$heights[[3]] = sum(rep(unit(0, "mm"), 3),
                                 unit(1, "npc"),
                                 unit(0, "mm"))

# grid.draw(leg)  # Check on heights and y values

# gtable_show_layout(gt) # Manually locate position of legend in layout
gt.new = gtable_add_grob(gt, leg, t = 7, l = 9)

# Draw it
grid.newpage()
grid.draw(gt.new)

enter image description here

tjebo
  • 21,977
  • 7
  • 58
  • 94
Sandy Muspratt
  • 31,719
  • 12
  • 116
  • 122
  • I'm running into an `invalid 'layout.pos.row'` error when trying to follow this example. Have you seen this before? – philiporlando Nov 17 '18 at 06:09
  • 1
    @spacedSparking In this context, the error usually occurs when "t" and/or "l" are outside possible values given the gtable. However, the code works for me - I'm using R v3.5.1, ggplot2 v3.1.0, gtable v0.2.0. I'm guessing you're using an older version of ggplot2. – Sandy Muspratt Nov 17 '18 at 22:39
  • 1
    Also this answer does not seem to work with ggplot2 3.4.0 – tjebo Dec 29 '22 at 18:17
8

It seems quite tricky, the closest I got was this,

## panel height is 1null, so we work it out by subtracting the other heights from 1npc
## and 1line for the default plot margins

panel_height <- unit(1,"npc") - sum(ggplotGrob(plot)[["heights"]][-3]) - unit(1,"line")
plot + guides(fill= guide_colorbar(barheight=panel_height))

unfortunately the vertical justification is a bit off.

tjebo
  • 21,977
  • 7
  • 58
  • 94
baptiste
  • 75,767
  • 19
  • 198
  • 294
6

The following option is a function that can be added to a ggplot to take the plot and make the colorbar the same height as the panel.

Essentially it uses the same technique as Baptiste, but is a bit more robust to changes in the ggplot implementation, and moves the legend title to a more natural position, allowing neater alignment. It also allows more recognisable ggplot-style syntax.

make_fullsize <- function() structure("", class = "fullsizebar")

ggplot_add.fullsizebar <- function(obj, g, name = "fullsizebar") {
  h <- ggplotGrob(g)$heights
  panel <- which(grid::unitType(h) == "null")
  panel_height <- unit(1, "npc") - sum(h[-panel])
  
  g + 
    guides(fill = guide_colorbar(barheight = panel_height,
                                 title.position = "right")) +
    theme(legend.title = element_text(angle = -90, hjust = 0.5))
}

You can then do:

ggplot(corrs, aes(x = Var1, y = Var2, fill = value)) + 
  geom_tile() +
  coord_cartesian(expand = FALSE) +
  make_fullsize()

enter image description here

The drawback is that the plot needs re-drawn if the plotting window is resized after the plot is drawn. This is a bit of a pain, but it's a fairly quick-and-simple fix. It will still work well with ggsave.

Note that the color bar is the same height as the panel, which is why it looks a bit neater to turn the expansion off in coord_cartesian, so it matches the actual tiles of the heatmap.

Another example for one of the linked duplicates:

library(reshape2)

dat <- iris[,1:4]
cor <- melt(cor(dat, use="p"))

ggplot(data=cor, aes(x=Var1, y=Var2, fill=value)) +
  geom_tile() + 
  labs(x = "", y = "") + 
  scale_fill_gradient2(limits=c(-1, 1)) +
  coord_cartesian(expand = FALSE) +
  make_fullsize()

enter image description here

Allan Cameron
  • 147,086
  • 7
  • 49
  • 87
  • That's very nice, Allan. Without having tried, I assume this could be tricky when used with `ggsave`? I guess to save a graphic you would save the plot in the "traditional way" (i.e., create a device in the console, then save)? – tjebo Dec 30 '22 at 06:54
  • @tjebo no, it works perfectly with `ggsave`, because the barheight is stored as a relative height within the guide. It's only when the plot is finalzed as a grid object that resizing is problematic. Essentially it's the same as any legend in ggplot - the size is fixed as you rescale the plot after it is drawn. – Allan Cameron Dec 30 '22 at 08:04
  • I am encountering an error with this solution, not quite sure where it comes from, this is the error: ggplot_add.fullsizebar(object = e2, plot = p, object_name = e2name) : unused arguments (object = e2, plot = p, object_name = e2name)" – Myriad Dec 30 '22 at 19:18
  • @Myriad do you have the latest version of ggplot installed? – Allan Cameron Dec 30 '22 at 19:43
  • @AllanCameron I have version ‘3.4.0’ of ggplot installed – Myriad Dec 30 '22 at 20:04
  • @AllanCameron seems to work on an empty session, but not on an existing one, must be something wrong in my system – Myriad Dec 30 '22 at 20:19
  • Unfortunately this does not seem to work for me - the guide colorbar is always somewhat shorter than the plot panel. Fresh session with only `ggplot2 3.4.0` loaded / `R 4.2.2` / `Windows 10 x64` / `RStudio 2022.12.0.353`. – Ritchie Sacramento Dec 30 '22 at 23:54
  • That's weird @RitchieSacramento. Do the exact examples above not reproduce? Any way to post a sample image on imgur? – Allan Cameron Dec 31 '22 at 00:05
  • I worked out why and it's a bit of a gotcha - it's because I had my system set to scale apps at 125%. Setting this back to 100% and it works as expected. – Ritchie Sacramento Dec 31 '22 at 00:07
  • I should add that adjusting for system scaling also works, e.g. if your system is scaled 125% then using `unit(1.25, "npc")` in your function gives the correct result. Ideally you would want an approach that accounts for system wide scaling as this will be a common issue in setups using high resolution monitors or amongst people with visual impairments. Any idea if this is possible? – Ritchie Sacramento Dec 31 '22 at 11:07
  • @RitchieSacramento I guess if the system scaling can be interrogated by R than it should be easy enough. I haven't come across this before though, so I'm not sure where R would look for it. – Allan Cameron Dec 31 '22 at 13:03
  • Back to my previous comment - funnily, it doesn't render as desired in my RStudio viewer pane, but it works very nicely when saving to a device with `ggsave`. Oh, what a miraculous function you have produced there. – tjebo Jan 02 '23 at 10:40
  • @tjebo it is weird. 3 people have 3 different problems with it, all can be resolved in different ways, and I can't replicate any of them! Maybe your problem is the same scaling issue that Ritchie Sacramento reported? I haven't come across app scaling before (my monitor has never been fancy enough to need it) – Allan Cameron Jan 02 '23 at 10:50
  • @AllanCameron this is weird, I can't get it to work on existing projects even If I remake plots. it throws unused argument errors. I can only get it to work on a new session. Can't figure out why – Myriad Jan 03 '23 at 15:16
2

The problem is that the plot panel does not have defined dimensions until drawing ("NULL unit"), but your legend guide has. See also npc coordinates of geom_point in ggplot2 or figuring out panel size given dimensions for the final plot in ggsave (to show count for geom_dotplot). I think it will be extremely tricky to draw the legend guide in the exact same size as your panel.

However, you can make use of a trick when dealing with complex legend formatting: Create a fake legend. The challenge here is to adjust the fill scale to perfectly match the range of your plot (which is not usually exactly the range of your data values). The rest is just a bit of R semantics. Some important comments in the code.

library(ggplot2)

corrs <- structure(list(Var1 = structure(c(1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L), levels = c("Var1", "Var2", "Var3"), class = "factor"), Var2 = structure(c(1L, 1L, 1L, 2L, 2L, 2L, 3L, 3L, 3L), levels = c("Var1", "Var2", "Var3"), class = "factor"), value = c(1, -0.11814395012334, -0.91732952510938, -0.969618394505233, 1, -0.00122085912153125, -0.191116513684392, -0.0373711776919663, 1)), class = "data.frame", row.names = c(NA, -9L))
## to set the scale properly, you will need to set limits and breaks, 
## I am doing this semi-automatically
range_fill <- range(corrs$value)
lim_fill <- c(floor(range_fill[1]), ceiling(range_fill[2]))
## len = 5 and round to 2 is hard coded, depending on the scale breaks that you want
breaks_fill <- round(seq(lim_fill[1], lim_fill[2], len = 5), 2)
## need to rescale the fill to the range of you y values,
## so that your fill scale correctly corresponds to the range of your y 
## however, the actual range of your plot depends if you're in a discrete or continuous range.
## here in a discrete range
lim_y <- range(as.integer(corrs$Var2))
lim_x <- range(as.integer(corrs$Var1))
lim_vals <- lim_y + c(-.5, .5)
## actual rescaling happens here
new_y <- scales::rescale(breaks_fill, lim_vals)
## the position and width of the color bar are defined with
scl_x <- lim_x[2] + .7 # the constant is hard coded
scl_xend <- scl_x + .2 # also hard coded
##  make a data frame for the segments to be created
## using approx so that we have many little segments
approx_fill <- approx(new_y, breaks_fill, n = 1000)
df_seg <- data.frame(y = approx_fill$x, color = approx_fill$y)
## data frame for labels, xend position being hard coded
df_lab <- data.frame(y = new_y, x = scl_xend + .1, label = breaks_fill)
## data frame for separators
sep_len <- .05
df_sep <- data.frame(
  y = new_y, yend = new_y,
  x = rep(c(scl_x, scl_xend - sep_len), each = 5),
  xend = rep(c(scl_x + sep_len, scl_xend), each = 5)
)

ggplot(corrs) +
  geom_tile(aes(x = Var1, y = Var2, fill = value)) +
  geom_segment(
    data = df_seg,
    aes(x = scl_x, xend = scl_xend, y = y, yend = y, color = color)
  ) +
  ## now the labels, the size being hard coded
  geom_text(data = df_lab, aes(x, y, label = label), size = 9 * 5 / 14) +
  ## now make the white little separators
  geom_segment(
    data = df_sep, aes(x = x, xend = xend, y = y, yend = yend),
    color = "white"
  ) +
  ## set both color and fill scales exactly
  scale_fill_continuous(limits = lim_fill, breaks = breaks_fill) +
  scale_color_continuous(limits = lim_fill, breaks = breaks_fill) +
  ## turn off coordinate clipping and limit panel to data area)
  coord_cartesian(xlim = lim_x, ylim = lim_y, clip = "off") +
  ## last but not least remove the other legends and add a bit of margin
  theme(
    legend.position = "none",
    plot.margin = margin(r = 1, unit = "in")
  )

Created on 2022-12-29 with reprex v2.0.2

tjebo
  • 21,977
  • 7
  • 58
  • 94