I'm working on a chart similar to a slopegraph, where I'd like to put labels along one or both sides with ample blank space to fit them on both sides. In cases where labels are very long, I've wrapped them using stringr::str_wrap
to place linebreaks. To keep labels from overlapping, I'm using ggrepel::geom_text_repel
with direction = "y"
so the x-positions are stable but the y-positions are repelled away from one another. I've also got hjust = "outward"
to align the left-side text at its right end and vice versa.
However, it seems that the repel positioning places the label's bounding box with an hjust = "outward"
, but the text within that label has hjust = 0.5
, i.e. text is centered within its bounds. Until now, I'd never noticed this, but with wrapped labels, the second line is awkwardly centered, whereas I'd expect to see both lines left-aligned or right-aligned.
Here's an example built off the mpg
dataset.
library(ggplot2)
library(dplyr)
library(ggrepel)
df <- structure(list(long_lbl = c("chevrolet, k1500 tahoe 4wd, auto(l4)",
"chevrolet, k1500 tahoe 4wd, auto(l4)", "subaru, forester awd, manual(m5)",
"subaru, forester awd, manual(m5)", "toyota, camry, manual(m5)",
"toyota, camry, manual(m5)", "toyota, toyota tacoma 4wd, manual(m5)",
"toyota, toyota tacoma 4wd, manual(m5)", "volkswagen, jetta, manual(m5)",
"volkswagen, jetta, manual(m5)"), year = c(1999L, 2008L, 1999L,
2008L, 1999L, 2008L, 1999L, 2008L, 1999L, 2008L), mean_cty = c(11,
14, 18, 20, 21, 21, 15, 17, 33, 21)), class = c("tbl_df", "tbl",
"data.frame"), row.names = c(NA, -10L))
df_wrap <- df %>%
mutate(wrap_lbl = stringr::str_wrap(long_lbl, width = 25))
ggplot(df_wrap, aes(x = year, y = mean_cty, group = long_lbl)) +
geom_line() +
geom_text_repel(aes(label = wrap_lbl),
direction = "y", hjust = "outward", seed = 57, min.segment.length = 100) +
scale_x_continuous(expand = expand_scale(add = 10))
The same thing happens with other values of hjust
. Looking at the function's source, I see a line that points to this issue:
hjust = x$data$hjust %||% 0.5,
where %||%
assigns 0.5 if x$data$hjust
is null. That's as far as I understand, but it seems that the hjust
I've set isn't being carried over to this positioning and is instead coming up null.
Have I missed something? Can anyone see where I might override this without reimplementing the whole algorithm? Or is there a bug here that drops my hjust
?
TL;DR: probably a bug
Long answer:
I think it might be a bug in the code. I checked the gtable of the plot you made, wherein the hjust
was specified numerically and correctly:
# Assume 'g' is the plot saved under the variable 'g'
gt <- ggplotGrob(g)
# Your number at the end of the geom may vary
textgrob <- gt$grobs[[6]]$children$geom_text_repel.textrepeltree.1578
head(textgrob$data$hjust)
[1] 1 0 1 0 1 0
Which got me thinking that (1) the plot can't be fixed by messing around in the gtable and (2) the drawtime code for the textrepeltree
class of grobs may contain some errors. This makes sense, since the labels are repositioned when the plot device is resized. So when we look at the makeContent.textrepeltree()
code in the link you provided, we can see that the hjust
parameter is passed on to makeTextRepelGrobs()
. Let's have a look at the relevant formals:
makeTextRepelGrobs <- function(
...other_arguments...,
just = "center",
...other_arguments...,
hjust = 0.5,
vjust = 0.5
) { ...body...}
We can see that hjust
is a valid argument, but there also exists a just
argument, which is an argument that is not passed on from makeContent.textrepeltree()
.
When we look at the function body there are these two lines:
hj <- resolveHJust(just, NULL)
vj <- resolveVJust(just, NULL)
Where resolveH/VJust
are imported from the grid package. The resolveHJust()
essentially checks whether the second argument is NULL
and if that is true, default to the first argument, otherwise return the second argument. You can see that the hjust
that was passed on to makeTextRepelGrobs()
does not get passed to resolveHJust()
, and this seems to be the point where your hjust
parameter is dropped unexpectedly.
Further down the code is where the actual text grobs are made:
t <- textGrob(
...other_arguments...
just = c(hj, vj),
...other_arguments...
)
I imagine that the fix would be relatively straightforward: you would just have to supply hjust
as the second argument to resolveHJust()
. However, since that makeTextRepelGrobs()
is internal to ggrepel and does not get exported, you would have to copy a lot of extra code to get this to work. (Not sure if only copying the makeTextRepelGrob()
would be sufficient, haven't tested this)
All of this leaves me to conclude that the hjust
that you specified in geom_text_repel()
gets lost at the last moment of drawtime by the makeTextRepelGrobs()
internal function.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With