tidyverse / ggplot2

An implementation of the Grammar of Graphics in R
https://ggplot2.tidyverse.org
Other
6.46k stars 2.02k forks source link

Suggestion to change the default line end of axis lines in theme_classic #5978

Closed psoldath closed 1 week ago

psoldath commented 2 months ago

Inspired by the newly released option to cap the axis lines (a great addition btw), I would like to suggest a cosmetic improvement to theme_classic as I believe the current default of the line end of the axis lines is not the best fit.

Theme_classic is the only original complete theme that uses axis lines. The line end of the axis lines in this theme is actually set to NULL but practically it works like the "butt" line end. The "butt" line end cuts off the very end of the lines. This is an appropriate setting for things like axis ticks etc in all other themes (as axis ticks with the "butt" line-end will align perfectly with either the panel border or panel background of all the other themes), but I don't think it is the best choice for theme_classic. In theme_classic, when the axis lines are not capped, they join together at the origin with a very ugly notch in the left lower corner as the lines are simply too short to come together correctly (unlike for instance the lines of the panel border in the themes that use that). The notch is pretty noticeable and becomes very apparent when zooming in. It gets even worse when the axis lines are capped as the notch will then be present at the upper-outer corner of both the first and last breaks of both axes.

A solution would be to change the default of the line end for the axis lines to the "square" or "round" lineend. Hereby, the two axes would join nicely at the origin when not capped and when they are capped, they would line up perfectly with the axis ticks of the first and last breaks eliminating the ugly notches. It would probably be most appropriate to change the default line end to the "square" line end as this would keep the straight lines, thereby being consistent with the form of the axis ticks and the panel border of some of the other themes etc. If there is any way to make it a default of all axis lines in ggplot2, it would probably be beneficiary to do that, so customized themes built on, say, theme_gray with added axis lines will also use the "square" line end. I can't see any downsides to make this change, but I am not an expert in either R or ggplot2, so I don't know if there is a good explanation to the current settings. I just think it would improve the aesthetics of plots made with theme_classic.

teunbrand commented 2 months ago

Just as an illustration. Ugly corner between x/y axis lines:

library(ggplot2)
p <- ggplot(mpg, aes(displ, hwy)) +
  geom_point() +
  theme_classic(base_line_size = 5) +
  theme(axis.ticks.length = unit(5, "mm"))

p

Ugly corners between axis line and ticks:

p + guides(x = guide_axis(cap = "both"))

Created on 2024-07-05 with reprex v2.1.0

It also doesn't quite make sense to me why there are two different shades of black here.

psoldath commented 2 months ago

Thank you for the illustrations @teunbrand. I absolutely agree. I too never got the point of the grey axis ticks in theme_classic. It would in my opinion be a lot more aesthetically pleasing with all black axis lines and tick marks.

clauswilke commented 2 months ago

The two shades of black are definitely a bug in my opinion. theme_classic() inherits from theme_gray() (via theme_bw()) which has dark gray axis ticks and no axis lines and then it sets black axis lines but doesn't adjust the colors of the axis ticks. Axis ticks should be black.

I believe I have commented on this before but don't remember in what context.

teunbrand commented 2 months ago

I'm also in favour of unifying the shade of black.

With regards to theme hierarchy, almost all themes are built on top of theme_gray() which has axis.line = element_blank(). We could set axis.line = element_line(lineend = "round"/"square", colour = "transparent") to keep hiding the axis line but have the lineend propagate to subsequent themes.

EDIT: nevermind the suggestion would break other stuff. I can't at the moment see a clear mechanism for this.

Ax3man commented 2 months ago

If it is not fixable in general, can it at least be fixed for theme_classic() specifically by including lineend = 'square' here?

https://github.com/tidyverse/ggplot2/blob/b8da7afc5823b26c9367c3e8982ddfd7b7c1be10/R/theme-defaults.R#L444

I use theme_classic() quite a lot, and it is a slight annoyance!

teunbrand commented 2 months ago

Yes fixing theme_classic()'s axis lineend and tick colour is a good idea. It would just be nice to have a solution to propagate the lineend in some way, but I don't see a clear way to do this yet.

psoldath commented 2 months ago

We all agree that the axis ticks should be changed to black to match the axis lines, but I would also suggest that the axis text should be changed to black as well since all the other text elements in theme_classic() (title, subtitle, axis titles, caption) are black. The axis text is the only text element that is specified with a color (grey30), which it inherits from theme_grey(), just like the axis ticks. It may make sense in that theme that the axis text is grey given the grey background panel, but for theme_classic(), which is otherwise completely black and white (at least after we change the color of the axis ticks!), I think it would definitely look better with black axis text instead of grey. Keeping only the axis text in grey doesn't make sense to me, but changing both the axis ticks and the axis text to black would truly mimic the classic base R plot, which arguably always must have been the real point of a theme named theme_classic().

I totally agree that we should strive towards finding a solution, where the axis lines will look right in all complete themes and theme customizations. However, it may not be as easy as we first thought; I have come across yet another problem. In the example given for capping of the axes on the tidyverse [blog](https://www.tidyverse.org/blog/2024/02/ggplot2-3-5-0-axes/#:~:text=To%20draw%20minor%20ticks%2C%20you,ticks%20argument%20of%20guide_axis()%20.&text=The%20minor%20ticks%20are%20unlabelled,is%20determined%20by%20the%20axis), the y-axis is only capped in the upper end in a plot that uses theme_grey(). We see that the capped end has an ugly corner, but we also see that the uncapped end aligns perfectly with the grey panel. So if we change the line end to "square", we get a nice looking corner of the upper line end and tick mark but an ugly lower one that extends further down than the grey panel! Right now, the line end argument only takes the three possible values of "butt", "round", or "square". I think the perfect solution would be something like the arrow argument, which takes the arrow() function, which allows for specification of which end to turn into an arrow? In this way it may be possible to write a conditional statement on which line end to be "butt" or "square"/"round"? I know this may be quite a laborious job, but I can't see any other solution that would assure that the axis lines will always match the other conditions.

The upper line end of the y-axis is not aligned with the tick mark of the last break, but the lower line end is perfectly aligned with the bottom of the panel background

library(ggplot2)
p <- ggplot(mpg, aes(displ, hwy)) +
  geom_point() +
  guides(
    x = guide_axis(cap = "both"),
    y = guide_axis(cap = "upper")
  ) +
  theme(axis.line = element_line(linewidth = 5),
        axis.ticks = element_line(linewidth = 5),
        axis.ticks.length = unit(5, "mm"))

p

p

The upper line end of the y-axis is now perfectly aligned with the tick mark of the last break, but in return the lower line end now extends beyond the bottom of the panel background

p +
  theme(axis.line = element_line(lineend = "square"))

p

teunbrand commented 2 months ago

write a conditional statement on which line end to be "butt" or "square"/"round"

The {grid} system on which ggplot2 is build doesn't accommodate different lineend settings for the two ends of a path and neither does the .svg specification, I think. This is just to indicate that there is no 'native' way of accommodating this request.

With some fiddling it might be possible to offset the first/last point of a path by half a linewidth, however, there is no device consensus on how linewidth is interpreted.

For example the documentation on lwd in ?par reads:

The line width, a positive number, defaulting to 1. The interpretation is device-specific, and some devices do not implement line widths less than one.

So there is no robust way of getting the size of half a linewidth correct all the time. Besides that, it would also make the position of the lineend graphically correct, but numerically wrong in that we couldn't do a smooth linejoin in a vector graphics editing suite with an orthogonal line, such as a tick mark or the opposite axis.

I think it would probably be wisest to just accept the limitations of the graphics systems instead of pursuing perfection in this case.

psoldath commented 2 months ago

If that is the case, I completely agree with you @teunbrand. The case I just brought up is also a very hypothetical case. I mean probably no one will ever have the need to cap one axis at both ends and the other only at one end? Either you would want to cap both axes at both ends or cap both axes only at one end and then everything works out fine with the “square” line end.

teunbrand commented 2 months ago

I've been giving the automatic inheritance some thoughts and here are some options.

Option 1: Use invisible line with square lineend

As mentioned in https://github.com/tidyverse/ggplot2/issues/5978#issuecomment-2211096216. Essentially set axis.line = element_line(colour = "transparent", lineend = "square")

+ Minimally invasive - Theme overrides, such as theme_gray() + theme(axis.line = element_line()) will be broken, as line will stay invisible.

Option 2: Axis guide internally set square lineend

As mentioned in #5982. Essentially give guide_axis() the power to break inheritance of the lineend parameter.

+ Overriding theme works as expected - Pretty invasive

Option 3: Axis children get square lineend

As mentioned in #5983. Give lineend = "square" to axis.line.x and axis.line.y instead of the parent axis.line, along with inherit.blank = TRUE.

+ Minimally invasive - No 'easy' swapping line ends

Option 4: New parent element

The idea here is to create an intermediate abstract theme element in between axis.line and line so that we can give axis.parent = element_line(lineend = "square").

+ Not too invasive - It is not very intuitive, e.g. what should this even be named?