*ggblend* is a small algebra of operations for blending,
copying, adjusting, and compositing layers in *ggplot2*. It
allows you to easily copy and adjust the aesthetics or parameters of an
existing layer, to partition a layer into multiple pieces for
re-composition, and to combine layers (or partitions of layers) using
blend modes (like `"multiply"`

, `"overlay"`

,
etc).

*ggblend* requires R ≥ 4.2, as blending and compositing
support was added in that version of R.

You can install the development version of *ggblend*
using:

`::install_github("mjskay/ggblend") remotes`

If/when *ggblend* is on CRAN, install it using:

`install.packages("ggblend")`

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(ggdist::theme_ggdist() + theme(
plot.title = element_text(size = rel(1), lineheight = 1.1, face = "bold"),
plot.subtitle = element_text(face = "italic"),
panel.border = element_rect(color = "gray75", fill = NA)
))
set.seed(1234)
= data.frame(x = rnorm(500, 0), y = rnorm(500, 1), set = "a")
df_a = data.frame(x = rnorm(500, 1), y = rnorm(500, 2), set = "b")
df_b
= rbind(df_a, df_b) |>
df_ab transform(order = "draw a then b")
= rbind(df_b, df_a) |>
df_ba transform(order = "draw b then a")
= rbind(df_ab, df_ba) df
```

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, alpha = 0.5) +
scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(title = "geom_point() without blending", subtitle = "Draw order matters.")
```

A *commutative* blend mode, like `"multiply"`

or
`"darken"`

, is one potential solution that does not depend on
drawing order. We can apply a `blend()`

operation to
geom_point()` to achieve this. There three ways to do this:

`blend(geom_point(...), "multiply")`

(normal function application)`geom_point(...) |> blend("multiply")`

(piping)`geom_point(...) * blend("multiply")`

(algebraic operations)

Function application and piping are equivalent. **In this
case**, all three approaches are equivalent. As we will see
later, the multiplication approach is useful when we want a shorthand
for applying the same operation to multiple layers in a list without
combining those layers first (in other words, multiplication of
operations over layers is *distributive* in an algebraic
sense).

```
|>
df ggplot(aes(x, y, color = set)) +
geom_point(size = 3, alpha = 0.5) |> blend("multiply") +
scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(
title = "geom_point(alpha = 0.5) |> blend('multiply')",
subtitle = "Draw order does not matter, but color is too dark."
)
```

Now the output is identical no matter the draw order, although the output is quite dark.

Part of the reason the output is very dark above is that all of the points are being multiply-blended together. When many objects (here, individual points) are multiply-blended on top of each other, the output tends to get dark very quickly.

However, we really only need the two sets to be multiply-blended with
each other. Within each set, we can use regular alpha blending. To do
that, we can partition the geometry by `set`

and then blend.
Each partition will be blended normally within the set, and then the
resulting sets will be multiply-blended together just once:

```
|>
df ggplot(aes(x, y, color = set)) +
geom_point(size = 3, alpha = 0.5) |> partition(vars(set)) |> blend("multiply") +
scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(
title = "geom_point(alpha = 0.5) |> partition(vars(set)) |> blend('multiply')",
subtitle = "Light outside the intersection, but still dark inside the intersection."
)
```

That’s getting there: points outside the intersection of the two sets look good, but the intersection is still a bit dark.

Let’s try combining two blend modes to address this: we’ll use a
`"lighten"`

blend mode (which is also commutative) to make
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, alpha = 0.5) |> partition(vars(set)) |> blend("lighten") +
geom_point(size = 3, alpha = 0.5) |> partition(vars(set)) |> blend("multiply", alpha = 0.5) +
scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(
title =
"geom_point(size = 3, alpha = 0.5) |> partition(vars(set)) |> blend('lighten') + \ngeom_point(size = 3, alpha = 0.5) |> partition(vars(set)) |> blend('multiply', alpha = 0.5)",
subtitle = 'A good compromise, but a long specification.'
+
) theme(plot.subtitle = element_text(lineheight = 1.2))
```

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

However, it is a little verbose to need to copy out a layer multiple times:

```
geom_point(size = 3, alpha = 0.5) |> partition(vars(set)) * blend("lighten") +
geom_point(size = 3, alpha = 0.5) |> partition(vars(set)) * blend("multiply", alpha = 0.5) +
```

We can simplify this is two ways: first,
`partition(vars(set))`

is equivalent to setting
`aes(partition = set)`

, so we can move the partition
specification into the global plot aesthetics, since it is the same on
every layer.

Second, operations and layers in *ggblend* act as a small
algebra. Operations and sums of operations can be multiplied by layers
and lists of layers, and those operations are distributed over the
layers (This is where `*`

and `|>`

differ:
`|>`

does not distribute operations like
`blend()`

over layers, which is useful if you want to use a
blend to combine multiple layers together, rather than applying that
blend to each layer individually).

Thus, we can “factor out”
`geom_point(size = 3, alpha = 0.5)`

from the above
expression, yielding this:

`geom_point(size = 3, alpha = 0.5) * (blend("lighten") + blend("multiply", alpha = 0.5))`

Both expressions are equivalent. Thus we can rewrite the previous example like so:

```
|>
df ggplot(aes(x, y, color = set, partition = set)) +
geom_point(size = 3, alpha = 0.5) * (blend("lighten") + blend("multiply", alpha = 0.5)) +
scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(
title = "geom_point(aes(partition = set)) * (blend('lighten') + blend('multiply', alpha = 0.5))",
subtitle = "Two order-independent blends on one layer using the distributive law."
+
) theme(plot.subtitle = element_text(lineheight = 1.2))
```

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, partition = set)) +
list(
geom_point(size = 3, alpha = 0.5) * (blend("lighten") + blend("multiply", alpha = 0.5)),
geom_vline(xintercept = 0, color = "gray75", linewidth = 1.5),
geom_hline(yintercept = 0, color = "gray75", linewidth = 1.5)
|> blend("hard.light") +
) scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(
title = "Blending multiple geometries together in a list",
subtitle = "Careful! The point layer blend is incorrect!"
)
```

Whoops!! If you look closely, the blending of the
`geom_point()`

layers appears to have changed. Recall that
this expression:

`geom_point(size = 3, alpha = 0.5) * (blend("lighten") + blend("multiply", alpha = 0.5))`

Is equivalent to specifying two separate layers, one with
`blend("lighten")`

and the other with
`blend("multiply", alpha = 0.65))`

. Thus, when you apply
`|> blend("hard.light")`

to the `list()`

of
layers, it will use a hard light blend mode to blend these two layers
together, when previously they would be blended using the normal (or
`"over"`

) blend mode.

We can gain back the original appearance by blending these two layers
together with `|> blend()`

prior to applying the hard
light blend:

```
|>
df ggplot(aes(x, y, color = set, partition = set)) +
list(
geom_point(size = 3, alpha = 0.5) * (blend("lighten") + blend("multiply", alpha = 0.5)) |> blend(),
geom_vline(xintercept = 0, color = "gray75", linewidth = 1.5),
geom_hline(yintercept = 0, color = "gray75", linewidth = 1.5)
|> blend("hard.light") +
) scale_color_brewer(palette = "Set1") +
facet_grid(~ order) +
labs(title = "Blending multiple geometries together")
```

Another case where it’s useful to have finer-grained control of
blending within a given geometry is when drawing overlapping uncertainty
bands. 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:

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

And generate some confidence distributions for the mean using distributional:

```
= unique(mtcars[, c("cyl", "hp")])
predictions
$mu_hat = with(predict(m_mpg, newdata = predictions, se.fit = TRUE),
predictions::dist_student_t(df = df, mu = fit, sigma = se.fit)
distributional
)
predictions
```

```
## cyl hp mu_hat
## Mazda RX4 6 110 t(28, 20.28825, 0.7984429)
## Datsun 710 4 93 t(28, 25.74371, 0.8818612)
## Hornet Sportabout 8 175 t(28, 15.56144, 0.8638133)
## Valiant 6 105 t(28, 20.54952, 0.8045354)
## Duster 360 8 245 t(28, 14.66678, 0.9773475)
## Merc 240D 4 62 t(28, 28.58736, 1.21846)
## Merc 230 4 95 t(28, 25.56025, 0.9024699)
## Merc 280 6 123 t(28, 19.60892, 0.842354)
## Merc 450SE 8 180 t(28, 15.49754, 0.8332276)
## Cadillac Fleetwood 8 205 t(28, 15.17801, 0.7674501)
## Lincoln Continental 8 215 t(28, 15.05021, 0.7866649)
## Chrysler Imperial 8 230 t(28, 14.85849, 0.8606705)
## Fiat 128 4 66 t(28, 28.22044, 1.12188)
## Honda Civic 4 52 t(28, 29.50466, 1.491467)
## Toyota Corolla 4 65 t(28, 28.31217, 1.145154)
## Toyota Corona 4 97 t(28, 25.37679, 0.9280143)
## Dodge Challenger 8 150 t(28, 15.88096, 1.077004)
## Porsche 914-2 4 91 t(28, 25.92718, 0.8665404)
## Lotus Europa 4 113 t(28, 23.9091, 1.262843)
## Ford Pantera L 8 264 t(28, 14.42394, 1.166062)
## Ferrari Dino 6 175 t(28, 16.89163, 1.550885)
## Maserati Bora 8 335 t(28, 13.5165, 2.045807)
## Volvo 142E 4 109 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))) +
::stat_lineribbon(
ggdistaes(ydist = mu_hat, fill_ramp = after_stat(.width)),
.width = ppoints(40)
+
) geom_point(aes(y = mpg), data = mtcars) +
scale_fill_brewer(palette = "Set2") +
scale_color_brewer(palette = "Dark2") +
::scale_fill_ramp_continuous(range = c(1, 0)) +
ggdistlabs(
title = "ggdist::stat_lineribbon()",
subtitle = "Overlapping lineribbons obscure each other.",
color = "cyl", fill = "cyl", y = "mpg"
)
```

Notice the overlap of the orange (`cyl = 6`

) and purple
(`cyl = 8`

) lines.

If we add a `partition = cyl`

aesthetic mapping, we can
blend the geometries for the different levels of `cyl`

together with a `blend()`

call around
`ggdist::stat_lineribbon()`

.

There are many ways we could add the partition to the plot:

- Add
`partition = cyl`

to the existing`aes(...)`

call. However, this leaves the partitioning information far from the call to`blend()`

, so the relationship between them is less clear. - Add
`aes(partition = cyl)`

to the`stat_lineribbon(...)`

call. This is a more localized change (better!), but will raise a warning if`stat_lineribbon()`

itself does not recognized the`partition`

aesthetic. - Add
`|> adjust(aes(partition = cyl))`

after`stat_lineribbon(...)`

to add the`partition`

aesthetic to it (this will bypass the warning). - Add
`|> partition(vars(cyl))`

after`stat_lineribbon(...)`

to add the`partition`

aesthetic. This is an alias for the`adjust()`

approach that is intended to be clearer. It takes a specification for a partition that is similar to`facet_wrap()`

: either a one-sided formula or a call to`vars()`

.

Let’s try the fourth approach:

```
|>
predictions ggplot(aes(x = hp, fill = ordered(cyl), color = ordered(cyl))) +
::stat_lineribbon(
ggdistaes(ydist = mu_hat, fill_ramp = after_stat(.width)),
.width = ppoints(40)
|> partition(vars(cyl)) |> blend("multiply") +
) geom_point(aes(y = mpg), data = mtcars) +
scale_fill_brewer(palette = "Set2") +
scale_color_brewer(palette = "Dark2") +
::scale_fill_ramp_continuous(range = c(1, 0)) +
ggdistlabs(
title = "ggdist::stat_lineribbon() |> partition(vars(cyl)) |> blend('multiply')",
subtitle = "Overlapping lineribbons blend together independent of draw order.",
color = "cyl", fill = "cyl", y = "mpg"
)
```

Now the overlapping ribbons are blended together.

`copy_under()`

A common visualization technique to make a layer more salient
(especially in the presence of many other competing layers) is to add a
small outline around it. For some geometries (like
`geom_point()`

) this is easy; but for others (like
`geom_line()`

), there’s no easy way to do this without
manually copying the layer.

The *ggblend* layer algebra makes this straightforward using
the `adjust()`

operation combined with operator addition and
multiplication. For example, given a layer like:

`geom_line(linewidth = 1)`

To add a white outline, you might want something like:

`geom_line(color = "white", linewidth = 2.5) + geom_line(linewidth = 1)`

However, we’d rather not have to write the `geom_line()`

specification twice If we factor out the differences between the first
and second layer, we can use the `adjust()`

operation (which
lets you change the aesthetics and parameters of a layer) along with the
distributive law to factor out `geom_line(linewidth = 1)`

and
write the above specification as:

`geom_line(linewidth = 1) * (adjust(color = "white", linewidth = 2.5) + 1)`

The `copy_under(...)`

operation, which is a synonym for
`adjust(...) + 1`

, also implements this pattern:

`geom_line(linewidth = 1) * copy_under(color = "white", linewidth = 2.5)`

Here’s an example highlighting the fit lines from our previous lineribbon example:

```
|>
predictions ggplot(aes(x = hp, fill = ordered(cyl), color = ordered(cyl))) +
::stat_ribbon(
ggdistaes(ydist = mu_hat, fill_ramp = after_stat(.width)),
.width = ppoints(40)
|> partition(vars(cyl)) |> blend("multiply") +
) geom_line(aes(y = median(mu_hat)), linewidth = 1) |> copy_under(color = "white", linewidth = 2.5) +
geom_point(aes(y = mpg), data = mtcars) +
scale_fill_brewer(palette = "Set2") +
scale_color_brewer(palette = "Dark2") +
::scale_fill_ramp_continuous(range = c(1, 0)) +
ggdistlabs(
title = "geom_line() |> copy_under(color = 'white', linewidth = 2.5)",
subtitle = "Highlights the line layer without manually copying its specification.",
color = "cyl", fill = "cyl", y = "mpg"
)
```

Note that the implementation of `copy_under(...)`

is
simply a synonym for `adjust(...) + 1`

; we can see this if we
look at `copy_under()`

itself:

`copy_under()`

`## <operation>: (adjust() + 1)`

In fact, not that it is particularly useful, but addition and multiplication of layer operations is expanded appropriately:

`adjust() + 3) * 2 (`

`## <operation>: (adjust() + 1 + 1 + 1 + adjust() + 1 + 1 + 1)`

I hesitate to imagine what that feature might be useful for…

In theory *ggblend* should be compatible with other packages,
though in more complex cases (blending lists of geoms or using the
`partition`

aesthetic) it is possible it may fail, as these
features are a bit more hackish. I have done some testing with a few
other layer-manipulating packages—including gganimate, ggnewscale, and relayer—and they appear
to be compatible.

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)
= ggplot(gapminder, aes(gdpPercap, lifeExp, size = pop, color = continent, partition = continent)) +
p list(
geom_point(show.legend = c(size = FALSE)) |> blend("multiply"),
geom_hline(yintercept = 70, linewidth = 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")
```