21

This is actually two questions in one (not sure if goes against SO rules, but anyway).

First question is how can I force a geom_text to fit within a geom_bar? (dynamically according to the values plotted)

Looking around, the solutions I found were changing the size of the label. That certainly works, but not for every case. You can change the size for a specific plot to make the text fit within the bar, but when the data changes, you may need to manually change the size of the text again. My real-life problem is that I need to generate the same plot for constantly changing data (daily), so I cannot really manually adjust the size for each plot.

I tried setting the size of the label as a function of the data. It kinda works, not perfectly, but works for many cases.

But here's another problem, even when the label fits within the bar, resizing the plot messes everything up. Looking into it, I also found in the ggplot documentation that

labels do have height and width, but they are physical units, not data units. The amount of space they occupy on that plot is not constant in data units: when you resize a plot, labels stay the same size, but the size of the axes changes.

Which takes me to my second question: Is it possible to change this default behaviour and let/make the labels resize with the plot?

And also let me refine my first question. Is it possible to force a geom_text to fit within a geom_bar, dynamically setting the size of the text using a clever relation between physical units and data units?

So, to follow good practice, here's my reproducible example:

set.seed(1234567)
data_gd <- data.frame(x = letters[1:5], 
                      y = runif(5, 100, 99999))

ggplot(data = data_gd,
       mapping = aes(x = x, y = y, fill = x)) +
    geom_bar(stat = "identity") +
    geom_text(mapping = aes(label = y, y = y/2))

This code produces this plot:

enter image description here

If I simply resize the plot, "labels stay the same size, but the size of the axes changes" thereby making the labels fit into the bars (now perhaps labels are even too small).

enter image description here

So, this is my second question. It would be nice that the labels resize as well and keep the aspect ration in relation to the bars. Any ideas how to accomplish this or if it is possible at all?

Ok, but going back to how to fit the labels within the bars, the simplest solution is to set the size of the labels.

ggplot(data = data_gd,
       mapping = aes(x = x, y = y, fill = x)) +
    geom_bar(stat = "identity") +
    geom_text(mapping = aes(label = y, y = y/2), size = 3)

Again, this works as shown below, but it is not maintainable /nor robust to changes in the data.

enter image description here

For example, the very same code to generate the plot with different data yields catastrophic results.

data_gd <- data.frame(x = letters[1:30], 
                      y = runif(30, 100, 99999))
ggplot(data = data_gd,
       mapping = aes(x = x, y = y, fill = x)) +
    geom_bar(stat = "identity") +
    geom_text(mapping = aes(label = y, y = y/2), size = 3)

enter image description here

And I can go on with the examples, setting the size of the labels as a function of the number of categories on x-axis and so on. But you get the point, and perhaps one of you ggplot2 experts can give me ideas.

Mike Wise
  • 22,131
  • 8
  • 81
  • 104
elikesprogramming
  • 2,506
  • 2
  • 19
  • 37

2 Answers2

15

one option might be to write a geom that uses a textGrob with a custom drawDetails method to fit within the allocated space, set by the bar width.

library(grid)
library(ggplot2)

fitGrob <- function(label, x=0.5, y=0.5, width=1){
  grob(x=x, y=y, width=width, label=label, cl = "fit")
}
drawDetails.fit <- function(x, recording=FALSE){
  tw <- sapply(x$label, function(l) convertWidth(grobWidth(textGrob(l)), "native", valueOnly = TRUE))
  cex <- x$width / tw
  grid.text(x$label, x$x, x$y, gp=gpar(cex=cex), default.units = "native")
}


`%||%` <- ggplot2:::`%||%`

GeomFit <- ggproto("GeomFit", GeomRect,
                   required_aes = c("x", "label"),

                   setup_data = function(data, params) {
                     data$width <- data$width %||%
                       params$width %||% (resolution(data$x, FALSE) * 0.9)
                     transform(data,
                               ymin = pmin(y, 0), ymax = pmax(y, 0),
                               xmin = x - width / 2, xmax = x + width / 2, width = NULL
                     )
                   },
                   draw_panel = function(self, data, panel_scales, coord, width = NULL) {
                     bars <- ggproto_parent(GeomRect, self)$draw_panel(data, panel_scales, coord)
                     coords <- coord$transform(data, panel_scales)    
                     width <- abs(coords$xmax - coords$xmin)
                     tg <- fitGrob(label=coords$label, y = coords$y/2, x = coords$x, width = width)

                     grobTree(bars, tg)
                   }
)

geom_fit <- function(mapping = NULL, data = NULL,
                     stat = "count", position = "stack",
                     ...,
                     width = NULL,
                     binwidth = NULL,
                     na.rm = FALSE,
                     show.legend = NA,
                     inherit.aes = TRUE) {

  layer(
    data = data,
    mapping = mapping,
    stat = stat,
    geom = GeomFit,
    position = position,
    show.legend = show.legend,
    inherit.aes = inherit.aes,
    params = list(
      width = width,
      na.rm = na.rm,
      ...
    )
  )
}


set.seed(1234567)
data_gd <- data.frame(x = letters[1:5], 
                      y = runif(5, 100, 99999))

ggplot(data = data_gd,
       mapping = aes(x = x, y = y, fill = x, label=round(y))) +
  geom_fit(stat = "identity") +
  theme()

enter image description here

baptiste
  • 75,767
  • 19
  • 198
  • 294
  • awesome!!!, ..., still trying to understand how it works 'cause I only know the basics of `ggplot2`, but the code works smoothly and addresses both issues: it perfectly fits the text within the bars and the text resize along with the plot. I really did not think it was possible. Many thanks – elikesprogramming Mar 31 '16 at 09:23
  • Very late to the party, but this is really awesome. Learned soo much, thx. Just one question for my nderstanding: why did your GeomFit not use `GeomBar` (instead of `GeomRect`) as a parent? In this case you could also skip the `setup_data` which is AFAICT a copy/paste of the very `GeomBar$setup_data`? – thothal Mar 22 '19 at 09:23
8

If horizontal bar charts are OK, then the issue is not the size of the labels but the placement. My solution would be

enter image description here

created by this code:

library(ggplot2)
data_gd <- data.frame(x = letters[1:26], 
                      y = runif(26, 100, 99999))
ymid <- mean(range(data_gd$y))
ggplot(data = data_gd,
       mapping = aes(x = x, y = y, fill = x)) +
  geom_bar(stat = "identity") +
  geom_text(mapping = aes(label = y, y = y, 
            hjust = ifelse(y < ymid, -0.1, 1.1)), size = 3) +
  coord_flip()

The trick is done in three steps:

  1. coord_flip makes a horizontal bar chart.
  2. The mapping in geom_text uses also hjust depending on the value of y. If the bar is shorter than half of the range of y, the text is printed outside of the bar (right to the y value). If the bar is longer than half of the range of y, the text is printed inside the bar (left to the y value). This makes sure that the text is always printed inside the plot area (if not too long at all).
  3. I have added some additional space between the bar and the text. If you want the text to start or end directly at the y-value you can use hjust = ifelse(y < ymid, 0, 1)).
Uwe
  • 41,420
  • 11
  • 90
  • 134
  • Thanks. I guess this is as good as it gets, isn't it?, for me, vertical bars are a must, but I think it would be acceptable to rotate just the labels (`angle = 90`) and use your trick with `hjust` to make sure the text is printed inside the plot area. – elikesprogramming Mar 31 '16 at 06:21
  • Still wondering about resizing the plot and labels at the same time, ..., but I am almost pretty sure it is not possible at all – elikesprogramming Mar 31 '16 at 06:23
  • Text size needs to be adjusted depending on the number of bars, like `text_size <- if (n_bar <= 20) 4 else 3` or something more sophisticated using `scale_size`. – Uwe Mar 31 '16 at 06:44
  • only now looking at your answer, I realized the mistake I made in the question (`letters[1:30]`) which you kindly and quietly corrected `letters[1:26]`. Thanks again, your answer is very helpful (but @baptiste's answer below addresses both issues). – elikesprogramming Mar 31 '16 at 09:21