Minimal PR for unit support in positional aesthetics
Following discussion on #5609 and helpful feedback from @teunbrand, @pmur002, and @thomasp85 (thanks!), this is a second proof-of-concept PR for unit support in positional aesthetics in ggplot2. This PR focuses on being a minimal implementation without hacking on the unit type system.
The basic idea here is to instead add another aesthetic evaluation stage, after_coord, which is akin to after_stat or after_scale. It is implemented by passing aesthetic mappings with stage() or after_coord() through to Coord$transform() via the panel_params argument, which is already passed through to that function. Coord$transform() can then apply the after_coord mappings after it does its own Coord-specific transformations.
Some demos:
data.frame(var1 = 1:5, var2 = 1:5, name = letters[1:5]) |>
ggplot(aes(var1, var2)) +
geom_point() +
geom_line() +
# labels exactly 8 points left and 6 points above their points, no matter how
# the plot is resized
geom_text(aes(
label = name,
x = stage(var1, after_coord = unit(x, "native") - unit(8, "pt")),
y = stage(var2, after_coord = unit(y, "native") + unit(6, "pt"))
)) +
# an annotation that is always 10 points inset from the lower right
# N.B. can't use annotate() because it doesn't understand stages, but using a
# geom with data = data.frame() instead seems to work. If desired, I believe
# annotate() could be made to understand stages by having it check for the
# stage / after_stat / after_scale /after_coord functions in the bare
# expressions supplied to it.
geom_text(
aes(
x = after_coord(unit(1, "npc") - unit(10, "pt")),
y = after_coord(unit(10, "pt"))
),
label = "some label", vjust = 0, hjust = 1,
data = data.frame()
) +
# a horizontal line exactly 25 points from the bottom.
# can't use after_coord() with the yintercept aesthetic because geom_hline()
# translates that to `y` and `yend` under the hood before the after_coord()
# stage, so must modify those values directly
geom_hline(aes(
yintercept = Inf, # placeholder value to avoid "missing required aes" error
y = after_coord(unit(25, "pt")),
yend = after_coord(unit(25, "pt")),
))
With polar coordinates:
data.frame(var1 = 1:5, var2 = 1:5, name = letters[1:5]) |>
ggplot(aes(var1, var2)) +
geom_point() +
geom_line() +
geom_text(aes(
label = name,
x = stage(var1, after_coord = unit(x, "native") + unit(10, "pt"))
)) +
geom_text(
aes(
x = after_coord(unit(1, "npc") - unit(10, "pt")),
y = after_coord(unit(10, "pt"))
),
label = "some label", vjust = 0, hjust = 1,
data = data.frame()
) +
coord_polar()
Some notes:
-
The main workhorse here is
compute_staged_aes(), which is based on a chunk of code I factored out ofGeom$use_defaults()for computingafter_scaleaesthetics. -
after_coord()is a bit different fromafter_scale()in that it provides the placeholder valueInfto earlier parts of the pipeline, because it needed a non-unit()value that would not affect scales (see the comment on that function). There should probably be a better way to do this. -
the implementation moves current
Coord$transform()implementations intoCoord$transform_numeric(). This was done so thatGeoms (which already callCoord$transform()) would not have to opt in to supportingunit()s; instead,Coords in extension packages can opt-in simply by renaming theirtransform()methods totransform_numeric(). An alternative might be to leaveCoord$transform()as-is but add aCoord$transform_unit()method, and then haveGeoms opt-in to unit support instead of havingCoords opt-in to it. I think havingCoords opt-in is better since it requires fewer changes in extension packages and propagates support more easily, but I could be wrong. -
No hacking on
unit()is required for this implementation, nor any upstream changes to {grid}. A small set of compatibility functions would need to be added to {vctrs}, akin to how {vctrs} supplies compatibility functions for other base-R types, like Dates. These are confined toR/utilities-unit.R, which could be turned into a PR on {vctrs}. -
In exchange for less "magic", this approach is a little more verbose than my original proposal and requires a little more understanding of the ggplot2 pipeline for users. Something like this in the current proposal:
x = stage(var1, after_coord = unit(x, "native") - unit(8, "pt"))could be written as this in the original proposal:
x = var1 - ggunit(8, "pt")However, this new proposal has more consistent semantics and no heuristic hacking of the
unit()datatype :). Aggunit()subtype ofunit()with consistent coercion rules for numerics could still be created to improve the syntax a bit (also without heuristic hacking ofunit()); allowing something like this to work:x = stage(var1, after_coord = x - ggunit(8, "pt"))Adding shortcuts like
as_pt()could shorten this still, to:x = stage(var1, after_coord = x - as_pt(8))The
ggunit()subtype could be added to ggplot2, or if that is not desired, I'd be happy to create a small extension package with that capability.
@teunbrand I wanted to circle back around here at some point and see if there's still potential for this.
I think a principled inclusion of unit support would be very cool and help improve a lot of stuff at the annotation level. This PR isn't all the way there, but is much less hacky than the previous version, and I'd be happy to iterate on it if there's interest.
I'm happy to help think about the code and I still like the concept a lot. It is great that this PR is much less invasive than the previous one.
However I feel like the sheer scope of this PR is outside my jurisdiction and the decision on whether to forge ahead with this is in @thomasp85's hands. If he doesn't reply here after invoking his name, I'll be sure to bring it up the next time Thomas and I have a chat (in ~2 weeks time).
I fully embrace the need for what this PR wants to solve. However, I'm very hesitant to retrofit this into ggplot2 in the way that this PR proposes.
I do think that perhaps we can have the best of both worlds with some changes to position_nudge() and Coord so we will start by exploring this route. If this fails I think we would try to surgically solve it for text, label, and point geoms first and foremost
Cool, thanks for entertaining the suggestion! Let me know if you want help for the next iteration.
Two other less invasive approaches also just occurred to me:
Move coord_mapping into a layer parameter or wrapper function
One less invasive (but still general) solution is to add a parameter to layer() to take the coord aesthetic mapping or add a function that wraps a Layer to provide the coord mapping in draw_geom(). Either of these solutions would avoid what I think is the remaining "hackiness" of my current PR, which is the need to marshall around coord-level mappings via stage() and provide placeholder aesthetic values for the other stages. Instead, the coord_mapping in Layer$draw_geom() would come from a parameter passed to layer().
Such an approach could replace this:
geom_text(aes(
label = name,
x = stage(var1, after_coord = unit(x, "native") + unit(10, "pt"))
))
With something like this:
geom_text(
aes(label = name, x = var1),
coord_mapping = aes(x = unit(x, "native") + unit(10, "pt"))
)
or maybe this:
geom_text(aes(label = name, x = var1)) |> after_coord(aes(x = unit(x, "native") + unit(10, "pt")))
This could be a more general solution than position_nudge(), which would allow it to be composed more easily with other position adjustments and support arbitrary positioning beyond just offsets.
Add unit support to Coords and move the rest to an extension package
Another alternative might be for you to just add the functionality to Coords needed to support units directly, like the modification to Coord$transform() I proposed (the transform() / transform_numeric() split). If this were done, full support for units using a layer wrapper function could be done in an extension package.