Search code examples
rggplot2plotaxes

ggplot2 annotation_ticks on the outside of the plot region


I try to find an elegant way to insert minor ticks on plots created with ggplot2. I found a function which does almost exactly what I want: https://rdrr.io/github/hrbrmstr/ggalt/src/R/annotation_ticks.r

There is only one drawback: the ticks, like in annotation_logticks, are drawn inside the plot region. I need them to be on the outside.

A solution could be to use negative values for the tick-length. When I do so, the ticks disappear. I assume, that this is due to the default clipping action of ggplot2, which supresses plotting outside the plot region (?) (see also log ticks on the outer side of axes (annotation_logticks), where the clipping is turned off which - unfortunately - leads to ticks exceeding the plot-range).

So: is there an option to modify the annotation_ticks - function in order to produce ticks outside of the plot region, only covering the range of the plot? Ideally, this functionality should by incorporated in the annotate_ticks - function (I don't want to save and then re-arrange the plot; I'd rather build my final plot in one step).


Solution

  • I've found a sort of satisfactory solution to adapting the annotation_ticks function. If we'd simply copy-paste the code from the link you've posted, we can make the following small adjustment near the end in the GeomTicks ggproto object:

    GeomTicks <- ggproto(
      "GeomTicks", Geom,
      # ...
      # all the rest of the code
      # ...
        gTree(children = do.call("gList", ticks), cl = "ticktrimmer") # Change this line
      },
      default_aes = aes(colour = "black", size = 0.5, linetype = 1, alpha = 1)
    )
    

    Then we can write a small function that simply clips the ticks that are outside the range that gets triggered just before drawing by hijacking the S3 generic makeContent in the grid package:

    library(grid)
    
    makeContent.ticktrimmer <- function(x) {
      # Loop over segment grobs
      x$children <- lapply(x$children, function(m) {
        # convert positions to values
        x0 <- convertX(m$x0, "npc", valueOnly = T)
        x1 <- convertX(m$x1, "npc", valueOnly = T)
        y0 <- convertY(m$y0, "npc", valueOnly = T)
        y1 <- convertY(m$y1, "npc", valueOnly = T)
    
        # check if values are outside 0-1
        if (length(unique(x0)) == 1) {
          keep <- y0 >= 0 & y0 <= 1 & y1 >= 0 & y1 <= 1
        } else if (length(unique(y0)) == 1) {
          keep <- x0 >= 0 & x0 <= 1 & x1 >= 0 & x1 <= 1
        } else {
          keep <- TRUE
        }
    
        # Trim the segments
        m$x0 <- m$x0[keep]
        m$y0 <- m$y0[keep]
        m$x1 <- m$x1[keep]
        m$y1 <- m$y1[keep]
        m
      })
      x
    }
    

    And now we can plot:

    g <- ggplot(iris, aes(Sepal.Width, Sepal.Length)) +
      geom_point(aes(colour = Species)) +
      annotation_ticks(long = -1 * unit(0.3, "cm"),
                       mid = -1 * unit(0.2, "cm"),
                       short = -1 * unit(0.1, "cm")) +
      coord_cartesian(clip = "off")
    

    enter image description here

    Besides the first tick on the left being slightly weirdly placed, this seems to work reasonably.

    EDIT: Here is a quick refactoring of the code to work with the native minor breaks instead of calculating minor breaks de novo. The user function:

    annotation_ticks <- function(sides = "b",
                                 scale = "identity",
                                 scaled = TRUE,
                                 ticklength = unit(0.1, "cm"),
                                 colour = "black",
                                 size = 0.5,
                                 linetype = 1,
                                 alpha = 1,
                                 color = NULL,
                                 ticks_per_base = NULL,
                                 ...) {
      if (!is.null(color)) {
        colour <- color
      }
    
      # check for invalid side
      if (grepl("[^btlr]", sides)) {
        stop(gsub("[btlr]", "", sides), " is not a valid side: b,t,l,r are valid")
      }
    
      # split sides to character vector
      sides <- strsplit(sides, "")[[1]]
    
      if (length(sides) != length(scale)) {
        if (length(scale) == 1) {
          scale <- rep(scale, length(sides))
        } else {
          stop("Number of scales does not match the number of sides")
        }
      }
    
      base <- sapply(scale, function(x) switch(x, "identity" = 10, "log10" = 10, "log" = exp(1)), USE.NAMES = FALSE)
    
      if (missing(ticks_per_base)) {
        ticks_per_base <- base - 1
      } else {
        if ((length(sides) != length(ticks_per_base))) {
          if (length(ticks_per_base) == 1) {
            ticks_per_base <- rep(ticks_per_base, length(sides))
          } else {
            stop("Number of ticks_per_base does not match the number of sides")
          }
        }
      }
    
      delog <- scale %in% "identity"
    
      layer(
        data = data.frame(x = NA),
        mapping = NULL,
        stat = StatIdentity,
        geom = GeomTicks,
        position = PositionIdentity,
        show.legend = FALSE,
        inherit.aes = FALSE,
        params = list(
          base = base,
          sides = sides,
          scaled = scaled,
          ticklength = ticklength,
          colour = colour,
          size = size,
          linetype = linetype,
          alpha = alpha,
          ticks_per_base = ticks_per_base,
          delog = delog,
          ...
        )
      )
    }
    

    The ggproto object:

    GeomTicks <- ggproto(
      "GeomTicks", Geom,
      extra_params = "",
      handle_na = function(data, params) {
        data
      },
    
      draw_panel = function(data,
                            panel_scales,
                            coord,
                            base = c(10, 10),
                            sides = c("b", "l"),
                            scaled = TRUE,
                            ticklength = unit(0.1, "cm"),
                            ticks_per_base = base - 1,
                            delog = c(x = TRUE, y = TRUE)) {
        ticks <- list()
    
        for (s in 1:length(sides)) {
          if (grepl("[b|t]", sides[s])) {
    
            xticks <- panel_scales$x.minor
    
            # Make the grobs
            if (grepl("b", sides[s])) {
              ticks$x_b <- with(
                data,
                segmentsGrob(
                  x0 = unit(xticks, "npc"),
                  x1 = unit(xticks, "npc"),
                  y0 = unit(0, "npc"),
                  y1 = ticklength,
                  gp = gpar(
                    col = alpha(colour, alpha),
                    lty = linetype,
                    lwd = size * .pt
                  )
                )
              )
            }
            if (grepl("t", sides[s])) {
              ticks$x_t <- with(
                data,
                segmentsGrob(
                  x0 = unit(xticks, "npc"),
                  x1 = unit(xticks, "npc"),
                  y0 = unit(1, "npc"),
                  y1 = unit(1, "npc") - ticklength,
                  gp = gpar(
                    col = alpha(colour, alpha),
                    lty = linetype,
                    lwd = size * .pt
                  )
                )
              )
            }
          }
    
    
          if (grepl("[l|r]", sides[s])) {
    
            yticks <- panel_scales$y.minor
    
            # Make the grobs
            if (grepl("l", sides[s])) {
              ticks$y_l <- with(
                data,
                segmentsGrob(
                  y0 = unit(yticks, "npc"),
                  y1 = unit(yticks, "npc"),
                  x0 = unit(0, "npc"),
                  x1 = ticklength,
                  gp = gpar(
                    col = alpha(colour, alpha),
                    lty = linetype, lwd = size * .pt
                  )
                )
              )
            }
            if (grepl("r", sides[s])) {
              ticks$y_r <- with(
                data,
                segmentsGrob(
                  y0 = unit(yticks, "npc"),
                  y1 = unit(yticks, "npc"),
                  x0 = unit(1, "npc"),
                  x1 = unit(1, "npc") - ticklength,
                  gp = gpar(
                    col = alpha(colour, alpha),
                    lty = linetype,
                    lwd = size * .pt
                  )
                )
              )
            }
          }
        }
        gTree(children = do.call("gList", ticks))
      },
      default_aes = aes(colour = "black", size = 0.5, linetype = 1, alpha = 1)
    )
    

    Plotting:

    ggplot(iris, aes(Sepal.Width, Sepal.Length)) +
      geom_point(aes(colour = Species)) +
      annotation_ticks(ticklength = -1 * unit(0.1, "cm"),
                       side = "b") +
      coord_cartesian(clip = "off")
    

    enter image description here