Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Finer control over aesthetic evaluation #3534

Merged
merged 16 commits into from Dec 16, 2019
Merged

Conversation

thomasp85
Copy link
Member

@thomasp85 thomasp85 commented Sep 19, 2019

This PR implements a parallel to stat(), called mod() that allows you to mark aesthetics for evaluation just before they are send to the draw method in the geom. At this point the data has been mapped so expressions inside mod() will work with mapped data.

The prime use case for this is for tying colour and fill values together:

library(ggplot2)
library(colourspace) # for lighten()
p <- ggplot(mpg, aes(class, hwy))
p + geom_boxplot(aes(colour = class, fill = mod(lighten(colour, 0.4))))

image

As can be seen the legend needs some work, but I open now so we can discuss this feature

Copy link
Member

@hadley hadley left a comment

Seems fine to me, except that mod() is too short; it's too likely to conflict with other packages and it's not going to be used that commonly so it doesn't need to be really short.

R/aes-calculated.r Outdated Show resolved Hide resolved
@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 19, 2019

any good suggestions on name? mapped() or modify_mapped() springs to mind. The former is a bit more in line with stat()

@clauswilke
Copy link
Member

@clauswilke clauswilke commented Sep 19, 2019

I like mapped(). I wouldn't use modify_mapped() because the mapping doesn't have to be modified. Sometimes it may be a good idea to just reuse a mapping, for example because it is quite complex. E.g., in the example below, being able to reuse the colour mapping without retyping everything might be nice. As an alternative to mapped(), you could also consider remap().

library(ggplot2)

ggplot(mpg, aes(class, hwy)) +
  geom_boxplot(aes(colour = ifelse(class %in% c("pickup", "suv", "minivan"), "large car", "small car"))) +
  scale_color_discrete(name = NULL)

Created on 2019-09-19 by the reprex package (v0.3.0)

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 20, 2019

changed name to mapped() but if anything better comes along I'm not married to that name. Fixed legend so the updated aesthetics get reflected:

ggplot(mpg, aes(class, hwy)) +
  geom_boxplot(aes(colour = class, fill = mapped(alpha(colour, 0.4))))

image

@thomasp85 thomasp85 marked this pull request as ready for review Sep 20, 2019
@thomasp85 thomasp85 changed the title POC: mod() to delay aesthetic calculation until after mapping mapped() to delay aesthetic calculation until after mapping Sep 20, 2019
@yutannihilation
Copy link
Member

@yutannihilation yutannihilation commented Sep 21, 2019

Cool, this solves the frequent request like #3485.

Minor question, do we need to be tolerant for color here?

ggplot(mpg, aes(class, hwy)) +
  geom_boxplot(aes(color = class, fill = mapped(alpha(color, alpha))), alpha = 0.4)
#> Error in as.character(col) %in% "0": object 'color' not found

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 21, 2019

Yeah — I thought about that as well. I think we should

@clauswilke
Copy link
Member

@clauswilke clauswilke commented Sep 21, 2019

That's an excellent point. Aesthetics names get standardized as described/implemented here:

ggplot2/R/aes.r

Lines 150 to 163 in b560662

#' This function standardises aesthetic names by converting `color` to `colour`
#' (also in substrings, e.g. `point_color` to `point_colour`) and translating old style
#' R names to ggplot names (eg. `pch` to `shape`, `cex` to `size`).
#' @param x Character vector of aesthetics names, such as `c("colour", "size", "shape")`.
#' @return Character vector of standardised names.
#' @keywords internal
#' @export
standardise_aes_names <- function(x) {
# convert US to UK spelling of colour
x <- sub("color", "colour", x, fixed = TRUE)
# convert old-style aesthetics names to ggplot version
revalue(x, ggplot_global$base_to_ggplot)
}

Ideally the same rules should apply inside mapped(). Maybe it's possible to apply standardise_aes_names() to all symbols inside the mapped() expression?

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 23, 2019

I have implemented a way to recurse through aesthetic expressions ago substitute names to standard aesthetic nomenclature. We should consider this carefully though as I don't think many people are aware of the old-style support and may be surprised that their variables are getting renamed..?

@thomasp85 thomasp85 changed the title mapped() to delay aesthetic calculation until after mapping Finer control over aesthetic evaluation Sep 23, 2019
@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 23, 2019

So... this PR has experienced a little feature creep 🙂

I've added a new control function as well called stage() which effectively lets you remap aesthetics between stat, geom, and mapped. This address recurring needs to decouple the output of stats from the mapping of geoms. The stat() and mapped() functions persists as shortcuts when multiple mappings are not needed.

API (continuing with the same example):

ggplot(mpg, aes(x = stage(stat = class, geom = x + 0.5), y = hwy)) +
  geom_boxplot(aes(colour = class, fill = mapped(alpha(color, alpha))), alpha = 0.4)

image

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 23, 2019

@lionel- can I get you to have a look at the meta-programming stuff and make sure I've not done something terribly stupid?

@has2k1
Copy link
Contributor

@has2k1 has2k1 commented Sep 23, 2019

For the name, as this functionality is similar to hjust and vjust of geom_text how about aes_adjust.

R/aes.r Outdated Show resolved Hide resolved
R/aes.r Outdated Show resolved Hide resolved
@@ -36,7 +73,12 @@ is_dotted_var <- function(x) {
is_calculated_aes <- function(aesthetics) {
vapply(aesthetics, is_calculated, logical(1), USE.NAMES = FALSE)
}

is_mapped_aes <- function(aesthetics) {
Copy link
Member

@lionel- lionel- Sep 23, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These semantics feel a bit sloppy to me because they allow users to affect the interpretation of an expression by using mapped() or stage() deep in the call, even though that applies to the whole expression, not subparts. It seems it would be cleaner to require the qualifier to be at top-level.

Then you could check for a mapped() call at top level, and bind mapped to a function that fails with an error message in the function-level of the data mask.

Copy link
Member

@lionel- lionel- Sep 23, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a side note, I find it confusing to have is_ predicate return length != 1 vectors. I think an is_ function should always be safe to use in if () (length 1, no missing value). In rlang I use the are_ prefix for vectorised predicates (but this convention wasn't picked up anywhere else I think).

Copy link
Member Author

@thomasp85 thomasp85 Sep 23, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole is_mapped and is_mapped_aes setup is borrowed completely from the way stat() calls are handled. @hadley do you think there is reason to rewrite them both?

Copy link
Member

@hadley hadley Sep 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should definitely only allow these at the top-level for new code. It's probably too late to change the behaviour of stat(), unless you want to deprecate it first.

Copy link
Member Author

@thomasp85 thomasp85 Sep 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, so I'll provide simplified parsers for mapped() (or whatever it ends up being called) and stage()

Copy link
Member

@lionel- lionel- Oct 10, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also expected:

is_call(~ foo())
#> [1] TRUE

is_call(~ foo(), "foo")
#> [1] FALSE

is_call(~ foo(), "~")
#> [1] TRUE

A quosure is not a call, and a formula is a call to ~. Some functions in rlang will unwrap formulas and quosures automatically, but is_call() is too low level for that.

Copy link
Member Author

@thomasp85 thomasp85 Oct 10, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah... I misunderstood... I thought that FALSE was an error and that it would be fixed with next release...

Copy link
Member Author

@thomasp85 thomasp85 Oct 10, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so... would this be the correct way to test for whether a quosure is of a specific call?

is_call(quo_squash(t), 'test')

or are there any gotchas to that approach?

Copy link
Member

@lionel- lionel- Oct 10, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should almost never use quo_squash(), its name is meant to be scary ;). quo_get_expr() is the way to extract an expression.

  • If you know you have a quosure, use quo_is_call() or is_call(quo_get_expr()).

  • If you'd like to automatically unwrap quosures and formulas (this can be tricky and cause unexpected results), use is_call(get_expr()).

Copy link
Member

@lionel- lionel- Oct 10, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_expr(quote(foo()))
#> foo()

get_expr(~ foo())
#> foo()

get_expr(quo(foo()))
#> foo()

R/aes-calculated.r Outdated Show resolved Hide resolved
R/aes-calculated.r Outdated Show resolved Hide resolved
@clauswilke
Copy link
Member

@clauswilke clauswilke commented Sep 23, 2019

On old-style support: I think pch at least is reasonably common in the wild. See e.g. here for an example I found within 2 minutes of googling. If we want to deprecated old-style names, we should probably do it in two stages, where we first just add a warning that informs people they're using old-style names and that support for this may go away in the future.

On the name of the function stage(): I don't find this name particularly intuitive. What is a "stage" in a geom? I also feel that a verb would be better than a noun. (I assume you mean "stage" as a noun, not a verb, here). I don't yet have the best proposal, but something like map_at() might work. I'll ponder some more.

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 23, 2019

stage is indeed meant as a verb. We are staging multiple mapping of the same aesthetic

@clauswilke
Copy link
Member

@clauswilke clauswilke commented Sep 23, 2019

stage is indeed meant as a verb.

Ah, Ok.

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Sep 23, 2019

As for old-style name. I do t suggest we deprecate them. But we need to be careful about this as there is a difference in supporting them as argument names and substituting their use inside expressions. The later will mask any variable that coincide with an old-style name

@thomasp85 thomasp85 added this to the ggplot2 3.3.0 milestone Sep 27, 2019
@thomasp85 thomasp85 requested a review from hadley Sep 30, 2019
NAMESPACE Outdated Show resolved Hide resolved
R/aes-calculated.r Outdated Show resolved Hide resolved
@@ -36,7 +73,12 @@ is_dotted_var <- function(x) {
is_calculated_aes <- function(aesthetics) {
vapply(aesthetics, is_calculated, logical(1), USE.NAMES = FALSE)
}

is_mapped_aes <- function(aesthetics) {
Copy link
Member

@hadley hadley Sep 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should definitely only allow these at the top-level for new code. It's probably too late to change the behaviour of stat(), unless you want to deprecate it first.

R/geom-.r Show resolved Hide resolved
R/geom-.r Outdated Show resolved Hide resolved
R/aes-calculated.r Outdated Show resolved Hide resolved
R/aes-calculated.r Outdated Show resolved Hide resolved
@thomasp85 thomasp85 requested a review from hadley Oct 11, 2019
@hadley
Copy link
Member

@hadley hadley commented Oct 11, 2019

Can you write a NEWS bullet please? I find them very helpful for giving me the PR context (which I've usually forgotten, and I'd prefer not to have to re-read the entire thread)

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Nov 14, 2019

news bullet added @hadley

Copy link
Member

@hadley hadley left a comment

Overall interface looks good to me. I didn't closely review the implementation.

@thomasp85
Copy link
Member Author

@thomasp85 thomasp85 commented Dec 16, 2019

can you approve if you trust me on the implementation 🙂

hadley
hadley approved these changes Dec 16, 2019
@thomasp85 thomasp85 merged commit fc60051 into tidyverse:master Dec 16, 2019
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants