ggblend

Blending and Compositing Algebra for 'ggplot2'


License
MIT

Documentation

ggblend

Lifecycle: experimental CRAN status Codecov test coverage R-CMD-check

ggblend adds support for blend modes (like "multiply", "overlay", etc) to ggplot2. It requires R >= 4.2, as blending and compositing support was added in that version of R.

Installation

You can install the development version of ggblend using:

remotes::install_github("mjskay/ggblend")

Blending within one geometry

We’ll construct a simple dataset with two semi-overlapping point clouds. We’ll have two versions of the dataset: one with all the "a" points listed first, and one with all the "b" points listed first.

library(ggplot2)
library(ggblend)
theme_set(theme_light())

set.seed(1234)
df_a = data.frame(x = rnorm(500, 0), y = rnorm(500, 1), set = "a")
df_b = data.frame(x = rnorm(500, 1), y = rnorm(500, 2), set = "b")

df_ab = rbind(df_a, df_b) |>
  transform(order = "draw a then b")

df_ba = rbind(df_b, df_a) |>
  transform(order = "draw b then a")

df = rbind(df_ab, df_ba)

A typical scatterplot of such data suffers from the problem that how many points appear to be in each group depends on the drawing order (a then b versus b then a):

df |>
  ggplot(aes(x, y, color = set)) +
  geom_point(size = 3) +
  scale_color_brewer(palette = "Set2") +
  facet_grid(~ order) +
  ggtitle("Scatterplot without blending: draw order matters")

A commutative blend mode, like "multiply" or "darken", is one potential solution that does not depend on drawing order. We can wrap geom_point() in a call to blend() to achieve this, using something like blend(geom_point(...), "darken") or equivalently geom_point(...) |> blend("darken"):

df |>
  ggplot(aes(x, y, color = set)) +
  geom_point(size = 3) |> blend("darken") +
  scale_color_brewer(palette = "Set2") +
  facet_grid(~ order) +
  ggtitle("Scatterplot with blend('darken'): draw order does not matter")

Now the output is identical no matter the draw order.

Alpha blending

One disadvantage of the "darken" blend mode is that it doesn’t show density within each group as well. We could use "multiply" instead, though then values can get quite dark:

df |>
  ggplot(aes(x, y, color = set)) +
  geom_point(size = 3) |> blend("multiply") +
  scale_color_brewer(palette = "Set2") +
  facet_grid(~ order) +
  ggtitle("Scatterplot with blend('multiply'): a little too dark")

Let’s try combining two blend modes to address this: we’ll use a "lighten" blend mode (which is also commutative, but which makes the overlapping regions lighter), and then draw the "multiply"-blended version on top at an alpha of less than 1:

df |>
  ggplot(aes(x, y, color = set)) +
  geom_point(size = 3) |> blend("lighten") +
  geom_point(size = 3) |> blend("multiply", alpha = 0.65) +
  scale_color_brewer(palette = "Set2") +
  facet_grid(~ order) +
  ggtitle("Scatterplot with blend('lighten') and blend(multiply, alpha = 0.65)")

Now it’s a little easier to see both overlap and density, and the output remains independent of draw order.

Blending multiple geometries

We can also blend geometries together by passing a list of geometries to blend(). These lists can include already-blended geometries:

df |>
  ggplot(aes(x, y, color = set)) +
  list(
    geom_point(size = 3) |> blend("darken"),
    geom_vline(xintercept = 0, color = "gray75", size = 1.5),
    geom_hline(yintercept = 0, color = "gray75", size = 1.5)
  ) |> blend("hard.light") +
  scale_color_brewer(palette = "Set2") +
  facet_grid(~ order) +
  labs(title = "Blending multiple geometries together")

Blending groups within one geometry

Sometimes it’s useful to have finer-grained control of blending within a given geometry. Here, we’ll show how to use blend() with stat_lineribbon() from ggdist to create overlapping gradient ribbons depicting uncertainty.

We’ll fit a model:

m_mpg = lm(mpg ~ hp * cyl, data = mtcars)

And generate some confidence distributions for the mean using distributional:

grid = unique(mtcars[ c("cyl", "hp")])

predictions = grid |>
  cbind(predict(m_mpg, newdata = grid, se.fit = TRUE)) |>
  transform(mu_hat = distributional::dist_student_t(df = df, mu = fit, sigma = se.fit))

predictions
##                     cyl  hp      fit    se.fit df residual.scale
## Mazda RX4             6 110 20.28825 0.7984429 28       2.973694
## Datsun 710            4  93 25.74371 0.8818612 28       2.973694
## Hornet Sportabout     8 175 15.56144 0.8638133 28       2.973694
## Valiant               6 105 20.54952 0.8045354 28       2.973694
## Duster 360            8 245 14.66678 0.9773475 28       2.973694
## Merc 240D             4  62 28.58736 1.2184598 28       2.973694
## Merc 230              4  95 25.56025 0.9024699 28       2.973694
## Merc 280              6 123 19.60892 0.8423540 28       2.973694
## Merc 450SE            8 180 15.49754 0.8332276 28       2.973694
## Cadillac Fleetwood    8 205 15.17801 0.7674501 28       2.973694
## Lincoln Continental   8 215 15.05021 0.7866649 28       2.973694
## Chrysler Imperial     8 230 14.85849 0.8606705 28       2.973694
## Fiat 128              4  66 28.22044 1.1218796 28       2.973694
## Honda Civic           4  52 29.50466 1.4914670 28       2.973694
## Toyota Corolla        4  65 28.31217 1.1451536 28       2.973694
## Toyota Corona         4  97 25.37679 0.9280143 28       2.973694
## Dodge Challenger      8 150 15.88096 1.0770037 28       2.973694
## Porsche 914-2         4  91 25.92718 0.8665404 28       2.973694
## Lotus Europa          4 113 23.90910 1.2628426 28       2.973694
## Ford Pantera L        8 264 14.42394 1.1660619 28       2.973694
## Ferrari Dino          6 175 16.89163 1.5508852 28       2.973694
## Maserati Bora         8 335 13.51650 2.0458075 28       2.973694
## Volvo 142E            4 109 24.27603 1.1625257 28       2.973694
##                                         mu_hat
## Mazda RX4           t(28, 20.28825, 0.7984429)
## Datsun 710          t(28, 25.74371, 0.8818612)
## Hornet Sportabout   t(28, 15.56144, 0.8638133)
## Valiant             t(28, 20.54952, 0.8045354)
## Duster 360          t(28, 14.66678, 0.9773475)
## Merc 240D             t(28, 28.58736, 1.21846)
## Merc 230            t(28, 25.56025, 0.9024699)
## Merc 280             t(28, 19.60892, 0.842354)
## Merc 450SE          t(28, 15.49754, 0.8332276)
## Cadillac Fleetwood  t(28, 15.17801, 0.7674501)
## Lincoln Continental t(28, 15.05021, 0.7866649)
## Chrysler Imperial   t(28, 14.85849, 0.8606705)
## Fiat 128              t(28, 28.22044, 1.12188)
## Honda Civic          t(28, 29.50466, 1.491467)
## Toyota Corolla       t(28, 28.31217, 1.145154)
## Toyota Corona       t(28, 25.37679, 0.9280143)
## Dodge Challenger     t(28, 15.88096, 1.077004)
## Porsche 914-2       t(28, 25.92718, 0.8665404)
## Lotus Europa          t(28, 23.9091, 1.262843)
## Ford Pantera L       t(28, 14.42394, 1.166062)
## Ferrari Dino         t(28, 16.89163, 1.550885)
## Maserati Bora         t(28, 13.5165, 2.045807)
## Volvo 142E           t(28, 24.27603, 1.162526)

A basic plot based on examples in vignette("freq-uncertainty-vis", package = "ggdist") and vignette("lineribbon", package = "ggdist") may have issues when lineribbons overlap:

predictions |>
  ggplot(aes(x = hp, fill = ordered(cyl), color = ordered(cyl))) +
  ggdist::stat_lineribbon(
    aes(ydist = mu_hat, fill_ramp = stat(.width)),
    .width = ppoints(40)
  ) +
  geom_point(aes(y = mpg), data = mtcars) +
  scale_fill_brewer(palette = "Set2") +
  scale_color_brewer(palette = "Dark2") +
  ggdist::scale_fill_ramp_continuous(range = c(1, 0)) +
  labs(
    title = "Overlapping lineribbons without blending", 
    color = "cyl", fill = "cyl", y = "mpg"
  ) +
  ggdist::theme_ggdist()

Notice the overlap of the orange (cyl = 6) and purple (cyl = 8) lines.

If we add a blend_group = cyl aesthetic mapping, we can blend the geometries for the different levels of cyl together with a blend() call around ggdist::stat_lineribbon():

predictions |>
  ggplot(aes(x = hp, fill = ordered(cyl), color = ordered(cyl), blend_group = cyl)) +
  ggdist::stat_lineribbon(
    aes(ydist = mu_hat, fill_ramp = stat(.width)),
    .width = ppoints(40)
  ) |> blend("multiply") +
  geom_point(aes(y = mpg), data = mtcars) +
  scale_fill_brewer(palette = "Set2") +
  scale_color_brewer(palette = "Dark2") +
  ggdist::scale_fill_ramp_continuous(range = c(1, 0)) +
  labs(
    title = "Overlapping lineribbons with aes(blend_group = cyl) and blend('multiply')", 
    color = "cyl", fill = "cyl", y = "mpg"
  ) +
  ggdist::theme_ggdist()

Now the overlapping ribbons are blended together.

Compatibility with other packages

In theory ggblend should be compatible with other packages, though in more complex cases (blending lists of geoms or using the blend_group aesthetic) it is possible it may fail, as these features are a bit more hackish.

As a hard test, here is all three features applied to a modified version of the Gapminder example used in the gganimate documentation:

library(gganimate)
library(gapminder)

p = ggplot(gapminder, aes(gdpPercap, lifeExp, size = pop, color = continent, blend_group = continent)) +
  list(
    geom_point(show.legend = c(size = FALSE)) |> blend("multiply"),
    geom_hline(yintercept = 70, size = 2.5, color = "gray75")
  ) |> blend("hard.light") +
  scale_color_manual(values = continent_colors) +
  scale_size(range = c(2, 12)) +
  scale_x_log10() +
  labs(
    title = 'Gapminder with gganimate and ggblend', 
    subtitle = 'Year: {frame_time}', 
    x = 'GDP per capita', 
    y = 'life expectancy'
  ) +
  transition_time(year) +
  ease_aes('linear')

animate(p, type = "cairo")