ggplot2 icon indicating copy to clipboard operation
ggplot2 copied to clipboard

aes(color) causes arrow() to become overly enthusiastic

Open twest820 opened this issue 2 years ago • 2 comments

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"))

twest820 avatar Aug 09 '22 18:08 twest820

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.

clauswilke avatar Aug 09 '22 21:08 clauswilke

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)

teunbrand avatar Aug 09 '22 21:08 teunbrand