1

I am making a horizontal bar plot with ggplot2, with labels to the right of the bars. Hoe do I leave enough room for the labels so they don't fall off the chart?

This question has been asked many times before, but my question is about automatically, that means without manual adjusting, the space next to a barplot to leave enough room for labels.

The use case is a shiny app where:

  • we don't know the width of the bars ahead of time
  • we don't know the length of the text labels
  • we don't know the text size

Example:

library(ggplot2)

data <- data.frame(
  weight = c("short","longer label","medium lab"),
  speed = sample(50:150,3)
)

ggplot(data, aes(x = weight, y = speed, label = weight)) +
  coord_flip(clip = 'off') +
  theme_minimal() +
  geom_bar(stat = "identity") + 
  geom_text(hjust = -0.1, size = 4) +
  ylim(c(0, 1.07 * max(data$speed)))

Re-run the code and you will see that the label sometimes falls off the chart on the right).

My solution so far which "kind of" works is to have some estimator for the ylim multiplier (here, 1.07) to leave enough room. I can of course use a really high value but then we create too much whitespace.

I have also attempted to calculate the width of the grob via grid::grobWidth, largely based on this post: How can I access dimensions of labels plotted by `geom_text` in `ggplot2`?

However in order to calculate the actual size of a text (or other) element with this approach we need to know cex in gpar, but we only have a size argument in geom_text. I don't see how they are related (?).

I have also looked at ggprepel and its internal code but cannot understand how to apply their methods to this particular problem.

Any help / pointers much appreciated!

Remko Duursma
  • 2,741
  • 17
  • 24

1 Answers1

6

One can measure the panel, the panel range and the text size using various tools from grid and ggplot2. This allows a recalculation of the upper limit required in your plot.

We can fit this into a function as follows:

library(ggplot2)
library(grid)

fix_labels <- function(p) {
  tl <- which(sapply(p$layers, function(x) any(grepl("Text", class(x$geom)))))
  g <- ggplot_build(p)
  range <- g$layout$panel_params[[1]]$x.range
  dat <- g$data[[tl]][order(factor(g$data[[tl]]$label)),]
  label_pos <- dat$x
  labels <- dat$label
  
  str_width <- sapply(labels, function(x) {
    textGrob(x, gp = gpar(fontsize = p$layers[[tl]]$aes_params$size * .pt)) |>
      grobWidth() |>
      convertWidth("cm", TRUE)
  })
  
  panel_width <- (unit(1, 'npc') - sum(ggplotGrob(p)$widths[-5])) |>
         convertWidth('cm', TRUE)
  
  units_per_cm <- diff(range) / panel_width
  
  new_x <- str_width * units_per_cm + label_pos 
  
  expansion_factor <- (max(new_x) - min(range))/diff(range)
  xval <- expansion_factor^2 * (max(new_x) - max(label_pos)) + max(label_pos)
  
  p + xlim(NA, xval)
}

We can test this on your own example, but we should modernize your code by removing the unnecessary coord_flip and change the geom_bar(stat = "identity") to the equivalent geom_col().

We will also use a random seed for reproducibility, and a larger font size to illustrate the problem.

set.seed(123)

data <- data.frame(
  weight = c("short","longer label","medium lab"),
  speed = sample(50:150,3)
)

p <- ggplot(data, aes(x = speed, y = weight, label = weight)) +
  theme_minimal() +
  geom_col() +
  geom_text(hjust = -0.1, size = 8) 

p

enter image description here

But now we can just do:

fix_labels(p)

enter image description here

This seems to work just as well with small text (here just the same code but with text size 4):

enter image description here

And even pointlessly large labels (here size 16)

enter image description here

Allan Cameron
  • 147,086
  • 7
  • 49
  • 87
  • Really nice, Allan. But what is `pan`? Just tried to run your code but ... (: – stefan Jan 12 '23 at 18:18
  • 1
    @stefan oops - missed a line of code. Now added – Allan Cameron Jan 12 '23 at 18:19
  • It is not 100% correct though : with lower `size` there seems to be too much space, and if I make size = 14 (super larger), the text still does not fit. But it is certainly close, so I wonder if there is a small change to be made somewhere. – Remko Duursma Jan 12 '23 at 20:17
  • I think I see what the problem is. Adding to the x limits effectively makes the units per mm smaller (since there are more units to fit in the same space on the x axis). This means that once the plot is redrawn, the extra space is no longer adequate. This is more pronounced for larger text and longer strings. I'll have a look at this more closely and fix it @RemkoDuursma – Allan Cameron Jan 12 '23 at 22:08
  • I now tested the updated function in our larger application and it works perfectly. One small thing: when there are missing values in the x aesthetic, the calculations are off. Useful to know if you are using this function. – Remko Duursma Jan 13 '23 at 08:32