Automatically align y-axis text and grid lines to bottom and top of ggplot

Viewed 412

Our company chart layout is based on Excel. In Excel by default the y-axis text and grid lines start at the bottom and end at the top of the chart. Using R and ggplot2 the default behavior seems that only labels which are between the min and max of the data are shown.

Manually, I would just use limits as in the example below to achieve the desired outcome. However, is there a way to achieve this behavior automatically? Doing it manually for each chart is not an option as we have hundreds of charts where the data changes over time.

library( ggplot2 )

# transform data to make the problem more obvious
df <- cars
df$speed  <- df$speed + 4

# default ggplot behaviour: y-axis grid lines and ticks are placed where they fit best
ggplot( df, aes( x = dist, y = speed ) ) + 
  geom_line() +
  ggtitle( "Default behavior: y-axis grid lines and text inside data-range" ) +
  scale_y_continuous( expand = c( 0, 0 ) ) +
  theme( panel.background    = element_rect( fill   = "white" ),
         panel.border        = element_blank(),
         panel.grid.major.y  = element_line( colour = "#d9d9d9",
                                             size   = 0.001 ),
         axis.ticks.x        = ggplot2::element_line( color = "#d9d9d9" ),
         axis.ticks.y        = ggplot2::element_blank() )

# desired outcome: y-axis text and grid lines start at bottom and end at top (achieved with expand and limits)
ggplot( df, aes( x = dist, y = speed ) ) + 
  geom_line() +
  ggtitle( "Disired outcome: y-axis grid lines and text at bottom and top" ) +
  scale_y_continuous( expand = c( 0, 0 ), 
                      limits = c( 5, 30 ) ) +
  theme( panel.background    = element_rect( fill   = "white" ),
         panel.border        = element_blank(),
         panel.grid.major.y  = element_line( colour = "#d9d9d9",
                                             size   = 0.001 ),
         axis.ticks.x        = ggplot2::element_line( color = "#d9d9d9" ),
         axis.ticks.y        = ggplot2::element_blank() )

Default ggplot2 behavior: note that y-axis text and grid-lines are not aligned to bottom and top of the chart

Default ggplot2 behavior: note that y-axis text and grid-lines are not aligned to bottom and top of the chart

Desired outcome: y-axis text and grid-lines are at the bottom and top of the chart (values 5 and 30)

Desired outcome: y-axis text and grid-lines are at the bottom and top of the chart (values 5 and 30)

1 Answers

Because the limits argument to a scale can accept a function that will be applied to the range of the data, we can make a function that sets the limits automatically to nicer numbers. Here is an example function that you could use, but I haven't tested this for all possible cases.

library( ggplot2 )

# transform data to make the problem more obvious
df <- cars
df$speed  <- df$speed + 4

nicelimits <- function(x) {
  # x is the range of the data
  # get breaks under default breaks behaviour (breaks are not known in advance)
  breaks <- scales::extended_breaks()(x)
  # Take the difference between breaks (should be constant)
  width  <- diff(breaks)[1]
  # Do something like `floor()`, but with `width`-units
  start <- (x[1] %/% width) * width
  # Do something like `ceiling()`, but with `width`-units
  end <- (c(x[2] %/% width) + 1) * width
  c(start, end)
}


ggplot( df, aes( x = dist, y = speed ) ) + 
  geom_line() +
  scale_y_continuous( expand = c( 0, 0 ),
                      limits = nicelimits) +
  theme( panel.background    = element_rect( fill   = "white" ),
         panel.border        = element_blank(),
         panel.grid.major.y  = element_line( colour = "#d9d9d9",
                                             size   = 0.001 ),
         axis.ticks.x        = ggplot2::element_line( color = "#d9d9d9" ),
         axis.ticks.y        = ggplot2::element_blank() )

Created on 2020-12-20 by the reprex package (v0.3.0)

EDIT: As @stefan in the comment pointed out, this would also make nice limits and is less custom code:

nicelimits <- function(x) {
  range(scales::extended_breaks(only.loose = TRUE)(x))
}

A difference I noticed is that the first function will extend the upper limit by + width if the upper limit is at a break, whereas the latter function will return the natural upper limit in this case.

Related