Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add shadowtext functionality to ggrepel #142

Merged
merged 3 commits into from
Nov 9, 2019
Merged

Conversation

rcannood
Copy link
Contributor

@rcannood rcannood commented Nov 5, 2019

Hello @slowkow!

This PR is related to issue #103, adding 'halos' to ggrepel. I looked at the shadowtext implementation and found that adding it to ggrepel would not be that difficult. At first, I simply reused the shadowtext::shadowtextGrob() function, but that would add an extra dependency to ggrepel. Since shadowtextGrob() is relatively simple, I instead copypasted it and changed it a little bit to make it work better with ggrepel.

The timings are in line with what is to be expected:
out

Code for timings
# adapted from your code in issue #103
library(tidyverse)
library(ggrepel)
library(ggbeeswarm)
library(shadowtext)

# subset data
d <- diamonds[1:20,]

# make basic plot with no text
basic_plot <- 
  ggplot(d, aes(carat, price)) +
  geom_point()

plot_with_text <- 
  basic_plot + 
  geom_text(
    aes(label = cut),
    size = 6,
    colour = 'black',
  )

plot_with_halo_and_repel <- 
  basic_plot + 
  geom_text_repel(
    aes(label = cut),
    size = 6,
    colour = 'black', 
    seed = 1,
    segment.colour = "grey80",
    bg.colour = "white"
  )

# basic plot with geom_repel only 
plot_with_repel_only <- 
  basic_plot  + 
  geom_text_repel(
    aes(label = cut),
    size = 6,
    colour = 'black', 
    seed = 1,
    segment.colour = "grey80"
  )

# basic plot with geom_repel only 
plot_with_shadowtext_only <- 
  basic_plot  + 
  geom_shadowtext(
    aes(label = cut),
    size = 6,
    colour = 'black',
    bg.colour = "white"
  )

the_plots <- list(
  plot_with_text, # the basic plot
  plot_with_repel_only, # the plot with repel only
  plot_with_shadowtext_only, # the plot with the shadow pkg only
  plot_with_halo_and_repel # the plot with halo layers and repel
) 

the_output <- vector("list", length = length(the_plots))

benchplot_and_clear <- function(x){
  tmp <- benchplot(x)
  dev.off()
  gc()
  return(tmp)
}

for(i in 1:length(the_plots)){
  timings <- rerun(100, benchplot_and_clear(the_plots[[i]]))
  
  the_output[[i]] <- 
    timings %>% 
    bind_rows(.id = 'run') %>% 
    filter(step == "TOTAL")
  
  message(str_glue('done with plot {i}'))
  
}

the_output %>% 
  bind_rows(.id = "runs") %>% 
  ggplot(aes(runs, user.self)) +
  geom_boxplot() +
  geom_quasirandom(alpha = 0.2) +
  coord_flip() +
  scale_x_discrete(
    labels = c('basic plot',
               'plot with repel only',
               'plot with shadowtext only',
               'plot with shadowtext and repel')) +
  labs(x = "",
       y = "seconds") +
  theme_minimal(base_size = 14) +
  expand_limits(y = 0)

The only issue I still have is, where do I document this functionality? Since it's an aesthetic, it's not a parameter I can document with @param bg.colour and @param bg.r. Where do you think this documentation should end up?

Alternatively, I could create a separate function, geom_shadowtext_repel(). It could use the same underlying implementation, just with different default aesthetics (bg.colour = NA, bg.r = .1 for geom_text_repel() and bg.colour = "white", bg.r = .1 for geom_shadowtext_repel).

What are your thoughts on this?

Kind regards,
Robrecht

@slowkow
Copy link
Owner

slowkow commented Nov 6, 2019

Thank you for the pull request. This could be a nice new feature!

It looks like the shadowtext Artistic 2.0 license is compatible with GPL, so we can use the code as you've done.

I agree that we should not add @param bg.colour and @param bg.r, since those are aesthetics.

Do you prefer bg.r or bg.radius? I prefer bg.radius, to be explicit. Perhaps you prefer bg.r to be consistent with the shadowtext package? What do you think is best for users?

Request 1: Instead, I would like to ask that you please add the new aesthetics to the table below. For example, segment.color is an aesthetic. Let's test that "bg.color" and "bg.colour" are both working.

ggrepel provides additional options for `geom_text_repel` and `geom_label_repel`:
|Option | Default | Description
|--------------- | ------------ | ------------------------------------------------
|`force` | `1` | force of repulsion between overlapping text labels
|`direction` | `"both"` | move text labels "both" (default), "x", or "y" directions
|`max.time` | `0.1` | maximum number of seconds to try to resolve overlaps
|`max.iter` | `2000` | maximum number of iterations to try to resolve overlaps
|`nudge_x` | `0` | adjust the starting x position of the text label
|`nudge_y` | `0` | adjust the starting y position of the text label
|`box.padding` | `0.25 lines` | padding around the text label
|`point.padding` | `0 lines` | padding around the labeled data point
|`segment.color` | `"black"` | line segment color
|`segment.size` | `0.5 mm` | line segment thickness
|`segment.alpha` | `1.0` | line segment transparency
|`segment.curvature` | `0` | numeric, negative for left-hand and positive for right-hand curves, 0 for straight lines
|`segment.angle` | `90` | 0-180, less than 90 skews control points toward the start point
|`segment.ncp` | `1` | number of control points to make a smoother curve
|`arrow` | `NULL` | render line segment as an arrow with `grid::arrow()`

Eventually, I might reorganize a bit and try to clarify that some of these are parameters for geom_text_repel() and some of these are aesthetics. I feel a bit confused about how to communicate this clearly and effectively. I wonder if documenting aesthetics in the vignette is sufficient or if there should be additional documentation elsewhere... maybe vignette is ok for now.

Request 2: Could I also ask you to add a new example to vignettes/ggrepel.Rmd that clearly demonstrates the new functionality for newcomers?

Request 3: Could you please update DESCRIPTION to add yourself as a contributor? Also add a new item to NEWS.md.

I'll review the code and make some comments.

Comment on lines +626 to +629
if (!is.unit(x))
x <- unit(x, default.units)
if (!is.unit(y))
y <- unit(y, default.units)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move this outside the loop?

@slowkow slowkow merged commit 1959492 into slowkow:master Nov 9, 2019
slowkow added a commit that referenced this pull request Nov 9, 2019
@rcannood
Copy link
Contributor Author

rcannood commented Nov 9, 2019

Hey @slowkow,

Sorry for the slow response, I got struck down by a virus for a couple of days just after I made this pull request. I hope that's correlation and not causation.

I see that you already did all of the work for me! Thanks a lot!

Do you prefer bg.r or bg.radius?

I'm not a big fan of bg.r either, but kept it this way for compatibility reasons. Feel free to change it at your convenience :)

I feel a bit confused about how to communicate this clearly and effectively.

I think this is an issue with ggplot2 in general. I think your vignette is extensive enough that anyone interested in using ggrepel should be able to find their way, especially since you provide so many examples.

I made a few more changes to my fork:

  • Add bg.color and bg.r to the vignette, for completeness sake (df5e6a8)
  • Add a bit more documentation on which changes were made to the shadowtext function, to avoid future issues (8634cdc)

Would you like me to make another PR?

Kind regards,
Robrecht

@slowkow
Copy link
Owner

slowkow commented Nov 10, 2019

Thanks, Robrecht! I like this new feature and I hope our users will enjoy it, too.

Sorry for merging without waiting for you. I added your code comment in b194b72 so we can remember where it came from.

You are now a contributor 🙂

I'd be happy to accept future pull requests if you have any other ideas!

Here's the new documentation:

## Options
Options allow us to change the behavior of ggrepel to fit the needs
of our figure. Most of them are global options that affect all of
the text labels, but some can be vectors of the same length as
your data, like `nudge_x` or `nudge_y`.
|Option | Default | Description
|--------------- | ------------ | ------------------------------------------------
|`force` | `1` | force of repulsion between overlapping text labels
|`direction` | `"both"` | move text labels "both" (default), "x", or "y" directions
|`max.time` | `0.1` | maximum number of seconds to try to resolve overlaps
|`max.iter` | `2000` | maximum number of iterations to try to resolve overlaps
|`nudge_x` | `0` | adjust the starting x position of the text label
|`nudge_y` | `0` | adjust the starting y position of the text label
|`box.padding` | `0.25 lines` | padding around the text label
|`point.padding` | `0 lines` | padding around the labeled data point
|`arrow` | `NULL` | render line segment as an arrow with `grid::arrow()`
## Aesthetics
Aesthetics are parameters that can be mapped to your data with `geom_text_repel(mapping = aes(...))`.
ggrepel provides the same aesthetics for `geom_text_repel` and `geom_label_repel` that are available in [geom_text()][geom_text] or [geom_label()][geom_text], but it also provides a few more that are unique to ggrepel.
[geom_text]: http://ggplot2.tidyverse.org/reference/geom_text.html
All of them are listed below. See the [ggplot2 documentation about aesthetic specifications][aes] for more details and examples.
[aes]: https://ggplot2.tidyverse.org/articles/ggplot2-specs.html
|Aesthetic | Default | Description
|--------------- | ------------ | ------------------------------------------------
|`color` | `"black"` | text and label border color
|`size` | `3.88` | font size
|`angle` | `0` | angle of the text label
|`alpha` | `NA` | transparency of the text label
|`family` | `""` | font name
|`fontface` | `1` | "plain", "bold", "italic", "bold.italic"
|`lineheight` | `1.2` | line height for text labels
|`hjust` | `0.5` | horizontal justification, different from geom_text()
|`vjust` | `0.5` | vertical justification, different from geom_text()
|`point.size` | `1` | size of each point for each text label
|`segment.linetype` | `1` | line segment solid, dashed, etc.
|`segment.color` | `"black"` | line segment color
|`segment.size` | `0.5 mm` | line segment thickness
|`segment.alpha` | `1.0` | line segment transparency
|`segment.curvature` | `0` | numeric, negative for left-hand and positive for right-hand curves, 0 for straight lines
|`segment.angle` | `90` | 0-180, less than 90 skews control points toward the start point
|`segment.ncp` | `1` | number of control points to make a smoother curve

@GuangchuangYu
Copy link

I don't think it is necessary to prevent dependency of shadowtext as what it depends also depended by ggrepel.

https://github.com/GuangchuangYu/shadowtext/blob/325d25919b28ccd4184c6363c11c8c26e822dd95/DESCRIPTION#L7-L14

Depends: R (>= 3.4.0)
Imports:
    ggplot2,
    grid,
    scales
Suggests:
    knitr,
    prettydoc

and the shadowtextGrob (1 and 2) are almost identical.

@slowkow
Copy link
Owner

slowkow commented Nov 11, 2019

Hi Guangchuang, thanks for the comment. Thanks for writing shadowtextGrob() in the shadowtext package. I think it's great, and Robrecht and I tailored it to match our needs for ggrepel. It's nice to have the freedom to consider other features I might like to add to it that ggrepel users might enjoy.

What I love about open source software development, is that when we use permissive software licenses like General Public License (ggrepel, ggplot2, R) and Artistic License (shadowtext), this gives all of us the freedom to contribute to an enormous collection of work we all create and share.

ggrepel also has code copied from ggplot2, shown below.

ggwordcloud also started by using and modifying my repel_boxes.cpp code from the ggrepel package into a new file called wordcloud_boxes.cpp. I'm very impressed, and happy to see that my small contribution could be reused in a way that I couldn't have done myself.

Thanks again for sharing! 🐮

Kamil

Example 1

https://github.com/tidyverse/ggplot2/blob/40e8b6094e23cad19c8f06182af6730a963c205f/R/utilities.r#L388-L405

# Parse takes a vector of n lines and returns m expressions.
# See https://github.com/tidyverse/ggplot2/issues/2864 for discussion.
#
# parse(text = c("alpha", "", "gamma"))
# #> expression(alpha, gamma)
#
# parse_safe(text = c("alpha", "", "gamma"))
# #> expression(alpha, NA, gamma)
#
parse_safe <- function(text) {
  stopifnot(is.character(text))
  out <- vector("expression", length(text))
  for (i in seq_along(text)) {
    expr <- parse(text = text[[i]])
    out[[i]] <- if (length(expr) == 0) NA else expr[[1]]
  }
  out
}

ggrepel/R/utilities.R

Lines 52 to 69 in 72bdd55

# Parse takes a vector of n lines and returns m expressions.
# See https://github.com/tidyverse/ggplot2/issues/2864 for discussion.
#
# parse(text = c("alpha", "", "gamma"))
# #> expression(alpha, gamma)
#
# parse_safe(text = c("alpha", "", "gamma"))
# #> expression(alpha, NA, gamma)
#
parse_safe <- function(text) {
stopifnot(is.character(text))
out <- vector("expression", length(text))
for (i in seq_along(text)) {
expr <- parse(text = text[[i]])
out[[i]] <- if (length(expr) == 0) NA else expr[[1]]
}
out
}

Example 2

https://github.com/tidyverse/ggplot2/blob/40e8b6094e23cad19c8f06182af6730a963c205f/R/position-nudge.R

#' Nudge points a fixed distance
#'
#' `position_nudge` is generally useful for adjusting the position of
#' items on discrete scales by a small amount. Nudging is built in to
#' [geom_text()] because it's so useful for moving labels a small
#' distance from what they're labelling.
#'
#' @family position adjustments
#' @param x,y Amount of vertical and horizontal distance to move.
#' @export
#' @examples
#' df <- data.frame(
#'   x = c(1,3,2,5),
#'   y = c("a","c","d","c")
#' )
#'
#' ggplot(df, aes(x, y)) +
#'   geom_point() +
#'   geom_text(aes(label = y))
#'
#' ggplot(df, aes(x, y)) +
#'   geom_point() +
#'   geom_text(aes(label = y), position = position_nudge(y = -0.1))
#'
#' # Or, in brief
#' ggplot(df, aes(x, y)) +
#'   geom_point() +
#'   geom_text(aes(label = y), nudge_y = -0.1)
position_nudge <- function(x = 0, y = 0) {
  ggproto(NULL, PositionNudge,
    x = x,
    y = y
  )
}

#' @rdname ggplot2-ggproto
#' @format NULL
#' @usage NULL
#' @export
PositionNudge <- ggproto("PositionNudge", Position,
  x = 0,
  y = 0,

  setup_params = function(self, data) {
    list(x = self$x, y = self$y)
  },

  compute_layer = function(self, data, params, layout) {
    # transform only the dimensions for which non-zero nudging is requested
    if (any(params$x != 0)) {
      if (any(params$y != 0)) {
        transform_position(data, function(x) x + params$x, function(y) y + params$y)
      } else {
        transform_position(data, function(x) x + params$x, NULL)
      }
    } else if (any(params$y != 0)) {
      transform_position(data, NULL, function(y) y + params$y)
    } else {
      data # if both x and y are 0 we don't need to transform
    }
  }
)

#' Nudge points a fixed distance
#'
#' `position_nudge` is generally useful for adjusting the position of
#' items on discrete scales by a small amount. Nudging is built in to
#' [geom_text()] because it's so useful for moving labels a small
#' distance from what they're labelling.
#'
#' @family position adjustments
#' @param x,y Amount of vertical and horizontal distance to move.
#' @examples
#' df <- data.frame(
#' x = c(1,3,2,5),
#' y = c("a","c","d","c")
#' )
#'
#' ggplot(df, aes(x, y)) +
#' geom_point() +
#' geom_text(aes(label = y))
#'
#' ggplot(df, aes(x, y)) +
#' geom_point() +
#' geom_text(aes(label = y), position = position_nudge(y = -0.1))
#'
#' # Or, in brief
#' ggplot(df, aes(x, y)) +
#' geom_point() +
#' geom_text(aes(label = y), nudge_y = -0.1)
position_nudge2 <- function(x = 0, y = 0) {
ggproto(NULL, PositionNudge2,
x = x,
y = y
)
}
#' @rdname ggplot2-ggproto
#' @format NULL
#' @usage NULL
PositionNudge2 <- ggproto("PositionNudge2", Position,
x = 0,
y = 0,
setup_params = function(self, data) {
list(x = self$x, y = self$y)
},
compute_layer = function(data, params, panel) {
x_orig <- data$x
y_orig <- data$y
# transform only the dimensions for which non-zero nudging is requested
if (any(params$x != 0)) {
if (any(params$y != 0)) {
data <- transform_position(data, function(x) x + params$x, function(y) y + params$y)
} else {
data <- transform_position(data, function(x) x + params$x, NULL)
}
} else if (any(params$y != 0)) {
data <- transform_position(data, NULL, function(y) y + params$y)
}
data$nudge_x <- data$x
data$nudge_y <- data$y
data$x <- x_orig
data$y <- y_orig
data
}
)

@rcannood
Copy link
Contributor Author

Hello Guangchuang,

I agree with Kamil's statements but wish to clarify the decisions I made in making this PR.

I apologise for having reused your code without your consent, but this is exactly the beauty of open-source software. I considered proposing to add shadowtext as a dependency to ggrepel. However, simply using shadowtext::shadowtextGrob() instead of the current ggrepel::shadowtextGrob() didn't work right out of the box. Since the function is relatively small in terms of the amount of code, I felt it would be better to simply copy-paste it and modify it.

Since shadowtext is mentioned in the documentation, if people use the shadowtext functionality in ggrepel, they will undoubtedly learn about using shadowtext the package when they need it in a non-repel scenario :)

I hope this helps you understand the reasoning behind my actions, even if you do not agree with them per se. If you have any further questions or comments, I'll be happy to answer them.

Kind regards,
Robrecht

@GuangchuangYu
Copy link

GuangchuangYu commented Nov 14, 2019

I didn't say you are doing it in the wrong way. I am quite happy to see the shadowtext functionality was incorporated into ggrepel.

The modification is quite simple, ggrepel::shadowtextGrob() return gList instead of gTree returned by shadowtext::shadowtextGrob().

You can access the gList from the gTree object very easy:

> a = gList(textGrob('a'),textGrob('b'))
> b = gTree(children=a)
> x = b$children
> a
(text[GRID.text.11], text[GRID.text.12]) 
> x
(text[GRID.text.11], text[GRID.text.12]) 
> class(x)
[1] "gList"

And you can also ask me to return a gList from shadowtext::shadowtextGrob(). I am also happy to adjust my source code to make it more extensible.

Taking another issue that you are considering to change the parameter names, IMO such things should be happened in the shadowtext side if it is neccessary, for parameter consistent is very important for users. They don't need to remember or check manual to find out that the parameters are different in different packages for the same thing.

Don't get me wrong. I just wondering whether there is a better way to do it.

@liuyanguu
Copy link

liuyanguu commented May 28, 2020

Thank you Kamil for ggrepel and @GuangchuangYu for shadowtext! Both are great packages that my colleagues and I found very useful and it is great to see that they could be combined! This is perfect.

In the beginning, I had some problems installing from Github but then I downloaded and compiled locally it works fine.

A trivial note: the example code on your example webpage is a little bit confusing @slowkow since there is no dat.

set.seed(42)
ggplot(dat, aes(wt, mpg, label = car)) +
  geom_point(color = "red") +
  geom_text_repel(
    color = "white",     # text color
    bg.color = "grey30", # shadow color
    bg.r = 0.15          # shadow radius
  )

And the webpage says ggrepel 0.9.0, which I suppose hasn't been released?
Thanks again for the great work.

@slowkow
Copy link
Owner

slowkow commented May 28, 2020

You're right! The 0.9.0 version has not yet been released, it is only available through GitHub right now. When time allows, I will upload the next version to CRAN.

And the dat variable is defined all the way up in the very first chunk of code on the examples page.

@kklot
Copy link

kklot commented Nov 12, 2020

This increases the graphic size (pdf) subtaintially (from ~1Mb to 15Mb) in my case with 49 legends. Is there a way to control this?

@slowkow
Copy link
Owner

slowkow commented Nov 12, 2020

@kklot Please consider opening a new issue and providing a reprex.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants