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

TLC for polar coordinates: coord_radial() #5334

Merged
merged 31 commits into from
Nov 20, 2023

Conversation

teunbrand
Copy link
Collaborator

@teunbrand teunbrand commented Jun 25, 2023

This PR fixes #4815, fixes #5059, fixes #3959, and fixes #4462

Briefly, this PR introduces a new coord system coord_polar2() with expanded options (name of function and arguments are up for discussion).

Less briefly, this PR started out with a simpler goal of having coord_polar() use the axis system. Realising that this couldn't be done without breaking backward compatibility, this has instead become coord_polar2(). This PR then started accumulating additional features and here we are today. I think it might be best to explain this PR with a visual walkthrough.

If we use coord_polar2() plainly, we see it resembles coord_polar() with the following differences:

  • Theta is represented by a proper position axis. Figuring out text placement here was the hardest part, and still isn't perfect. The default is to display text of the theta axis horizontally.
  • The panel.background theme setting is applied to the circle that forms the background. You can still use panel.border = element_rect(fill = NA) in the theme if you need a frame for the plot.
  • Radius axis line starts and stops at the actual radius limits and doesn't cover the entire left side of the rectangular panel.
  • The coords has a expand = TRUE default argument so that the behaviour of scale expansion is more in line with coord_cartesian() than with coord_polar().
devtools::load_all("~/packages/ggplot2/")
#> ℹ Loading ggplot2

p <- ggplot(mtcars, aes(disp, mpg)) +
  geom_point() +
  theme(axis.line = element_line())

p + coord_polar2()

One feature is that we can also place the radius axis inside the circle. Admittedly, this looks a little bit awkward because it intersects with the theta axis, which is why this isn't the default when you have a full circle.

p + coord_polar2(r_axis_inside = TRUE)

The next feature is that requested in #4462, namely to have partial polar coordinates. The following things are of note:

  • The start and end arguments to control which sector of a circle the plot occupies.
  • Since we don't have a full circle, the radius axis is placed inside and not at the rectangular bounding box. You can use coord_polar2(r_axis_inside = FALSE) to prevent this.
  • The radius axis snaps to the start position. While the axis itself is rotated to reflect this, the labels remain horizontal.
p + coord_polar2(start = -0.25 * pi, end = 0.25 * pi)

  • If you swap direction, the radius axis follows the swap.
p + coord_polar2(start = -0.25 * pi, end = 0.25 * pi, direction = -1)

Then the reason I started this PR: #3959. A few comments:

  • There is a new guide_axis_theta() that can be used for the theta axis.
  • Radius axes cannot be the new theta guide, which is what prompts the warning below. The inverse is also true, i.e. guides(theta = "axis") also throws a warning.
  • If the angle argument is set, either in a radius axis or theta axis, this is interpreted as a relative angle. Note that the theta text follows the curvature and that the radius text are projected from the tick marks.
p + coord_polar2(start = -0.25 * pi, end = 0.25 * pi) +
  guides(
    r     = guide_axis_theta(),
    r.sec = guide_axis(angle = 0),
    theta = guide_axis_theta(angle = 0)
  )
#> Warning: `guide_axis_theta()` cannot be used for r.
#> ℹ Use one of x, y, or theta instead.

I then took a little bit of a liberty and decided on my own it would be fun to also be able to set a donut hole in polar coordinates. This is mostly just a convenience that doesn't force you to fiddle with the scale limits or expansions to not have all shapes disappear in a single point at the center. Also, it is the only circumstance I could ever imagine that you'd need a secondary theta axis.

p + coord_polar2(donut = 0.5) +
  guides(theta.sec = "axis_theta")

Lastly, I also implemented #5059, since getting the (relative) angle right in text geoms in polar coordinates is a bit of pain. Text between 90 and 270 degrees is flipped for readability reasons.

df <- data.frame(
  x = LETTERS[1:5], lab = c("cat", "farm", "banana", "airplane", "baker")
)

ggplot(df, aes(x, label = lab)) +
  geom_text(aes(y = "0 degrees"),  angle = 0)  +
  geom_text(aes(y = "90 degrees"), angle = 90) +
  coord_polar2(rotate_angle = TRUE)

Created on 2023-06-25 with reprex v2.0.2

A few closing remarks:

  • guide_axis_theta() also works reasonably well with cartesian coordinates.
  • guide_axis_theta() does not respond to hjust/vjust settings, mostly because text placement had given me enough headaches at this point and the theme's defaults translate horribly to the theta axis.
  • The theta positions uses x.bottom theme settings, theta.sec uses x.top, r/r.sec use y.left/y.right depending on the direction argument. In theory, we could change this, but it would involve some hairy adaptations to guide_axis() as well.
  • Clipping currently isn't ideal, as it applies to the rectangular bounding box. We could use R4.1+ features to clip to the curved bounding boxes, but we'd need to implement Feature request: graphics device capabilities checker #5332 for that.

GuideAxisTheta <- ggproto(
"GuideAxisTheta", GuideAxis,

# TODO: delete if minor ticks PR (#5287) gets merged
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This may be simplified once #5287 is merged.

@@ -41,7 +44,7 @@
#'
#' # can also be used to add a duplicate guide
#' p + guides(x = guide_axis(n.dodge = 2), y.sec = guide_axis())
guide_axis <- function(title = waiver(), check.overlap = FALSE, angle = NULL,
guide_axis <- function(title = waiver(), check.overlap = FALSE, angle = waiver(),
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The default is now waiver() because coord_polar2() may overrule angle for display purposes, but NULL still uses the theme's angle.

return(element_text(angle = NULL, hjust = NULL, vjust = NULL))
}

# it is not worth the effort to align upside-down labels properly
check_number_decimal(angle, min = -90, max = 90)
Copy link
Collaborator Author

@teunbrand teunbrand Jun 25, 2023

Choose a reason for hiding this comment

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

I very much did need some alignments for upside-down labels, so I have expanded this function to include this. Due to this, some angles in the svg snapshots have changed from e.g. 45 degrees to -315 degrees but that doesn't matter visually.

position <- params[[1]]$position %||% scale$position
if (position != scale$position) {
order <- rev(order)
if (!is.null(params)) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Layout doesn't know about theta/r guides, so had to avoid taking labels from NULL params here that are returned when the x/y guide doesn't exist.

@teunbrand
Copy link
Collaborator Author

Also worth pointing out that interactions with e.g. {ggtext} aren't that horrible:

devtools::load_all("~/packages/ggplot2/")
#> ℹ Loading ggplot2
library(ggtext)
labels <- c(
  setosa = "<img src='https://upload.wikimedia.org/wikipedia/commons/thumb/8/86/Iris_setosa.JPG/180px-Iris_setosa.JPG'
    width='100' /><br>*I. setosa*",
  virginica = "<img src='https://upload.wikimedia.org/wikipedia/commons/thumb/3/38/Iris_virginica_-_NRCS.jpg/320px-Iris_virginica_-_NRCS.jpg'
    width='100' /><br>*I. virginica*",
  versicolor = "<img src='https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/20140427Iris_versicolor1.jpg/320px-20140427Iris_versicolor1.jpg'
    width='100' /><br>*I. versicolor*"
)

ggplot(iris, aes(Species, Sepal.Width)) +
  geom_boxplot() +
  scale_x_discrete(
    name = NULL,
    labels = labels
  ) +
  coord_polar2(r_axis_inside = TRUE) +
  theme(
    axis.text.x = element_markdown(color = "black", size = 11),
    axis.title.y = element_blank(),
    plot.margin = margin(5,5,70,5)
  )

Created on 2023-06-25 with reprex v2.0.2

Note that you couldn't do this with regular coord_polar().

@teunbrand teunbrand mentioned this pull request Jun 25, 2023
@teunbrand teunbrand added coord 🗺️ feature a feature request or enhancement labels Jul 9, 2023
R/coord-polar2.R Outdated Show resolved Hide resolved
R/guide-axis-theta.R Outdated Show resolved Hide resolved
R/guide-axis-theta.R Outdated Show resolved Hide resolved
R/guide-axis-theta.R Outdated Show resolved Hide resolved
@thomasp85
Copy link
Member

Name suggestion: coord_radial()

@teunbrand
Copy link
Collaborator Author

Latest changes:

  • Renamed coord_polar2() to coord_radial().
  • Use minor ticks plumbing from Minor ticks #5287, so custom extract_key method and params became redundant.
  • coord_polar() and coord_radial() now share the rescale helper functions

Should be ready for review :)

@teunbrand teunbrand changed the title TLC for polar coordinates TLC for polar coordinates: coord_radial() Oct 25, 2023
@teunbrand
Copy link
Collaborator Author

I couldn't really use Map() with old R (3.6.3) units to render labels, so instead I just vectorised rotate_just(). This allows element_text() to have multiple angles, thereby eliminating the need for Map()ing the labels.

@teunbrand teunbrand added this to the ggplot2 3.5.0 milestone Nov 7, 2023
Copy link
Member

@thomasp85 thomasp85 left a comment

Choose a reason for hiding this comment

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

I approve this, but please fix the small style stuff I commented on

R/guide-axis-theta.R Outdated Show resolved Hide resolved
Comment on lines 87 to 88
# We likely have a linear coord, so we match the text angles to
# standard axes.
Copy link
Member

Choose a reason for hiding this comment

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

What happens if this axis is used with a non-radial coord?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It will draw a very normal looking axis with slightly inferior options for text justification.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

devtools::load_all("~/packages/ggplot2")
#> ℹ Loading ggplot2

ggplot(mpg, aes(displ, hwy)) +
  geom_point() +
  guides(x = "axis_theta", y = "axis_theta") +
  theme(axis.line = element_line())

Created on 2023-11-20 with reprex v2.0.2

R/guide-axis-theta.R Outdated Show resolved Hide resolved
R/guide-axis-theta.R Outdated Show resolved Hide resolved
@teunbrand teunbrand merged commit 5e29f33 into tidyverse:main Nov 20, 2023
12 checks passed
@teunbrand
Copy link
Collaborator Author

Thanks for the review Thomas!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
coord 🗺️ feature a feature request or enhancement
Projects
None yet
2 participants