2

I've written a function to make a scatter plot that allows the user to input the size of the points as either a numeric value (which is kept outside the aes() call) or as a variable in the data frame to be mapped (which needs to go inside the aes() call). I'm far from an expert in NSE and, although I've got it working, I feel like there must be a better way to do this?

A simplified version of the function is as follows:

library(tidyverse)

data <- tibble(x = 1:10, y = 1:10)

test_func <- function(data, variable = 6){
  # capture the variable in vars (I think quote would also work in this function)
  variable <- vars({{variable}})
  
  # convert it to a string and check if the string starts with a digit
  # i.e. checking if this is a simple point size declaration not an aes mapping
  is_number <- variable[[1]] %>% 
    rlang::as_label() %>% 
    str_detect("^[:digit:]*$")
  
  # make initial ggplot object
  p <- ggplot(data, aes(x = x, y = y))
  
  # if variable is a simple number, add geom_point with no aes mapping
  if(is_number){
    variable <- variable[[1]] %>% 
      rlang::as_label() %>% 
      as.numeric()
    
    p <- p + geom_point(size = variable)
  } else{
    # otherwise it must be intended as an aes mapping variable  
    variable <- variable[[1]] %>% 
      rlang::as_label()
    
    p <- p + geom_point(aes(size = .data[[variable]]))
  }
  p
}

# works as a number
test_func(data, 10)

# works as a variable
test_func(data, y)

Created on 2021-04-08 by the reprex package (v2.0.0)

Mooks
  • 593
  • 4
  • 12

2 Answers2

2

One option is to check if what the user provided to variable is a column in data. If it is, use that column in aes() mapping. If not, evaluate the variable and feed the result to size outside of aes():

test_func <- function(data, variable = 6) {
  v <- enquo(variable)
  gg <- ggplot(data, aes(x=x, y=y))
  
  if(exists(rlang::quo_text(v), data))
    gg + geom_point(aes(size=!!v))
  else
    gg + geom_point(size = rlang::eval_tidy(v))
}

# All of these work as expected:
test_func(data)      # Small points
test_func(data, 10)  # Bigger points
test_func(data, x)   # Using column x

As a bonus, this solution allows you to pass a value that is stored in a variable, instead of direct numeric input. As long as the variable name is not in data, it will be correctly evaluated and fed to size=:

z <- 20
test_func(data, z)   # Works
Artem Sokolov
  • 13,196
  • 4
  • 43
  • 74
1

You're right. NSE can be a bit of a fiddle. But if you use is.object(), deal with the NSE and then maybe cheat a little by using show.legend in your geom_point call...

test_func <- function(data, variable = 6){
  qVariable = enquo(variable)
  needLegend <- !is.object(qVariable)
  data %>% 
    ggplot(aes(x = x, y = y)) + 
    geom_point(
      aes(size = !! qVariable), 
      show.legend=needLegend
    )
}

Gives the same output as your function in your two test cases.

Limey
  • 10,234
  • 2
  • 12
  • 32
  • Great, thank you a lot. That’s a much better solution than I had. I keep intending to really get into NSE properly so I *really* understand it instead of only learning separate bits and pieces as I need them, it’s finding the time though. – Mooks Apr 08 '21 at 17:55
  • Oh actually, this doesn't quite work after all I'm afraid. It does give a plot when using `test_func(data, 6)` *but* the point size doesn't actually change the point sizes. – Mooks Apr 08 '21 at 20:12
  • Damn. You're right. I only checked your two test cases. Apologies. I have a solution that works for `variable` as a constant, but it breaks when `variable` is a variable. Leave it with me... – Limey Apr 09 '21 at 06:45
  • That's ok, took me a while to notice, too! I mean there is an easy way to do it - just make the user have to write 6 or "y", and then I can use a simple is.numeric control flow. But I now have a bee in my bonnet that I'd like to not force the user to have to use "y" instead of y. I think the solution will probably have to have some sort of if statement and then two geom_point's, one with size inside aes and one with it outside. But it's just getting something that will work in an if statement without all the fiddling in my solution. – Mooks Apr 09 '21 at 09:02