3

I'm trying to achieve a solution for wrapping x axis labels so they won't overlap. I know this question has been asked several times, and that there are some good answers. However, no solution that I've seen answers how to re-wrap the labels as the plot gets resized.

Three different answers at SO make me believe this could be attainable.

  1. This solution wrote a custom-made geom for fitting the bar's label size to fit within the bar's width, dynamically as you resize the plot.

  2. This solution relies on an extension package for ggplot2 called ggtext. The solution allows dynamic word wrapping of the plot's title, as you resize the plot, based on creating a element_textbox().

  3. This solution relies on another extension called ggfittext. It shows how the size of the label inside the bar can vary dynamically to fit the bar's dimensions as you resize the plot. Essentially, it addresses the same problem as solution (1) above, but is much more powerful. In fact, and this is the feature that makes me hopeful, it relies on a general solution geom_fit_text() to fit text inside rectangles, not just geom_bar()s.

Some demo data to work with

1. Just to show the typical output when x axis labels are overlapping

  library(tidyverse)
  
  my_mtcars <-
    mtcars[15:20,] %>% 
    rownames_to_column("cars")
  
  my_mtcars %>%
    ggplot(aes(x = cars, y = mpg, fill = cars)) + 
    geom_bar(stat = "identity")

Created on 2021-01-29 by the reprex package (v0.3.0)


2. When we use ggfittext we can see how labels inside the bars shrink in size to fit the bar

  library(tidyverse)
  library(ggfittext)
#> Warning: package 'ggfittext' was built under R version 4.0.3
  
  my_mtcars <-
    mtcars[15:20,] %>% 
    rownames_to_column("cars")
  
  my_mtcars %>%
    ggplot(aes(x = cars, y = mpg, fill = cars)) + 
    geom_bar(stat = "identity") +
    geom_bar_text(aes(label = cars), 
      color = "blue", 
      vjust = 1, 
      size = 7 * ggplot2::.pt, 
      min.size = 0,
      padding.x = grid::unit(0, "pt"),
      padding.y = grid::unit(0, "pt"))
#> Warning: Ignoring unknown aesthetics: label

Created on 2021-01-29 by the reprex package (v0.3.0)


3. ggfittext has the reflow argument that promotes text wrapping

  library(tidyverse)
  library(ggfittext)
#> Warning: package 'ggfittext' was built under R version 4.0.3
  
  my_mtcars <-
    mtcars[15:20,] %>% 
    rownames_to_column("cars")
  
  my_mtcars %>%
    ggplot(aes(x = cars, y = mpg, fill = cars)) + 
    geom_bar(stat = "identity") +
    geom_bar_text(aes(label = cars), 
      color = "blue", 
      vjust = 1, 
      size = 7 * ggplot2::.pt, 
      min.size = 0,
      padding.x = grid::unit(0, "pt"),
      padding.y = grid::unit(0, "pt"),
      reflow = TRUE ## <--------------- added this
      )
#> Warning: Ignoring unknown aesthetics: label

Created on 2021-01-29 by the reprex package (v0.3.0)


My question

I don't know how to do it, but could we get x axis labels wrapped/resized/rescaled dynamically, by somehow letting ggfittext do the hard work for us? In the naïve way I see this, the text within the bars is already rendered the right way, can we just "copy" this rendering somehow to the axis labels?

Emman
  • 3,695
  • 2
  • 20
  • 44

2 Answers2

3

How about we just place the ggfittext text below the y-axis? We turn off clipping and set the oob and limits to suit our data. Should probably tweak the axis.text.x size to align better with the x-axis title.

library(tidyverse)
#> Warning: package 'tidyr' was built under R version 4.0.3
#> Warning: package 'readr' was built under R version 4.0.3
#> Warning: package 'dplyr' was built under R version 4.0.3
library(ggfittext)
#> Warning: package 'ggfittext' was built under R version 4.0.3

my_mtcars <-
  mtcars[15:20,] %>% 
  rownames_to_column("cars")

my_mtcars %>%
  ggplot(aes(x = cars, y = mpg, fill = cars)) + 
  geom_bar(stat = "identity") +
  geom_fit_text(aes(label = cars, y = -4),
                reflow = TRUE, height = 50,
                show.legend = FALSE) +
  scale_y_continuous(oob = scales::oob_keep,
                     limits = c(0, NA)) +
  coord_cartesian(clip = "off") +
  theme(axis.text.x = element_text(colour = "transparent", size = 18))

Created on 2021-01-29 by the reprex package (v0.3.0)

EDIT: Getting the labels out of the grob

library(tidyverse)
library(ggfittext)

my_mtcars <-
  mtcars[15:20,] %>% 
  rownames_to_column("cars")

p <- my_mtcars %>%
  ggplot(aes(x = cars, y = mpg, fill = cars)) + 
  geom_bar(stat = "identity") +
  geom_fit_text(aes(label = cars, y = -1),
                reflow = TRUE, height = 50,
                show.legend = FALSE) +
  scale_y_continuous(oob = scales::oob_keep,
                     limits = c(0, NA)) +
  coord_cartesian(clip = "off") +
  theme(axis.text.x = element_text(colour = "transparent", size = 18))

grob <- grid::makeContent(layer_grob(p, 2)[[1]])$children

sizes <- vapply(grob, function(x){x$gp$fontsize}, numeric(1))
labels <- unname(vapply(grob, function(x){x$label}, character(1)))
print(labels)
#> [1] "Cadillac\nFleetwood"  "Lincoln\nContinental" "Chrysler\nImperial"  
#> [4] "Fiat 128"             "Honda Civic"          "Toyota\nCorolla"

Created on 2021-01-29 by the reprex package (v0.3.0)

teunbrand
  • 33,645
  • 4
  • 37
  • 63
  • This is really good, thank you. However, `y = -4` suggests a limitation. While `-4` works for this plot, a different plot could have any y-scale, and `-4` won't work. Is there a way to set the vertical placement independently of the y values? – Emman Jan 29 '21 at 20:04
  • I have another idea.... is it possible at all to hack the rendered labels, and extract both the word wrapping (e.g., `"Fiat 128"`, `"Honda\nCivic"`) *and* text size, then pass that to `scale_x_discrete()`? – Emman Jan 29 '21 at 20:15
  • Yes I agree that this is a limitation of this approach, which is why I've left some suggestions for the ggtfittext authors on their github to implement it at the `theme()` level. – teunbrand Jan 29 '21 at 20:16
  • I like the idea, but the limitation is that the ggfittext isn't rendered until actual render time and all absolute dimensions are known. You'd have to somehow intercept their `makeContent.fittexttree` method, which you can do with the debugger, but this is even less generalisable than manually setting a `y` position. Also, if you resize the device window, the values you've intercepted aren't the correct ones anymore. – teunbrand Jan 29 '21 at 20:19
  • Can't we first save the rendered plot to object `p`, then extract the info from `p` and add `scale_x_discrete()`? And finally wrap it all in one function... It will be a semi-dynamic solution, but sufficient. – Emman Jan 29 '21 at 20:23
  • I don't think `scale_x_discrete()` does different sizes for labels, but you could get the text wrapped part. And I don't think you can rely on the vectorisation of theme elements that you can transfer that via `theme(axis.text.x = ...)`. – teunbrand Jan 29 '21 at 20:28
  • Then we can give the smallest size (meaning, the smallest rendered by `ggfittext`) to all labels. It will actually look better than varying sizes. – Emman Jan 29 '21 at 20:29
  • I'm looking through `grob <- grid::makeContent(layer_grob(p, 2))$children`, wherein `p` is the plot object, but I cannot find varying sizes. You can find the wrapped text by `labels <- vapply(grob, function(x){x$label}, character(1))` though. – teunbrand Jan 29 '21 at 20:33
  • I got `NULL` returned for `grob`. Could you do a `reprex()` maybe? – Emman Jan 29 '21 at 20:38
  • Ah yes I forgot to subset the layer_grob, see edit. – teunbrand Jan 29 '21 at 20:41
  • Well if it just is about having wrapped text, you could try `stringr::str_wrap()` instead, that seems a lot easier than delving into the graphics devices. – teunbrand Jan 29 '21 at 20:57
  • Sure it is much simpler, but how could I fit the `width` of `str_wrap()`, which is defined as # of characters, with the changing width of bars? The number of characters that fit a given bar width is a matter of font size. I don't know how to calculate this, and especially given the fact that bars width changes from one plot to another, as data changes. Also consider situations where you might want to use `facet_grid()` etc. That's why I wanted to rely on some other function to do the dirty work... `ggfittext` seemed good for the task – Emman Jan 29 '21 at 21:00
  • Yes I agree that their algorithm is good. If I can find the time tomorrow I could maybe try to get an `element_fittext()` working. – teunbrand Jan 29 '21 at 21:06
  • Disappointingly, I couldn't get it to work properly. It'll likely require changes on ggfittext's side. – teunbrand Jan 30 '21 at 18:22
  • Thanks for trying! Would you mind trying to update your answer with a workflow for (1) getting the text correctly wrapped (using `ggfittext`), (2) saving to `p`, (3) extracting the info from the grob, (4) re-plotting with labels through `scale_x_discrete()` *but without* the `ggfittext` labels elsewhere? I wonder how generalizable/solid we can make such method. Problem is that I tried hacking the grob myself and couldn't figure out what's going on there. – Emman Jan 30 '21 at 20:11
  • I'm going to accept this answer and post another question regarding how to extract the labels and their size and re-plot, in a generalizable way. – Emman Jan 31 '21 at 07:54
-1

How about just changing the angle or size of the text?

angle:

my_mtcars %>%
  ggplot(aes(x = cars, y = mpg, fill = cars)) + 
  geom_bar(stat = "identity")+
  theme(axis.text.x = element_text(angle = 45, vjust = 0.5, hjust=1))

size:

my_mtcars %>%
  ggplot(aes(x = cars, y = mpg, fill = cars)) + 
  geom_bar(stat = "identity")+
  theme(axis.text.x = element_text(size = 4)) 
Paul Tansley
  • 171
  • 7
  • Of course we could, and I'm aware of such solutions. I acknowledged that by referencing to this [rundown](https://stackoverflow.com/questions/41568411/how-to-maintain-size-of-ggplot-with-long-labels/41607201#41607201) at the very beginning of this post. But my question is different. – Emman Jan 29 '21 at 16:02
  • Ah, apologies, I didn't go into the references. – Paul Tansley Jan 29 '21 at 16:18