-->

geom_path() refuses to cross over the 0/360 line i

2020-08-14 15:20发布

问题:

I'm trying to plot the angle of an object (let's say it's a weather vane) over time. I want to plot it on a polar coordinate system and have the time points be connected by a path, showing how the angle evolves over time. I simply have a dataframe, with one column being the angle in degrees (numeric) and then the time step when the angle was recorded (integer).

But when I run the below code:

ggplot(df, aes(x = angle.from.ref, y = time.step)) +
  coord_polar() + 
  geom_path() + 
  geom_point() +
  scale_x_continuous(limits = c(0, 360), breaks = seq(0, 360, 45))

I get something that looks like this:

The path created by geom_path() refuses to cross the 0/360 degree line. If a value of 359 is followed by a value of 1, the path will not create a short link passing across the x=0/360 point. Instead, the path curves back ALL the way around the circle, arriving at x=1 from the other side.

I had hoped using coord_polar() would have solved this, but clearly not. Is there some way I can tell ggplot that the values 0 and 360 are adjacent/contiguous?

回答1:

It may be more straightforward to bypass the crossing-over problem: interpolate at the 360/0 point, and plot each revolution as its own section. Here's how it can work:

library(dplyr)
library(ggplot2)

# sample data
n <- 100
df <- data.frame(
  angle.from.ref = seq(0, 800, length.out = n),
  time.step = seq(Sys.time(), by = "min", length.out = n)
)

df %>%
  interpolate.revolutions() %>%
  ggplot(aes(x = angle.from.ref, y = time.step, 
             group = revolution)) +
  geom_line(aes(color = factor(revolution)), size = 1) + # color added for illustration
  scale_x_continuous(limits = c(0, 360),
                     breaks = seq(0, 360, 45)) +
  coord_polar()

Code for interpolate.revolutions function:

interpolate.revolutions <- function(df, threshold = 360){
  # where df is a data frame with angle in the first column & radius in the second

  res <- df

  # add a label variable such that each span of 360 degrees belongs to
  # a different revolution
  res$revolution <- res[[1]] %/% threshold

  # keep only the angle values within [0, 360 degrees]
  res[[1]] <- res[[1]] %% threshold

  # if there are multiple revolutions (i.e. the path needs to cross the 360/0 threshold), 
  # calculate interpolated values & add them to the data frame
  if(n_distinct(res$revolution) > 1){        
    split.res <- split(res, res$revolution)
    res <- split.res[[1]]
    for(i in seq_along(split.res)[-1]){
      interp.res <- rbind(res[res[[2]] == max(res[[2]]), ],
                          split.res[[i]][split.res[[i]][[2]] == min(split.res[[i]][[2]]), ])
      interp.res[[2]] <- interp.res[[2]][[1]] + 
        (threshold - interp.res[[1]][1]) / 
        (threshold - interp.res[[1]][1] + interp.res[[1]][2]) *
        diff(interp.res[[2]])
      interp.res[[1]] <- c(threshold, 0)          
      res <- rbind(res, interp.res, split.res[[i]])
    }
  }
  return(res)
}

This approach can be applied to multiple lines in a plot as well. Just apply the function separately to each line:

# sample data for two lines, for different angle values taken at different time points
df2 <- data.frame(
  angle.from.ref = c(seq(0, 800, length.out = 0.75 * n),
                     seq(0, 1500, length.out = 0.25 * n)),
  time.step = c(seq(Sys.time(), by = "min", length.out = 0.75 * n),
                seq(Sys.time(), by = "min", length.out = 0.25 * n)),
  line = c(rep(1, 0.75*n), rep(2, 0.25*n))
)


df2 %>%
  tidyr::nest(-line) %>%
  mutate(data = purrr::map(data, interpolate.revolutions)) %>%
  tidyr::unnest() %>%

  ggplot(aes(x = angle.from.ref, y = time.step, 
             group = interaction(line, revolution),
             color = factor(line))) +
  geom_line(size = 1) +
  scale_x_continuous(limits = c(0, 360),
                     breaks = seq(0, 360, 45)) +
  coord_polar()



回答2:

Ok my implementation is a bit hacky, but it might solve your problem. The idea is to simply implement a version of geom_point() that draws lines instead of points.

First, we'll need to build a ggproto object that inherits from GeomPoint and modify the way it draws panels. If you look at GeomPoint$draw_panel, you'll see that our function is virtually the same, but we're using polylineGrob() instead of pointsGrob().

GeomPolarPath <- ggproto(
  "GeomPolarPath", GeomPoint,
  draw_panel = function(data, panel_params, coord, na.rm = FALSE){
    coords <- coord$transform(data, panel_params)
    ggplot2:::ggname(
      "geom_polarpath",
      polylineGrob(coords$x, coords$y,
                   gp = grid::gpar(col = alpha(coords$colour, coords$alpha),
                                   fill = alpha(coords$fill, coords$alpha),
                                   fontsize = coords$size * .pt + coords$stroke * .stroke/2,
                                   lwd = coords$stroke * .stroke/2))
    )
  }
)

Now that we have that, we just need to write the usual function for geoms to accept this in layers. Again, this does the same thing as geom_point(), but passes GeomPolarPath instead of GeomPoints to the layer.

geom_polarpath <- function(mapping = NULL, data = NULL, stat = "identity",
                           position = "identity", ..., na.rm = FALSE, show.legend = NA,
                           inherit.aes = TRUE)
{
  layer(data = data, mapping = mapping, stat = stat, geom = GeomPolarPath,
        position = position, show.legend = show.legend, inherit.aes = inherit.aes,
        params = list(na.rm = na.rm, ...))
}

Finally, we can happily plot away all we want (blatantly stealing dww's example data):

ggplot(df, aes(x = angle, y = time.step)) +
  coord_polar() + 
  geom_polarpath() +
  geom_point() +
  scale_x_continuous(limits = c(0, 360), breaks = seq(0, 360, 45))

And here we go. I've only tested this for this particular plot, so I would expect some bugs and wierdness along the way. Potential downside is that it draws straight lines between points, so it doesn't curve along the angles. Good luck!

EDIT: You might need to load the grid package for this to work.



回答3:

It may be simpler to calculate your own polar coordinates, and plot on a cartesian grid.

Some dummy data (where all angles are less than 360, but with data points crossing the 360/0 boundary, as described in the comment)

df = data.frame(angle.from.ref = rep(seq(0,350,10), 4))
df$time.step = seq_along(df$angle.from.ref)

Now we use basic trig to calculate the position on a cartesian plane:

df$x = sin(pi * df$angle.from.ref/180) *  df$time.step 
df$y = cos(pi * df$angle.from.ref/180) *  df$time.step 

and plot using geom_path

ggplot(df, aes(x, y)) +
  geom_path() + 
  geom_point() +
  coord_equal()

To replace the cartesian grid with a polar one, we can also calculate the coordinates for the gridlines (I put into a function for convenience)

ggpolar = function(theta, r) {
  # convert polar coordinates to cartesian
  x = sin(pi * theta/180) * r 
  y = cos(pi * theta/180) * r

  # generate polar gridlines in cartesian (x,y) coordinates
  max.r = ceiling(max(r) / 10) * 10
  grid.a = data.frame(a = rep(seq(0, 2*pi, length.out = 9)[-1], each=2))
  grid.a$x = c(0, max.r) * sin(grid.a$a)
  grid.a$y = c(0, max.r) * cos(grid.a$a)
  circle = seq(0, 2*pi, length.out = 361)
  grid.r = data.frame(r = rep(seq(0, max.r, length.out = 4)[-1], each=361))
  grid.r$x = sin(circle) *  grid.r$r
  grid.r$y = cos(circle) *  grid.r$r

  labels = data.frame(
    theta = seq(0, 2*pi, length.out = 9)[-1],
    lab = c(seq(0,360,length.out = 9)[-c(1,9)], "0/360"))
  labels$x = sin(labels$theta) *max.r*1.1
  labels$y = cos(labels$theta) *max.r*1.1

  #plot  
  ggplot(data.frame(x,y), aes(x, y)) +
    geom_line(aes(group=factor(a)), data = grid.a, color='grey') +
    geom_path(aes(group=factor(r)), data = grid.r, color='grey') +
    geom_path() + 
    geom_point() +
    coord_equal() +
    geom_text(aes(x,y,label=lab), data=labels) +
    theme_void()
}

ggpolar(df$angle.from.ref, df$time.step)

Also, demonstrating the same with data similar to your example that oscillates across the 360/0 line:

set.seed(1234)
df = data.frame(angle = (360 + cumsum(sample(-25:25,20,T))) %% 360)
df$time.step = seq_along(df$angle)
ggpolar(df$angle, df$time.step)

Edit: A slightly more complex version that draws curved lines

One issue with the above solution is that the line segments are straight, rather than curved along the angles. Here's a slightly improved version that draws either spline or polar curves between the points using method='spline' or method='approx', respectively.

plus360 = function(a) {
  # adds 360 degrees every time angle crosses 360 degrees in positive direction.
  # and subtracts 360 for crossings in negative direction
  a = a %% 360
  n = length(a)
  up = a[-n] > 270 & a[-1] < 90
  down   = a[-1] > 270 & a[-n] < 90
  a[-1] = a[-1] + 360* (cumsum(up) - cumsum(down))
  a
}

ggpolar = function(theta, r, method='linear') {
  # convert polar coordinates to cartesian
  x = sin(pi * theta/180) * r 
  y = cos(pi * theta/180) * r
  p = data.frame(x,y)
  if (method=='spline') {
    sp = as.data.frame(spline(r,plus360(theta),10*length(r)))
    } else {
    if (method=='approx') {
      sp = as.data.frame(approx(r,plus360(theta),n=10*length(r)))
      } else {
      sp = data.frame(x=r, y=theta)  
      }
    }
    l = data.frame(
      x = sin(pi * sp$y/180) * sp$x,
      y = cos(pi * sp$y/180) * sp$x)
    
    # generate polar gridlines in cartesian (x,y) coordinates
    max.r = ceiling(max(r) / 10) * 10
    grid.a = data.frame(a = rep(seq(0, 2*pi, length.out = 9)[-1], each=2))
    grid.a$x = c(0, max.r) * sin(grid.a$a)
    grid.a$y = c(0, max.r) * cos(grid.a$a)
    circle = seq(0, 2*pi, length.out = 361)
    grid.r = data.frame(r = rep(seq(0, max.r, length.out = 4)[-1], each=361))
    grid.r$x = sin(circle) *  grid.r$r
    grid.r$y = cos(circle) *  grid.r$r
    
    labels = data.frame(
      theta = seq(0, 2*pi, length.out = 9)[-1],
      lab = c(seq(0,360,length.out = 9)[-c(1,9)], "0/360"))
    labels$x = sin(labels$theta) *max.r*1.1
    labels$y = cos(labels$theta) *max.r*1.1
    
    #plot  
    ggplot(mapping =  aes(x, y)) +
      geom_line(aes(group=factor(a)), data = grid.a, color='grey') +
      geom_path(aes(group=factor(r)), data = grid.r, color='grey') +
      geom_path(data = l) + 
      geom_point(data = p) +
      coord_equal() +
      geom_text(aes(x,y,label=lab), data=labels) +
      theme_void()
  }

using splines it looks like this

ggpolar(df$angle, df$time.step, method = 'spline')

and with polar curves which interpolate the angle

ggpolar(df$angle, df$time.step, method = 'approx')



回答4:

I'm sorry for coming back to this a year later, but I think I found a simpler solution than all of the above, including my own. So if anybody revisits this question because they have a similar problem, they'll find this answer.

Since scales v1.1.1, there is the oob_keep() function that you can pass as oob argument to a scale. What this does, is basically the same as setting the limits in coord_cartesian(), but it works for polar coordinates as well.

While this doesn't automatically pre-wrangle your data, as long as the input data is correctly centered around a phase start, it should work.

For example:

library(ggplot2)

set.seed(1234)
df <- data.frame(angle = (360 + cumsum(sample(-25:25,20,T))))

ggplot(df, aes(angle, seq_along(angle))) +
  geom_point() +
  geom_path() +
  scale_x_continuous(limits = c(0, 360), 
                     oob = scales::oob_keep,
                     breaks = seq(0, 360, length.out = 9)) +
  coord_polar()

And for the spiral example:

library(ggplot2)

spiral <- data.frame(x = seq(0, 360*3, length.out = 500))

ggplot(spiral, aes(x, x)) +
  geom_point() +
  geom_path() +
  scale_x_continuous(limits = c(0, 360),
                     oob = scales::oob_keep) +
  coord_polar()