ggplot2
ggplot2 copied to clipboard
aes(color) causes arrow() to become overly enthusiastic
In this repex only one arrow should be drawn at (x = 1, y = 1). However, it appears something in the drawing stack gets confused by the color aesthetic and starts putting an arrow on every segment.
data = tibble(x = c(0, 0.5, 1), y = x, color = x)
ggplot(data) +
geom_path(aes(x = x, y = y, color = color), arrow = arrow(ends = "last"))
This happens with geom_line()
as well. In real world use the results I get are poor―even in the small hello world I was doing when I first encountered this issue 1800 extra arrowheads get drawn, which turns out to be both impressively slow to render (AGG backend with alpha) and almost unreadably cluttered.
@hadley closed #2280 because this type of intermediate arrow drawing was too difficult to implement but, apparently, it's become more possible than anticipated. This makes me wonder if perhaps arrow(ends = { "last", "first", "both })
should start additionally differentiating something like { "segment_last", "segment_first", "segment_both" }
in order to give callers reasonable control over what's drawn. @yutannihilation suggested a commit in this direction in in #4133.
A couple workarounds for this issue are given in #4133 and it looks like at one point there might have been an attempt to address the issue from within the ggplot codebase.
- @yutannihilation's approach of suppressing unwanted arrows with an equivalent of
arrow(length = unit(if_else(<clause>, 0.25, 0), "line"))
is elegant. But, unfortunately, nothing I've tried lead to correctly placed arrows in my actual use case. So I can't figure out how to turn it into a viable workaround. - I can get acceptable results at the hello world level using an equivalent of @JTC-R's doubled
geom_path()
. However, overdrawing composes poorly with alpha aesthetics and it'd be nice to have some way of getting variable arrowhead color and fill while maintaining only one arrow per group. - There also appears to be at least one documentation error since
arrow.fill
is rejected as both an unknown aesthetic and as an unknown parameter.
Additionally, I noticed NA
doesn't work like it usually would to shut off arrow drawing. Instead, an arrow fragment is drawn extending far off the plotting area.
data = tibble(x = c(0, 0.5, 1), y = x, color = x)
ggplot(data) +
geom_path(aes(x = x, y = y, color = color), arrow = arrow(length = unit(c(NA_real_, 5), "mm"), ends = "last"))
The fundamental problem here is that grid graphics cannot draw lines with continuously varying colors, so geom_path()
simply breaks the line into individual segments and draws those.
You can simulate this by manually setting the group aesthetic:
library(tidyverse)
data <- tibble(x = c(0, 0.5, 1))
ggplot(data, aes(x = x, y = x, color = x)) +
geom_path(arrow = arrow(ends = "last"))
data2 <- tibble(x = c(0, 0.5, 0.5, 1), group = c(1, 1, 2, 2))
ggplot(data2, aes(x = x, y = x, group = group)) +
geom_path(arrow = arrow(ends = "last"))
Created on 2022-08-09 by the reprex package (v2.0.1)
If you need paths with continuously varying colors, your best bet is probably to break up the paths explicitly and set the colors directly. Then, you can add arrows to those specific path segments where you need them. I don't think it makes sense to add an awkward API to ggplot2 for something that is fundamentally a workaround for a grid limitation, and that could at some point go away.
I think perhaps the reprex is too simple to address the issue of placing a single arrow at the end of a grouped path, so this is an attempt to make the issue slightly more visual. Let's suppose we want to draw five arrows in a spiral, and colour varies for each segment that lead up to the arrow.
What I think might be wrong here is that the arrows are drawn even when the individual segment pieces belong to the same group.
library(tibble)
library(ggplot2)
data <- tibble(
t = seq(5, -1, length.out = 100) * pi,
x = sin(t) * 1:100,
y = cos(t) * 1:100,
group = 0:99 %/% 20
)
p <- ggplot(data, aes(x = x, y = y, colour = t, group = group))
p + geom_path(arrow = arrow(ends = "last"))
When you try the approach of setting the arrow length for every last-in-group segment, you get misplaced arrows. This makes a this approach a good solution for a different problem.
# lig = last in group
lig_unit <- function(group, value = 0.25, unit = "inches") {
rle <- rle(group)
ends <- cumsum(rle$lengths)
unit((seq_along(group) %in% ends) * value, unit)
}
p + geom_path(arrow = arrow(ends = "last", length = lig_unit(data$group)))
Instead, in the GeomPath$draw_panel()
method, the if (!constant)
branch treats data as if every group has 1 less member. Now we could adapt our function to account for this, but I'd have to admit it is a bit unintuitive.
lig_unit2 <- function(group, value = 0.25, unit = "inches") {
rle <- rle(group)
ends <- cumsum(rle$lengths - 1)
unit((seq_along(group) %in% ends) * value, unit)
}
p + geom_path(arrow = arrow(ends = "last", length = lig_unit2(data$group)))
Created on 2022-08-09 by the reprex package (v2.0.1)